Written by Lewis Cianci✏️ A long time ago, computing was almost entirely synchronous. You’d call a function, and it would return almost immediately. Then, as time went on, we started depending on resources that would take a little while to give a response.
In the early days, we’d use callbacks for other functions to get called after our asynchronous function had completed, but this caused “callback hell”, where it would be hard to track what code was executing at a given time, and difficult to follow the logical progression of an app's execution. Fortunately, easier asynchronous operations were introduced to JavaScript and TypeScript in the form of promises.
It may seem weird to use a word that conveys a sense of human emotion, ie: “I promise to buy you flowers”, but it certainly captures the temporal aspect. Making a promise is constructing a contract with the recipient that you will do something, but that thing is always in the future.
Promising someone that you’ll stand next to them and talk to them when you are standing next to them talking is uncanny at best, and slightly crazed at worst. But sometimes, we need to break our promises. The flower shop shut early, or a remote API call didn’t work out as we hoped it would. Ah, a pity. But not the end of the world. Let’s see why it’s not.
Learning to cope with rejection
Typically, when we fire off our asynchronous request, there are only a few possible outcomes.
- It’s currently running (pending)
- It’s completed successfully, or
- It’s failed
When a promise resolves successfully, the expected program flow can continue. But when things go wrong, our promise gets rejected. This is obviously a problem because, if we don’t handle our error (or exception), the exception can “bubble up” the stack. If nothing is available to handle the rejection, then execution of our JavaScript will bail out. Let’s see how we can handle promise rejection in a way that works for us.
Promise rejection in TypeScript (Angular)
We’ll use Angular to demonstrate how promise rejection works in TypeScript for client-side libraries. We only use Angular as a way to visualize what’s happening, so you should be able to apply these concepts to any TypeScript client-side app that you are working on. To demonstrate, let's create an array that has ten promises in it, and make every other promise fail:
export class NumbersComponent implements OnInit {
promises = Array<Promise<any>>();
ngOnInit(): void {
this.promises = Array.from({length: 10}, (_, i) => i % 2 === 0 ? this.randomAsyncNumber() : this.willFail());
}
willFail(): Promise<void> {
return new Promise((resolve, reject) => {
setTimeout(() => {
reject('error');
}, 1000);
});
}
randomAsyncNumber(): Promise<number> {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve(Math.random() * 100);
}, 1000)
})
}
And this is the HTML:
<div style="width: 100%; height: 100%; display: flex; flex-direction: column; gap:5px">
@for (number of promises; track number) {
<div style="min-height: 20px; border: 1px solid black;">
<p>
{{ number | async }}
</p>
<p>
<i>Object Value: {{ number | json }}</i>
</p>
</div>
}
</div>
We use the json
directive so we can see what is happening inside the Promise
as it executes. Every other Promise
fails with a value of “error”
, and no value given: But what if we wanted to go back and re-run those failed promises? Sometimes we might have to, like if we have a list of items and some of them fail. Promises execute and have a result; we can’t repeat them. So instead, we need to splice out the old failed promise, insert a new promise, and re-execute it. To achieve this, let's make a new function that does just that:
fixPromise(index: number){
const promise = this.randomAsyncNumber();
this.promises.splice(index, 1, promise);
}
And then, within our for
loop, add the button to retry the promise on failure:
@if ((number | json).indexOf("error") > -1){
<button (click)="fixPromise($index)">Retry Promise</button>
}
The result is that the failed promises are taken out of the array, and a new functional promise is added instead:
Promises can specify types of the return. But you can’t specify a return type for thereject
path. Instead, you can wrap your resolution in another object that indicates whether a request has succeeded or not.
Specifying a Result
interface
To achieve this, lets create a new interface
that describes this Result
type object. It’s very simple: something to hold the result, and something to tell us whether the Promise
has succeeded or not:
export interface Result{
result: any;
success: boolean;
}
Now, let’s make both of our functions resolve
to this new type:
willFail(): Promise<Result> {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve({result: 'fail', success: false});
}, 1000);
});
}
randomAsyncNumber(): Promise<Result> {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve({
result: Math.floor(Math.random() * 100),
success: true
});
}, 1000)
})
}
Both Promises
resolve now, but they resolve to a typed object: The benefit of this approach is that we can be sure that the
Promise
will always resolve to our expected type, as opposed to calling reject
, which can only ever be any
.
Uncaught errors in an observable chain
We can’t talk about Promises
and asynchronous functionality in TypeScript without also mentioning Observables
. Promises that reject can cause our execution to stop, and errors within an Observable
pipe can cause our logic to exit earlier than intended. For example, this Observable
will yield once with a valid result:
randomAsyncNumber(): Observable<Result> {
return of(null).pipe(
delay(1000),
map(() => ({
result: Math.floor(Math.random() * 100),
success: true
}))
);
}
But what would this do?:
willThrowError(): Observable<Result> {
return of(null).pipe(
delay(1000),
tap(() => {
throw new Error('This observable has thrown an error!');
}),
map(() => ({ result: 'This should not be visible', success: false })),
catchError(error => {
// This will not prevent the observable from entering error state
// It just transforms the error into a Result object
return of({ result: error.message, success: false });
})
);
}
Normally, we’d expect each operator within our pipe to be evaluated. But within the tap
, an error is thrown. This causes the catchError
operator to run before the Observable
completes. Catching errors within our Observable
pipe is always a good idea because if our Observable
errors, then it will continue to process events, as opposed to just ditching out.
Conclusion
Things don’t always work out, and when they don’t, that’s okay. Catching exceptions within our Promises
and Observables
with some good type information can help us degrade gracefully and retry certain operations when it makes sense.
How do you handle asynchronous errors in your applications? Let us know in the comments below. And don’t forget to check out our code samples from this article here.
LogRocket understands everything users do in your web and mobile apps.
LogRocket lets you replay user sessions, eliminating guesswork by showing exactly what users experienced. It captures console logs, errors, network requests, and pixel-perfect DOM recordings — compatible with all frameworks, and with plugins to log additional context from Redux, Vuex, and @ngrx/store.
With Galileo AI, you can instantly identify and explain user struggles with automated monitoring of your entire product experience.
Modernize how you understand your web and mobile apps — start monitoring for free.
Top comments (2)
Using explicit result objects made my async flows so much more reliable, especially when chaining logic. Do you also implement retries with backoff or let users manually retry in your production apps?
Okay, the article in one line: wrap in a result object. What else did I expect?