This is an interesting idea, especially the parallel to Go's error handling model (which I like). Are you aware of any frameworks that use a similar patten internally?
I like the idea and would be absolutely ecstatic to use it (I've ran into too many cases where this would've been invaluable), but I'd rather it return something more like {caught, value} where caught is a boolean and value is the returned/caught value. That way, you can still handle throw undefined. Also, engines could recognize the pattern statically and potentially elide the object allocation entirely even at baseline bytecode for stuff like const {caught, value} = try JSON.parse(someString), something they can't do for arrays since Array.prototype[Symbol.iterator] could be overridden.
The feature itself would be super easy to spec and similarly easy to implement.
For the original:
TryExpression :: `try` PrimaryExpression
Let E be the result of evaluating PrimaryExpression.
Let error be undefined.
Let result be undefined.
If E.[[Type]] is normal, let result be E.[[Value]].
Else, if E.[[Type]] is throw, let error be E.[[Value]].
Else, return Completion(E).
Return NormalCompletion(CreateArrayFromList(« error, result »)).
For my alternative:
TryExpression :: `try` PrimaryExpression
Let E be the result of evaluating PrimaryExpression.
It'd be complicated to transpile in either case, but that's about it. An engine would probably take the obvious approach of setting up an internal try/catch block to deal with this.
BTW, there's some other language precedent for this, mostly for my alternative of a {caught, value} pair:
Lua does almost exactly what I suggest here for their primary way of handling exceptions: pcall(func, ...) calls func(...) and returns true, result, ... for success, false, error for caught errors. (They support multiple return values, but you can't throw multiple errors at once.) So this exists as pretty strong precedent.
Rust does simple error handling via returning Result<T, E> (with Ok(T) and Err(E) variants), but its std::panic::catch_unwind catches panics (its stack-unwinding exceptions) and converts them to an Ok(result) on success, Err(error) on error. They recommend using the Result<T, E> directly for what you would normally use try/catch for, and only this if you have no other option (like avoiding UB with C interop).
Update: I've created my own separate proposal which attempts to address much of the original gist and other cross-cutting concerns. I've personally ran into issues around try/catch a few too many times to count, and it's not so much a nice-to-have as fixing a frequent source of papercuts and annoyances for me.
@claudiameadows that's an interesting idea to use an object with 2 properties: caught and value which type can be one of result or an Error. But I think that situations like:
const throwNone = () => {
throw undefined;
};
const [e, data] = tryCatch(throwNone);
// e is undefined
// data also undefined
can be compared to:
function read(callback) {
fs.readFile('some file', 'utf8', (e, data) => {
// let's suppose that e is undefined
// because of cached throwUndefined
if (e)
return callback(e);
// do some data processing
// but wait e can be undefined (!)
// and everything will crash (?)
const reversed = data.reverse();
callback(null, data);
});
}
Node.js maintainers doesn't describe such scenario in documentation . So if it throw undefined happens, it's rare and very strange situation and a bug in 99% of cases.
Why make architecture design on a very rare cases? I agree the code will be a little bit more stable, on 1%, but it will be much less usable and obvious.
Compare:
const [errorRead, dataRead] = try readFile();
const [errorWrite] = try writeFile();
if (errorRead)
throw errorRead;
if (errorWrite)
throw errorWrite;
process(dataRead);
with:
const {caught, value} = try readFile();
// we can't reuse variable names
const readData = try readFile();
const writeData = try writeFile();
if (readData.caught) {
// readData.value is an Error;
// but the name tells us nothing about it
throw readData.value;
}
if (writeData.caught)
throw writeData.value;
// that's not intuitive and not obvious solution
// to use one name for two opposite things
// and propagate this among developers
// since it's not really readable
process(writeData.value);
// we can rename variables while destructuring
// and we should do it every time to make things more readable
// 4 lines instead of 1 which gave us array destructuring
const {
caught: caughtWrite,
value: valueWrite,
} = try writeFile();
So would be definitely better to use array destructuring for such things:
const [caught, value] = try readFile();
It will give ability to name variables in a short and usable way.
And last things: word caught is not obvious and I can't remember I saw it in other languages, it's much harder to write, then error and data used all the time in most languages. Anyways main thing to know is whether we have an error or not, and not stimulating developers to use constructions like:
throw undefined;
Because they will have ability to use it, why not if caught can caught it. I think that if we have ability to improve language we should do everything we can to make using bad practices not usable, and having no sense even try, like all the benefits we have using strict mode.
Read my "Rationale" section in the repo I linked to. It specifically recommends not destructuring when you need names like that.
Also, language precedent favors mine over yours - Go is the exception, not the rule, and I've heard and read frequent complaints over its idiom, unlike Rust's enum Result<T, E> { Ok(T), Err(E) } or the Maybe/Either idioms used in Haskell. (In fact, those typically get praised.)
Mine also synergizes well with the pattern matching proposal, and unlike yours, it doesn't require you to check success first: when [err, undefined] matches before when [undefined, result] when the inner expression evaluates to undefined.
@claudiameadows, yes I totally agree with you, if a value is undefined, we got both results an error is undefined and a value is undefined. But shouldn't we understand first was exception caught or not?
In the code:
const {caught, value} = try readFileSync('some file');
// wait a minute, what if there was read error
// that says we have no permission to read file
if (!value)
try writeFileSync('some file');
// we should definitely check was exception caught or not
// all our logic relies on this so the check will be one of
if (value instanceof Error)
throw value;
// or
if (caught)
throw value;
And again, value could be undefined, so we can't use instanceof, we should definitely check caught.
Undestand me right, I'like conception of Monad from Haskell, and the way Promises works, I just think that this implementation of Monad is not quite right, because the way how they works. If you talking about EatherMonad, it can be something like:
But we already have promises for this purpose: to have ability not use if statement. But current try implementations doesn't provide a way to avoid if, so I don't think that this can be compared to Monad.
If you just want to chain them, you might as well just use try/catch instead - it'll be so much easier.
I'm not suggesting anything like promises or monads, just referring to a data type common to those languages (and Lua, which does mine but returns 2 values instead, similar to mine). In fact, although Rust's Result has stuff like .map, it's far more idiomatic to just pattern-match it in most cases where do_something()? (formerly try!(do_something())) isn't sufficient. It's also why I said this synergizes well with the pattern matching proposal.
I can see the need for such a feature, but I dislike both the solutions suggested. An [errorOrNull, nullOrValue] tuple has the problem with falsy errors, an {isError, errorOrValue} tuple (or object) has a polymorphic type as its second component which is awkward to deal with and awkward to descriptively name.
Why not ask for a proper Result type, like the one implemented in Rust? Or Haskell's Either? That should be what a try expression returns. It could have all the useful utility methods associated with such a data structure, and might have .isError, .error and .value getters. No need to write code with destructuring and if statements, no weird error ignoring patterns like [_, result] = try …; console.log(result) (yes, I've seen people doing that already with the libraries linked in the OP).
Btw, a large share of use case I've seen is asynchronous code. But people seem to forget that promises actually have the necessary utilities built in:
Instead of writing
This is an interesting idea, we ca have something like this:
cosnt readEither = try await readFile();
if (readEather.isError)
return;
console.log(readEther.value);
This definitely helps to catch cases like throw undefined, but do we really want to handle such cases?
We already have:
try {
JSON.parse('x');
} catch {
// we already know what the problem is,
// and it's OK for us to handle it in a way we want
}
So this adds ability for developers to use constructions like throw undefined and think that this is a good design pattern. I think that we should just forbid it, and everything will be fine.
You right, we can still use promise chain, but isn't async-await is the solution to callback and promise hell? If developer have ability to write code that smells, he will :).
await readFile()
// this is an anti-pattern because it wan't catch next `then` chain
// it want even catch errors in resolve
.then(function resolve() {}, function reject() => {})
But let's suppose we are going to read and write file using good old promises:
await readFile(name)
.then((data) => {
return writeFile(name, data);
},
(error) => {
if (error.code === 'EACESS') {
// do something async again
}
};
We have a lot code for such a simple case, isn't this example simpler:
const [e, data] = try await readFile(name)
if (error.code === 'EACESS') {
// do something async again
}
const [e] = await writeFile(name, data);
if (error) {
// do something
}
We got less noise of functions, indentation and all the things.
And also this want help us to work with synchronous code, should we wrap to a promise JSON.parse? Or just use it with:
const [e, data] = try JSON.parse('xxx');
I think destructuring should not be a problem, v8 team just improved speed of object destructuring, so array destructuring will have normal speed as well, and it will help us to stop using such constructions:
try {
} catch (e) {
}
Which are not helps at all to support a big production ready code base :). All it's do is wraps a function, why should it be so wordily, and why an error should be an argument, if we can get it on top near result.
How would you imagine to forbid this? Have try expression() throw an exception (like new TypeError("don't throw primitives")) when expression() throws undefined? That would seem quite counterproductive to me.
Promises (I assume you mean then syntax?) are not hellish. A hell is eternal, but from promises you can always return (no matter how deep you are in). That is what sets them apart from callback hell.
No, this is not an antipattern! It is a very useful and powerful pattern to do exactly what we want, not having the onreject callback handle exceptions/rejections from the onfulfill callback. You cannot achieve this properly with try/catch syntax, and even with the proposed try expressions you need an extra if condition (or better, pattern match).
No, this example is wrong: it throws an exception when readFile succeeded and error is null. To do it correctly, you'd do
const [error, data] = try await readFile(name)
if (error) {
if (error.code === 'EACESS') {
… // do something async again
} else {
throw error; // or something
}
} else {
…
}
That's exactly as much indentation as in the promise then code. The only difference is that the error case comes first while in the promise syntax the onrejected callback comes second. Also I don't consider the functions as noise, they are very useful to introduce proper scopes for error and data, just as pattern matching would.
For using early returns/throws in the if block (and no else), the equivalent promise code could use an await.
const [error, data] = try await readFile(name)
if (error && error.code === 'EACESS') {
// do something async again
}
And no need to make such a deep nested conditions, anyways is much simpler.
No, this is not an antipattern! It is a very useful and powerful pattern to do exactly what we want, not having the onreject callback handle exceptions/rejections from the onfulfill callback. You cannot achieve this properly with try / catch syntax, and even with the proposed try expressions you need an extra if condition (or better, pattern match).
On link you provided we can read this:
However, this pattern is actually very useful: When you want to handle errors that happened in exactly this step, and you want to do something entirely different when no error happened - i.e. when the error is unrecoverable. Be aware that this is branching your control flow. Of course, this is sometimes desired.
I agree that sometimes it could be useful to handle an error case of exactly one promise, this is what try-catch proposal does, it also handle one promise only, but with a couple of differences.
Let's consider next example:
This is a very simple operation, we just copy two files, and handle it's errors independently, and this is very useful for us, because that's what we want. What I want to see, is how are you going to test such a code :)? It's really hard to read, edit, and test such code, if you will need one more step between readFile and writeFile it would be a challenge to add it, because you see what a mess it is now.
So definitely what we need is to split this into a couple named functions, each will do some simple stuff, so we can test and modify whatever will want:
Now we can test this, but it's not really readable, this is why then(onSuccess, onError) is an anti-pattern, no matter how you write code, it looks like a mess. With try-await we can write logic in a linear manner, without any noise, all steps written step-by-step, this logic is quite simple and much easier to test and extend then promise then(onSuccess, onError) pattern:
async function copy(name) {
const [e, data] = try await readFile(name);
if (e && e.code === 'EXIST')
return;
if (e)
return console.error(e.message);
const [e] = try await writeFile(name, data);
if (e)
return console.error(e.message);
console.log('file written');
}
There is much less indentation :)
Error should came first, so no one forget to handle it, and because of node.js callback style every js developer knows.
Why do we need so much scopes? Does a lot scopes helps us to avoid errors? Does it works fast? What purpose of a lot nested scopes, how does it help us programming? I think less scopes we have - less error prone would be our code.
I don't quite understand what syntax are you suggesting for using try-catch with pattern matching. Could you please show an example?
I'd just like to throw in my two cents here and voice my support for this as a valuable proposal; it certainly appears to me to be a legitimate shortcoming of the language for which many people are independently arriving at their own (very similar) solutions.
As another data point, my own approach uses a bimodal variadic function with (fn, ...args) as its parameters:
My solution of using an {isError, errorOrValue} (with smaller names, of course) is isomorphic to yours, just using a POJO instead of a built-in Result class. And no, destructuring isn't required for mine, either. Also, errors are themselves a type of value - "value" doesn't imply origin. (I'm aligning with the ES spec in terminology - its completions use [[Value]] for both [[Type]]: return and [[Type]]: throw, and [[Value]] is just what's directly exposed to ES code.)
I considered a built-in Completion (a variant of it), but found that for most things, it didn't really offer anything that a {caught, value} didn't. I wouldn't be against a Result class provided it was 1. fairly simple and 2. it had a method like get() or unwrap() that basically translated to returning if success, throwing if error. But absent that, my variant does carry the benefit of being much easier to optimize, something that's a bit easier for implementors to get behind.
BTW, the two variants, using a .then and using if/else statements, don't differ in code size as much as you might think. I find myself more often wanting to return early, and it's worth noting that even Rust normally uses result? (syntax sugar for roughly match result { Ok(v) -> v, e -> return e }), and most of the time when that's not sufficient, they almost always pattern match rather than using .map or .or because they need to break or return early. Most of the time when I see those two or .flat_map() used, it's in cases where we just rely on exception unwinding to do that implicitly. And mine was designed with the ES pattern matching proposal in mind, so most of that boilerplate will go away if that hits stage 4 (and the committee seems very committed to that for a stage 1 proposal).
While true, promises don't provide very good introspection capabilities, so it can get a little boilerplatey and odd-looking. This would still synergize well with that and provide a clearer picture on the intent. To take a couple examples of mine:
// My proposal
async function asyncTask() {
const userReq = try await UserModel.findById(1)
if (userReq.caught) throw new CustomerError('No user found')
const user = userReq.value
const taskReq = try await TaskModel({userId: user.id, name: 'Demo Task'})
if (taskReq.caught) throw new CustomError('Error occurred while saving task')
if (user.notificationsEnabled) {
const notificationReq = try await NotificationService.sendNotification(user.id, 'Task Created')
if (notificationReq.caught) console.error('Just log the error and continue flow')
}
}
// Using `.catch` to translate errors
async function asyncTask() {
const user = await UserModel.findById(1)
.catch(() => { throw new CustomerError('No user found') })
await TaskModel({userId: user.id, name: 'Demo Task'})
.catch(() => { throw new CustomError('Error occurred while saving task') })
if (user.notificationsEnabled) {
await NotificationService.sendNotification(user.id, 'Task Created')
.catch(() => { console.error('Just log the error and continue flow') })
}
}
// With promises only
async function asyncTask() {
return UserModel.findById(1)
.catch(() => { throw new CustomerError('No user found') })
.then(user => {
return TaskModel({userId: user.id, name: 'Demo Task'})
.catch(() => { throw new CustomError('Error occurred while saving task') })
.then(() => user.notificationsEnabled
? NotificationService.sendNotification(user.id, 'Task Created')
.catch(() => { console.error('Just log the error and continue flow') })
: null)
})
}