In The Problem with Promises in Javascript I looked at how the API and design of promises felt casually dangerous to writing responsible and safe code.
I included a section proposing a library (fPromise) which used a functional approach to overcome these problems.
After it was published, Mike Sherov was kind enough to respond to a tweet about the article and offered his take on it: that I under-appreciated the value of the async/async syntax (that it abstracts out the tricky then/catch API, and returns us to "normal" flow) and that the problems that remain (ie, bad error handling) are problems with JavaScript itself (which TC39 is always evolving).
I am very grateful for his thoughts on this, and helping elucidate a counter-narrative to the one I proposed!!
Here's what Mike says:
Lets look at an example from the Problems article:
const handleSave = async rawUserData => {
try {
const user = await saveUser(rawUserData);
createToast(`User ${displayName(user)} has been created`);
} catch {
createToast(`User could not be saved`));
}
};
I had balked at this, as the try was "catching" too much, and used the point that if displayName
threw, the user would be alerted that no user was saved, even though it was. But - though the code is a bit monotonous - this is overcome-able - and was a bad job out me for not showing.
If our catch is smart about error handling, this goes away.
const handleSave = async rawUserData => {
try {
const user = await saveUser(rawUserData);
createToast(`User ${displayName(user)} has been created`);
} catch (err) {
if (err instanceof HTTPError) {
createToast(`User could not be saved`));
} else {
throw err;
}
}
};
And if the language's evolution includes better error handling, this approach would feel better:
// (code includes fictitious catch handling by error type)
const handleSave = async rawUserData => {
try {
const user = await saveUser(rawUserData);
createToast(`User ${displayName(user)} has been created`);
} catch (HTTPError as err) {
createToast(`User could not be saved`));
}
};
While this is much better, I still balk about having too much in the try. I believe catch's should only catch for the exception they intend to (bad job out of me in the original post), but that the scope of what is being "tried" should be as minimal as possible.
Otherwise, as the code grows, there are catch collisions:
// (code includes fictitious catch handling by error type)
const handleSave = async rawUserData => {
try {
const user = await saveUser(rawUserData);
createToast(`User ${displayName(user)} has been created`);
const mailChimpId = await postUserToMailChimp(user);
} catch (HTTPError as err) {
createToast(`Um...`));
}
};
So here is a more narrow approach about what we are catching:
// (code includes fictitious catch handling by error type)
const handleSave = async rawUserData => {
try {
const user = await saveUser(rawUserData);
createToast(`User ${displayName(user)} has been created`);
try {
const mailChimpId = await postUserToMailChimp(user);
createToast(`User ${displayName(user)} has been subscribed`);
} catch (HTTPError as err) {
createToast(`User could not be subscribed to mailing list`));
}
} catch (HTTPError as err) {
createToast(`User could not be saved`));
}
};
But now we find ourselves in a try/catch block "hell". Lets try to get out of it:
// (code includes fictitious catch handling by error type)
const handleSave = async rawUserData => {
let user;
try {
user = await saveUser(rawUserData);
} catch (HTTPError as err) {
createToast(`User could not be saved`));
}
if (!user) {
return;
}
createToast(`User ${displayName(user)} has been created`);
let mailChimpId;
try {
await postUserToMailChimp(rawUserData);
} catch (HTTPError as err) {
createToast(`User could not be subscribed to mailing list`));
}
if (!mailChimpId) {
return;
}
createToast(`User ${displayName(user)} has been subscribed`);
};
Despite that this is responsible and safe code, it feels the most unreadable and like we're doing something wrong and ugly and working uphill against the language. Also, remember this code is using a succinct fictitious error handler, rather than the even more verbose (real) code of checking the error type and handling else re-throwing it.
Which is (I believe) exactly Mike's point, that error handling (in general) needs improved, and exactly my point - that doing async code with promises is casually dangerous, as it makes dangerous code clean and ergonomic, and responsible code less readable and intuitive.
So, how could this be better? What if there was -
Await catch handling
What if we could do something like this?
// (code includes fictitious await catch handling by error type)
const handleSave = async rawUserData => {
const [user, httpError] = await saveUser(rawUserData) | HTTPError;
if (httpError) {
return createToast(`User could not be saved`));
}
createToast(`User ${displayName(user)} has been created`);
const [id, httpError] = await saveUser(rawUserData) | HTTPError;
if (httpError) {
return createToast(`User could not be subscribed to mailing list`));
}
createToast(`User ${displayName(user)} has been subscribed`);
};
This reads nicely and is safe and responsible! We are catching exactly the error type we intend to. Any other error causes the await to "throw".
And it could be used with multiple error types. Eg,
// (code includes fictitious catch handling by error type)
const [user, foo, bar] = await saveUser(rawUserData) | FooError, BarThing;
How close can we get to this in userland?
Pretty close. Introducing fAwait (as in functional-await).
const {fa} = require('fawait');
const [user, httpError] = await fa(saveUser(rawUserData), HTTPError);
const [user, foo, bar] = await fa(saveUser(rawUserData), FooError, BarThing);
Thanks for reading!
craigmichaelmartin / fawait
A javascript library for making await more functional
fAwait
What is fAwait
?
fAwait
is a javascript library for working with the await
syntax in a more functional way.
How To Use It
Wrap your promise with the fa
function, and provide error types you want to catch, and you'll receive an array you can unpack to those values. If error types are provided, and the promise rejects with one not specified, it will be thrown. Error types can be the built-ins, or your own custom error types. If no error types are provided, all will be caught.
const { fa } = require('fawait');
const [data, myError] = await fa(promise, MyError);
// If the promise resolves, data will be defined.
// If the promise rejects with my own custom error, myError will be defined.
// If the promise rejects with any other error, the await will
…
Top comments (8)
I like the Option pattern here but if
saveUser
throws,fa
won't catch it because it hasn't been called yet. This requiressaveUser
to contain try/catch logic.Could this be resolved by
fa
taking a function as a param, instead?Hm?
fa
takes a promise, so it's all good. In the example,saveUser
is returning a promise - no try/catch necessary there. You can check out the source (12 lines) and the tests to see examples github.com/craigmichaelmartin/fawaitDerp! Yup, I forgot it takes a promise, even when it was shown right above
saveUser
returns a promise :)Nice utility 👍
Your "bad" example is not very honest (the one before your section "Await catch handling"), compared to your "good" example (the one just after your section "Await catch handling").
It should be something like that:
And in that case, I actually prefer it...
Hm, interesting. I didn't mean for it to be dishonest. I've always held return-ing inside a catch to be taboo and so didn't even consider it here. Things can get crazy (for example) if a finally clause is then attached. I can't even tell you the what the result would be between js evaluating the finally and returning the value from the catch, which is probably why I've never let myself return in a catch... but maybe I should update the example? Or maybe this adds more to the "casually dangerous" / less readable point?
Anyway, thanks for calling it out!!
Some of those issues can be solved through refactoring efforts and abstraction. JS lacks typed errors in the catch statement, and while it would be nice to catch specific error types, you can still do that in the catch statement.
As JS evolves, it takes well-worn paradigms that are present and oft used in strongly typed languages. This is often a breaking point for devs that may only be experienced with JS, and not have that wider exposure. JS is an expressive language, but to be that way, it compromises on some of the aspects that strongly typed languages have that are baked in.
I don't agree that catching an error and returning it as a var reference is useful, it's not particularly testable, and further abstracts the api logic from the developer.
You didn't include in your examples, one example where the api request throws an error. Looking at a stack trace, it's polluted with try/catch/throw.
It seems that what you really want is to catch errors by type. All code examples revolve around this simple and good idea.
I must then make the horrible assertion that you must switch languages.
It's not only that js doesn't have catching by type, but that it goes against the design of the language itself.
Js is based on duck typing, an idea that you should never ask the type of an object and instead check whether it behaves the way you want.
I'm on your team - it sucks not being able to catch by type, and that's why I prefer statically typed languages - but as for js, embrace the paradigm.
On another note, the multiples try/catch blocks and unreadable code is correctly solved by splitting your method.
Not so much in es6 with object types. It's something that was present much earlier, but the concept that JS should just 'do' and not ask questions isn't relevant to its modern state. Sure, 15 years ago when most of the existing tools didn't exist, but not today.