tswrap - why awaiting errors sucks
The Problem
We’ve all been there: trying to best work out how to use async
/await
properly.
Here’s how the process usually goes.
You start out with something basic:
const users = await fetchUsers()
This works, until you realise you’re not handling errors.
You rewrite your code, and it ends up looking like:
try {
const users = await fetchUsers()
} catch (error) {
// handle error
}
Ok, great. We now know when our fetchUser
function fails to fetch our users.
Now we want to do something with that result.
try {
const users = await fetchUsers()
} catch (error) {
// handle error
}
console.log(users[0].email)
Anyone knowing how const
works in block statements will realise this isn’t going to work - users
is only available inside the try
/catch
block.
Sure, we could always move the console.log
inside of the try block, but what if that throws? Ideally we only want to wrap our fetchUser
call.
The common suggestion is to “hoist” users
outside of the try:
let users
try {
users = await fetchUsers()
} catch (error) {
// handle error
}
console.log(users[0].email)
This works! But notice how we had to switch const
to let
, and also define the variable above the actual fetch.
You know what else is bad? You could totally just forget to check for errors.
This would cause the error to bubble up and potentially cause havok to your service.
Not something we want.
The Solution
Leveraging typescript union types, we can ditch the required use of try/catch
, and also enforce error checking for all async operations.
Meet tswrap.
Before anything else, an example:
const users = await fetchUsers()
if (T.isError(users)) {
// handle error
return
}
console.log(users[0].email)
How does this work? And how does it enforce error checking?
Never throw.
The biggest underlying change that we’re going to make here is to never call throw
from a function marked async
. This also means never calling reject()
from inside a Promise.
Instead of throwing, we just return the error.
The return type.
Here is an example of how fetchUsers()
could be written.
// Import our tswrap library
import * as T from 'tswrap'
// Import our fictitious database connection
import db from './db'
// Define an interface for the response type
interface User {
email: string
}
/*
Next, we define our function.
As this is an async operation, it is marked as such.
You'll also notice that the return type is
wrapped in T.R<T> instead of Promise<T>
*/
export async function fetchUsers (): T.R<User[]> {
/*
Lets assume our fictitious database connection always returns a promise,
and lets assume we have no control over this library. This means that the
library is probably doing the typical promise thing, rejecting and throwing.
We can wrap this returned promise with `T.wrapPromise`, interally, this
function just wraps the promise evaluation in a `try`/`catch` block, and
just `return`s either the resolved value, or the error.
*/
return T.wrapPromise<User[]>(db.get('users'))
}
Remember: Never throw.
So how does this actually work?
For clarity sake, lets assume:
type E = NodeJS.ErrnoException
Now, let’s talk about T.R
: it’s basically just an alias for type R<T> = Promise<E | T>
.
What this means is that every function will return either an Error, or your correct type (T
).
If we have a look at the type of our fetchUsers
function, we can see that it’s it resolves to Promise<E | Users[]>
now, and once you await
the function, the type ends up being E | Users[]
.
Because this is a distinct union type - trying access the right side (the non-error), will result in typescript telling yelling at you.
Now that we have our result of E | Users[]
, how do we narrow this value down to either E
or Users[]
?
Error checking
Like using union-types for error responses, we can utilise type guards to help us narrow down our response.
export function isError (arg: any): arg is E {
return arg instanceof Error
}
Type guards are functions that you can write that have a return type of A is B
, where A
is a parameter passed to the function, and B
is a defined Typescript type. If this function returns true, then typescript knows that the argument passed in is B
.
Let’s have a look how this looks in practice:
// Here, `users` will have a type of `E | Users[]`
const users = await fetchUsers()
if (T.isError(users)) {
// Because `isError` is a type guard for `E`,
// if we're inside this function, typescript
// knows that users is `E` and that it is NOT `Users[]`.
return
}
// If we get here, typescript is smart enough to understand
// that because we've already run a typeguard for `E`, and
// we actually `return` in that if statement, that the type
// of users cannot be `E`, therefor must be `Users[]`
console.log(users[0].email)
There you have it.
As you can see, utilising some of typescripts more advanced features lets us ensure that we’re actually error checking, and we can avoid having to try
/catch
and potentially hoist our variables into different scopes.