DEV Community

Ville Hakulinen for Duunitori

Posted on

Mimicing Rust's Result type in typescript

In the beginning...

...of every project there are API calls without error handling. One of ours looked like this:

  /**
   * Register a new (local) account.
   *
   * @param email Email of the new user.
   * @param password Password of the new user.
   *
   * @returns Promise of boolean about the success of the registration request.
   */
  public async registerLocalAccount(
    email: string,
    password: string
  ): Promise<boolean> {
    return this._fetch('/auth/local/register', {
      method: 'POST',
      body: JSON.stringify({ email, password })
    }).then(res => res.ok);
  }
Enter fullscreen mode Exit fullscreen mode

The problem here is that we don't have any information on failures. We're just checking if the response status code is 200 or not. On failures, the user is faced with message like "Oops, something went wrong. Try again.". Surely we can do better, can't we? Yes! Lets get our selves a Result<T, E> type.

And the Result is...

export type Result<T, E> = Ok<T, E> | Err<T, E>;

export class Ok<T, E> {
  public constructor(public readonly value: T) {}

  public isOk(): this is Ok<T, E> {
    return true;
  }

  public isErr(): this is Err<T, E> {
    return false;
  }
}

export class Err<T, E> {
  public constructor(public readonly error: E) {}

  public isOk(): this is Ok<T, E> {
    return false;
  }

  public isErr(): this is Err<T, E> {
    return true;
  }
}

/**
 * Construct a new Ok result value.
 */
export const ok = <T, E>(value: T): Ok<T, E> => new Ok(value);

/**
 * Construct a new Err result value.
 */
export const err = <T, E>(error: E): Err<T, E> => new Err(error);
Enter fullscreen mode Exit fullscreen mode

Note that this is a really plain version of the type. Inspiration for this is taken from various implementations found online, one of these being neverthrow. Neverthrow also implements some of the utility functions like map.

A few notes:

  1. We have a union type Result<T, E>, which is either Ok or Err
  2. Ok and Err types have isOk and isErr type guards which can be used to tell which ever the values is
  3. We have ok and err constructors which are used to construct a new Result type

Now, simple usage of this might look something like this:

function getCount(): Promise<Result<number, Error>> {
  return fetch('/index-count')
    .then(res => res.json())
    .then((body): Ok<number, Error> => ok(body['count']))
    .catch(() => err(new Error('Something when wrong while fetching count')));
}

const res: Result<number, Error> = await getCount();

// To access the count, we'll first have to check if the calculation succeeded.
if (res.isOk()) {
  // Now we can access the value.
  console.log('Count is:', res.value);
}

if (res.isErr()) {
 // Now we can access the error.
 console.error('Oh no, there was an error:', res.error)
}
Enter fullscreen mode Exit fullscreen mode

Cool, that should be enough to get you started with fancy and elegant error handling. But wait, there's more! Heres some more meat around the bones of the Result type.

More on the network errors

In our code, we've defined a common NetworkError type for all our API return types. It looks like this:

export type NetworkError<T> = {
  // Was this error expected or not?
  unexpected: boolean;
  // Possible value for the error.
  value?: T;
};

// Utility function to construct a new NetworkError.
export const networkError = <T>(
  unexpected: boolean,
  value?: T
): NetworkError<T> => ({ unexpected, value });
Enter fullscreen mode Exit fullscreen mode

Here, we wrap any possible (known) errors into an object with a field that also tells us if the error was unexpected or not. Unexpected errors might be network failures or internal server errors, while expected errors usually are input validation errors, like a bad email or a weak password.

Suddenly, our API request handler looks like this:

// Error codes used by the backend.
export enum ApiErrorCode {
  PasswordTooWeak = 1,
  EmailInvalid = 2,
  EmailAlreadyInUse = 3
}

export type RegisterLocalAccountError = {
  email?: ApiErrorCode.EmailInvalid | ApiErrorCode.EmailAlreadyInUse;
  password?: ApiErrorCode.PasswordTooWeak;
};

export type RegisterLocalAccount = Result<
  boolean,
  NetworkError<RegisterLocalAccountError>
>;

public async registerLocalAccount(
  email: string,
  password: string
): Promise<RegisterLocalAccount> {
  return this._fetch('/auth/local/register', {
    method: 'POST',
    body: JSON.stringify({ email, password })
  })
    .then(
      async (res): Promise<RegisterLocalAccount> => {
        if (res.ok) {
          return ok(true);
        }

        if (res.status === 400) {
          const e = await res
            .json()
            .then(
              (reason: RegisterLocalAccountError): RegisterLocalAccount =>
                err(networkError(false, reason))
            )
            .catch((): RegisterLocalAccount => err(networkError(true)));

          return e;
        }

        return err(networkError(true));
      }
    )
    .catch(() => err(networkError(true)));
}
Enter fullscreen mode Exit fullscreen mode

Ugh, more types you might say. Trust me, there is a reason for those: in typescript you can do an exhaustive switch statement. For example, our email error message utility function looks like this:

const emailError = (
  error: NetworkError<RegisterLocalAccountError> | null
): string => {
  if (
    error === null ||
    error.value === undefined ||
    error.value.email === undefined
  ) {
    return '';
  }

  switch (error.value.email) {
    case ApiErrorCode.EmailAlreadyInUse:
      return 'Email is already in use';
    case ApiErrorCode.EmailInvalid:
      return 'Invalid email';
  }
};
Enter fullscreen mode Exit fullscreen mode

Now, if we were to extend the RegisterLocalAccountError to this:

export type RegisterLocalAccountError = {
  email?: ApiErrorCode.EmailInvalid | ApiErrorCode.EmailAlreadyInUse | 'random error';
  password?: ApiErrorCode.PasswordTooWeak;
};
Enter fullscreen mode Exit fullscreen mode

The compiler would yell at us:

Function lacks ending return statement and return type does not include 'undefined'.  TS2366

    91 | const emailError = (
    92 |   error: NetworkError<RegisterLocalAccountError> | null
  > 93 | ): string => {
       |    ^
    94 |   if (
    95 |     error === null ||
    96 |     error.value === undefined ||
Enter fullscreen mode Exit fullscreen mode

Even though the error message isn't ideal (it's not telling which case we're not handling), it's still 100x better than a runtime error (or no error at all!).

Anyway, lets take a bit closer look at the API handler code. There are three return points on the then handler:

  1. The happy path, which returns Ok if the response status code is 200
  2. First error path when we get an "expected" 400 status code. On this path, we should get a JSON body telling us about validation errors. So if our JSON parsing succeeds, we'll return a network error that is "expected". But if the JSON parsing fails, we'll return a network error that is unexpected.
  3. If the status code is not 200 nor 400, we'll just return a network error that is unexpected.

All the error values are wrapped into a Result<T, E> with the err constructor. Also note the final catch, in which we return a network error which is (again) unexpected. This last catch is extremely important because we don't want to pollute our caller code with try/catch. Instead, we'll want to rely on the Result type.

Conclusion

Depending on your taste on the topic (and mainly how much you like static types and are familiar with functional types), the Result<T, E> type can be extremely helpful. We've tried to extract the most out of typescript to help us with development and with always shipping functioning software. The result type is part of our efforts to model our API inputs and outputs into the type system. When done properly, we can fearlessly make changes to our backend and just take care that we update our frontend types that are describing the backend. At that point, the compiler will work with you and tell the places where you might need to extend your error handling and so on.

One obvious downside with the result type given earlier is that it's an class and not a plain object. You might want to turn the classes into a plain serializable objects for your redux store.

Top comments (0)