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 getPoststo 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.