As we saw in Part 1, error handling in callback-based asynchronous code gets messy quickly, and loses many of the qualities of synchronous code that make it familiar and easier to reason about. In Part 2, we introduced Promises and saw how they restore call-and-return semantics, allow errors to propagate up the stack similarly to synchronous exceptions, and generally provide a cleaner approach to managing asynchrony, especially when handling errors.
Try/catch/finally
In synchronous code, try/catch/finally
provides a simple and familiar, yet very powerful idiom for performing a task, handling errors, and then always ensuring we can clean up afterward.
Here’s a simple try/catch/finally
example in the same vein as the original getTheResult()
from Part 1:
1 2 3 4 5 6 7 8 9 10 11 12 |
|
As we’ve seen, attempting to simulate even the try/catch
via a callback-based approach is fraught with pitfalls. Adding the notion of finally
, that is, guaranteed cleanup, only makes things worse.
Using Promises, we can build an approach that is analogous to this familiar try/catch/finally
idiom, without deep callback structures.
Try/catch
Let’s start with a simpler version of example above that only uses try/catch
, and see how we can use Promises to handle errors in the same way.
1 2 3 4 5 6 7 8 9 10 |
|
And now, as in Part 2, let’s assume that thisMightFail()
is asynchronous and returns a Promise. We can use then()
to simulate catch
:
1 2 3 4 5 6 7 8 9 10 11 12 |
|
Waitaminit, that’s even less code than using try/catch
! What’s going on here?
Propagating a success
This example introduces two very important facts about how Promises behave. The first of which is:
If no onFulfilled
handler is provided to then()
, the fulfillment value will propagate through unchanged to the returned Promise.
We’re not supplying an onFulfilled
handler when calling then()
. This means that a successful result from thisMightFail()
simply will propagate through and be returned to the caller.
Handling an error
The other important behavior is:
A handler may produce either a successful result by returning a value, or an error by throwing or returning a rejected promise.
We are supplying an onRejected
handler: recoverFromFailure
. That means that any error produced by thisMightFail
will be provided to recoverFromFailure
. Just like the catch
statement in the synchronous example, recoverFromFailure
can handle the error and return
a successful result, or it can produce an error by throwing or by returning a rejected Promise.
Now we have a fully asynchronous construct that behaves like its synchronous analog, and is just as easy to write.
Adding some sugar
Hmmm, but what about that null
we’re passing as the first param? Why should we have to type null
everywhere we want to use this asynchronous try/catch
-like construct? Can’t we do better?
While the primary interface to a Promises/A+ Promise is its then()
method, many implementations add convenience methods, built, with very little code, upon then()
. For example, when.js Promises provide an otherwise()
method that allows us to write this example more intuitive and compactly:
1 2 3 4 5 6 7 |
|
Now we have something that reads nicely!
Adding finally
Let’s add finally
back into the mix, and see how we can use Promises to achieve the same result for asynchronous operations.
1 2 3 4 5 6 7 8 9 10 11 12 |
|
First, let’s note that there are some very interesting things about this seemingly simple finally
block. It:
- will always execute after
thisMightFail
and/orrecoverFromFailure
- does not have access to the value returned by
thisMightFail
, or to the thrown exception (e
), or to the value returned byrecoverFromFailure
1. - cannot, in this case, transform an exception thrown by
recoverFromFailure
back into a successful result2. - can change a successful result (returned by either
thisMightFail
orrecoverFromFailure
) into a failure ifalwaysCleanup
throws an exception. - can substitute a new exception in place of one thrown by
recoverFromFailure
. That is, if bothrecoverFromFailure
andalwaysCleanup
throw exceptions, the one thrown byalwaysCleanup
will propagate to the caller, and the one thrown byrecoverFromFailure
will not.
This seems fairly sophisticated. Let’s return to our asynchronous getTheResult
and look at how we can achieve these same properties using Promises.
Always execute
First, let’s use then()
to ensure that alwaysCleanup
will execute in all cases (for succinctness, we’ll keep when.js’s otherwise
):
1 2 3 4 5 6 7 |
|
That seems simple enough! Now, alwaysCleanup
will be executed in all cases:
- if
thisMightFail
succeeds, - if
thisMightFail
fails andrecoverFromFailure
succeeds, or - if
thisMightFail
andrecoverFromFailure
both fail.
But wait, while we’ve ensured that alwaysCleanup
will always execute, we’ve violated two of the other properties: alwaysCleanup
will receive the successful result or the error, so has access to either/both, and it can transform an error into a successful result by returning successfully.
Don’t access result/error
We can introduce a wrapper to prevent passing the result or error to alwaysCleanup
:
1 2 3 4 5 6 7 8 9 10 11 12 |
|
Now we’ve achieved one of the two properties we had lost: alwaysCleanup
no longer has access to the result or error. Unfortunately, we had to add some code that feels unnecessary. Let’s keep exploring, though, to see if we can achieve the remaining property.
Don’t change the result
While alwaysCleanupWrapper
prevents alwaysCleanup
from accessing the result or error, it still allows alwaysCleanup
to turn an error condition into a successful result. For example, if recoverFromFailure
produces an error, it will be passed to alwaysCleanupWrapper
, which will then call alwaysCleanup
. If alwaysCleanup
returns successfully, the result will be propagated to the caller, thus squelching the previous error.
That doesn’t align with how our synchronous finally
clause behaves, so let’s refactor:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
|
In both the success and failure cases, we’ve preserved the outcome: alwaysCleanupOnSuccess
will execute alwaysCleanup
but not allow it to change the ultimate result, and alwaysCleanupOnFailure
will also execute alwaysCleanup
and always rethrow the original error, thus propagating it even if alwaysCleanup
returns successfully.
The remaining two properties
Looking at the refactor above, we can also see that the remaining two properties hold:
In alwaysCleanupOnSuccess
, if alwaysCleanup
throws, the return result
will never be reached, and this new error will be propagated to the caller, thus turning a successful result into a failure.
In alwaysCleanupOnFailure
, if alwaysCleanup
throws, the throw error
will never be reached, and the error thrown by alwaysCleanup
will be propagated to the caller, thus substituting a new error.
Finally?
With this latest refactor, we’ve created an asynchronous construct that behaves like its familiar, synchronous try/catch/finally
analog.
More sugar
Some Promise implementations provide an abstraction for the finally
-like behavior we want. For example, when.js Promises provide an ensure()
method that has all of the properties we achieved above, but also allows us to be more succinct:
1 2 3 4 5 6 7 8 |
|
Finally
We started with the goal of finding a way to model the useful and familiar synchronous try/catch/finally
behavior for asynchronous operations. Here’s the simple, synchronous code we started with:
1 2 3 4 5 6 7 8 9 10 11 12 |
|
And here is the asynchronous analog we ended up with something that is just as compact, and easily readable:
1 2 3 4 5 6 7 8 |
|
Try/finally
Another common construct is try/finally
. It is useful in executing cleanup code, but always allowing exceptions to propagate in the case where there is no immediate recovery path. For example:
1 2 3 4 5 6 7 8 9 10 |
|
Now that we’ve modeled a full try/catch/finally
using Promises, modeling try/finally
is trivial. Similarly to simply cutting out the catch
above, we can cut out the otherwise()
in our Promise version:
1 2 3 4 5 6 7 |
|
All of the constraints we’ve been attempting to achieve still hold—this asynchronous construct will behave analogously to its synchronous try/finally
counterpart.
Using it
Let’s compare how we would use the synchronous and asynchronous versions of getTheResult
. Assume we have the following two pre-existing functions for showing results and errors. For simplicity, let’s also assume that showResult
might fail, but that showError
will not fail.
1 2 3 4 5 |
|
Synchronous
First, the synchronous version, which we might use like this:
1 2 3 4 5 6 |
|
It’s quite simple, as we’d expect. If we get the result successfully, then we show it. If getting the result fails (by throwing an exception), we show the error.
It’s also important to note that if showResult
fails, we will show an error. This is an important hallmark of synchronous exceptions. We’ve written single catch
clause that will handle errors from either getTheResult
or showResult
. The error propagation is automatic, and required no additional effort on our part.
Asynchronous
Now, let’s look at how we’d use the asynchronous version to accomplish the same goals:
1 2 3 |
|
The functionality here is analogous, and one could argue that visually, this is even simpler than the synchronous version. We get the result, or rather in this case, a Promise for the result, and when the actual result materializes (remember, this is all asynchronous!), we show it. If getting the result fails (by rejecting resultPromise), we show the error.
Because Promises propagate errors similarly to exceptions, if showResult
fails, we will also show an error. So, the automatic the behavior here is also parallel to the synchronous version: We’ve written single otherwise
call that will handle errors from either getTheResult
or showResult
.
Another important thing to notice is that we are able to use the same showResult
and showError
functions as in the synchronous version. We don’t need artificial callback-specific function signatures to work with promises—just the same functions we’d write anyway.
Putting it all together
We’ve refactored our getTheResult
code to use Promises to eumlate try/catch/finally
, and also the calling code to use the returned Promise to handle all the same error cases we would handle in the synchronous version. Let’s look at the complete Promise-based asynchronous version of our code:
1 2 3 |
|
1 2 3 4 5 |
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
|
The end?
Of course, there will always be differences between synchronous and asynchronous execution, but by using Promises, we can narrow the divide. The synchronous and Promise-based versions we’ve constructed not only look very similar, they behave similarly. They have similar invariants. We can reason about them in similar ways. We can even refactor and test them in similar ways.
Providing familiar and predictable error handling patterns and composable call-and-return semantics are two powerful aspects of Promises, but they are also only the beginning. Promises are a building block on which fully asynchronous analogs of many other familiar features can be built easily: higher order functions like map
and reduce
/fold
, parallel and sequential task execution, and much more.
-
You might be wondering why we want this property. For this article, we’re choosing to try to model
finally
as closely as possible. The intention of synchronousfinally
is to cause side effects, such as closing a file or database connection, and not to transform the result or error by applying a function to it. Also, passing something that might be a result or might be an error toalwaysCleanup
can be a source of hazards without also tellingalwaysCleanup
what kind of thing it is receiving. The fact thatfinally
doesn’t have a “parameter”, likecatch
means that the burden is on the developer to grant access to the result or error, usually by storing it in a local variable before execution enters thefinally
. That approach will work for these promise-based approaches as well.↩ -
Note that
finally
is allowed to squelch exceptions by explicitly returning a value. However, in this case, we are not returning anything explicitly. I’ve never seen a realistic and useful case for squelching an exception that way.↩