Adding Result versatility to async error handling in TypeScript
TLDR
13 April 2023
Writing async functions that supports multiple ways to handle errors.
Can be used with standard try/catch by default.
Can opt into Rust like error handling through Neverthrow.
#TypeScript
#Frontend
In this post, I will introduce Rust-style error handlind for TypeScript using Neverthrow. By default, it uses native try / catch style exception handling, but once you opt in, it will switch to Neverthrow Result objects.
An example
Let me explain the concept using the following code. Here is an API call using Axios, to download posts.
import axios from 'axios'
async function getPosts(): Promise<Posts> {
const response = await axios.request<Posts>({
url: '/api/posts',
method: 'GET',
});
const data = await response.data
return data
}
I can handle exceptions by wrapping getPosts
in a try / catch statement. This is a regular idiomatic way to handle errors.
try {
const posts = await getPosts()
console.log('here are your posts', posts)
} catch (error: unknown) {
console.error('something went wrong', error)
}
When it's desired, I want to be able to use this as a Result from Neverthrow. The docs there have good examples on how to do this.
Here is an example of turning the result of getPosts
into a Result object using Neverthow:
import { ResultAsync } from 'neverthrow'
// 1. Note I need to define a way
// to parse the error from the Promise.
function parseError(error: unknown) {
if (error instanceof Error) {
return {
message: error.message
}
}
return {
message: 'Something went wrong'
}
}
// 2. The `parseError` is passed
// into the Result when it's created.
const result = await ResultAsync.fromPromise(
getPosts(), parseError)
if (result.isOk()) {
console.log('here are your posts', result.value)
} else {
console.error('something went wrong', result.error)
}
I dislike the solution above (and this is the motivation for the idea behind this blog post).
One of the main advantages of using a Result
, is it can provide the type of the error inside. However the error type here will be, by default, unknown. We need to provide a way to decipher the error type inside.
The way I solve this above, is by providing a function to decode the error — parseError
. This is provided each time I call getPosts
.
I would prefer getPosts
to provide this for me. In order to cover all possible error cases, I need to know the internals of how getPosts
works. That defeats the point of abstraction.
If getPosts
were to return a Result
for me, then it can also provide this error decoding as well. However then it no longer works with idiomatic try / catch statements by default.
Next I will go through a solution that solves both of the above.
Add toResult
Let’s come back to our getPosts
function. Instead of returning a Promise<Posts>
, I enhance it with a toResult
method which will be callable on getPosts
.
// toResult can be called on the Result directly,
// to do the convention there and then.
const result = await getPosts().toResult()
if (result.isOk()) {
console.log('here are your posts', result.value)
} else {
console.error('something went wrong', result.error)
}
To make it possible, I first extend the Promise
type returned, with a subclass of my own, as in the code example below:
import { Result, ok, err } from 'neverthrow'
/**
* My own Promise type, with the means to be
* turned into a `Result` object.
*/
export class PromiseWithResult<V>
extends Promise<V>
{
/**
* This matches the constructor for the Promise.
*/
constructor(
onExecute: (
resolve: (value:V) => void,
reject: (error?:any) => void,
) => void,
) {
super(onExecute)
}
/**
* Returns the results of this Promise wrapped
* in a `Result`.
*/
async toResult(): Promise<Result<V, unknown>> {
try {
const value = await this
return ok(value)
} catch (error: unknown) {
return err(error)
}
}
}
Now I can use it from the API call. However we need to make a few unconventional changes the getPosts
function:
import axios from 'axios'
// 1. Note this isn't async anymore.
function getPosts(): PromiseWithResult<Posts> {
const asyncFun = async () => {
const response = await axios.request<Posts>({
url: '/api/posts',
method: 'GET',
});
const data = await response.data
return data
}
// 2. Note how I am connecting the Promise
// up with non-async calls.
return new PromiseWithResult((resolve, reject) => {
const promise = asyncFun()
promise.then(resolve).catch(reject)
})
}
I have changed to return a PromiseWithResult
, allowing me to return a Promise
with the toResult
extension.
As a consequence, getPosts
is no longer declared as async
. This is because async
functions only return an explicit Promise
object, and not a subclass. But we will still await
on the function as normal (since PromiseWithResult
is still a Promise
).
(You can also achieve the above through adding to the Promise.prototype
. For many reasons, this is frowned upon, as altering the prototype directly is bad practice in modern JS.)
// It works!
const result = await getPosts().toResult()
if (result.isOk()) {
console.log('here are your posts', result.value)
} else {
console.error('something went wrong', result.error)
}
At this stage adding the toResult
method is done. Next I will add in the error handling I complained about above, which is where this gets more useful.
Add error types
As a reminder, one of the advantages of using the Result
type, is it carries the type of the error it is holding. This makes error handling simpler on larger code bases, as you can have confidence about the type of the error values you are working with.
Again, I want getPosts
to provide this for the caller.
At this point, this is straight forward for me to achieve. I add the Error
type to the PromiseWithResult
interface, and a means to parse out that error when needed.
Essentially moving the parseError
function above from being defined outside of getPosts
to being supplied within.
import { Result, ok, err } from 'neverthrow'
// 1. I add an `E` generic type here
// for the Error.
export class PromiseWithResult<V, E = void>
extends Promise<V>
{
// 2. Within the Promise, I will store
// a function for parsing the Error value.
// For when the Promise is eventually awaited.
private readonly parseError: (error: unknown) => E
constructor(
onExecute: (
resolve: (value:V) => void,
reject: (error?:any) => void,
) => void,
// 3. This will receive the error parsing
// when created.
parseError: (error: unknown) => E,
) {
super(onExecute)
this.parseError = parseError
}
// 4. The Result here changes to contain
// an Error of E.
async toResult(): Promise<Result<V, E>> {
try {
const value = await this
return ok(value)
} catch (error: unknown) {
// 5. I use the `parseError` here
// to get the correct error type.
const parsedError = this.parseError(error)
return err(parsedError)
}
}
}
This also changes the API call as well. It has to provide the means to parse the error type from the error.
import axios, { AxiosError } from 'axios'
// 1. This is the type I'll use
// to model the errors.
interface PostsError {
message: string
}
// 2. `getPosts` can now type the error it returns
// (when a Result), by adding the `PostsError` type
// to the generic parameter.
function getPosts(): PromiseWithResult<Posts, PostsError> {
const asyncFun = async () => {
const response = await axios.request<Posts>({
url: '/api/posts',
method: 'GET',
});
const data = await response.data
return data
}
// 3. The means to parse the error
// is passed in as the second parameter.
return new PromiseWithResult((resolve, reject) => {
const promise = asyncFun()
promise.then(resolve).catch(reject)
}, parsePostsError)
}
// 4. Finally here is the function to convert
// from unknown to `PostsError`.
// Note I have multiple clauses based on what I find.
function parsePostsError(err: unknown): PostsError {
if (err instanceof AxiosError) {
if (err.response?.status === 404) {
return {
message: 'Posts are not found',
}
}
if (err.response?.status === 401) {
return {
message: 'Access to posts is unauthorised',
}
}
}
if (err instanceof Error) {
return {
message: err.message
}
}
return {
message: 'An unknown error has occurred'
}
}
Finally, this is what calling the getPosts
function looks like with these changes:
const result = await getPosts().toResult()
if (result.isOk()) {
console.log('here are your posts', result.value)
} else {
// Note this now has the type of the `PostsError`.
const errorMessage = result.error.message
console.error('something went wrong', errorMessage)
}
This concludes how to make this work, and one could refactor the above to make the code more reusable.
As a recap, we can still use the getPosts
function in an idiomatic way, with standard try/catch exception behaviour.
try {
const posts = await getPosts()
console.log('here are your posts', posts)
} catch (error: unknown) {
console.error('something went wrong', error)
}
An alternative
I could simply use Neverthow everywhere, and remove throw / catch exception handling entirely.
I am personally not a fan of this approach. I am a believer of trying to write code in an idiomatic way as often as possible. Idiomatic code tends to be easier and better to work with.
There are also libraries that expect your code to throw errors when things fail which means you need to write code to opt out of Neverthrow.
I would prefer to only opt-into the benefits of using Neverthrow, where it makes sense in my code.