UP | HOME

Typescript typed error handling

Table of Contents

1 Typescript typed error handling

1.0.1 TLDR

Given a typescript function, we want to define the types of errors it might encounter as part of the function’s type definition. This will help consumers of the function reason about it’s result, and separate the idea of expected errors, which may arise out of changes outside of the application, from unexpected ones, which we do not have a case to handle.

We will implement a monad similar to Promise.then/catch that includes a typed success case and a typed failure pattern.

1.0.2 Introduction

Why do applications fail? Recently I spent a long time working on a very large multi-user application. It was my first time using typescript, after a long time of using python (django) and javascript for application design.

Well known to functional programmers, especially those who work in languages with static typing, is that side effects can introduce bugs in a system that is otherwise not expecting any. For the purpose of this piece, we will look at a simple function: getUser: (id: string) => User, and use this function to come up with a sane way to handle errors.

First, we should be able to split the type of error into two camps: expected errors and unexpected errors. Expected errors can be considered information, and are often thrown in cases where the state of the application has changed. For our example getUser, the user may have been deleted, or left the application, and therefore no longer exists. This is because the getUser function is the api function to the mutable state of the database.

Different clients of the function getUser might want to handle different failures differently. In a case of NotFound, a frontend client displaying the user’s profile might want to direct to a page saying the user was not found. Getting a list of users, however, might simply want to not include the user’s which were not found. Furthermore, our function getUser might want to throw an error specifically stating that the input of the function was not valid. In theory, the typescript compiler should catch this last sort of error for us, but in practice, in a world of side effects, a client might send complete bullshit data to the getUser function.

1.0.3 Lay the groundwork

Let’s put together a barebones, fake database application to run the rest of our code: (this isn’t so important, we are just laying the groundwork)

type User = { name: string } // a dirt simple user type
type DB = { [id: string]: User } // a fake database as a data-object

const fakeDb: DB = {
  "1": { name: "pablo" },
  "2": { name: "kristina" },
  "3": { name: "hildegard von bingen" },
}

// fake a database getter function as a promise
const db = {
  get: (id: string): Promise<User | undefined> =>
    Promise.resolve(fakeDb[id]),
}

Now, we should be able to use our fake database like so:

await db.get("1").then(console.log);
{ name: 'pablo' }

In a real world application, the state of the database would be constantly changing, so it could happen that you query a user who no longer exists. In our case, we know that the users 1, 2, and 3 exist.

Our ’database’ will return undefined when queried for a user who does not exist:

await db.get("4").then(console.log);
undefined

and will also return undefined when you query with ’bad’ data:

await db.get(null).then(console.log);
undefined

1.0.4 Api function, first draft

We could let our business logic touch the database directly, but better application design would have us create an api layer to abstract the database layer. This api layer can add naming, sanitize inputs, change data on getting or setting, and in general include the logic our application needs for the database layer. Today, we will focus on throwing different errors when you gave the function bad data versus when the database returns no data.

To do this, we will make our api function getUser.

Especially coming from a python world, throwing errors and catching them in the consuming function is the norm. Let’s take a look at a similar approach in typescript here (the purpose of the article is rewriting this function, so this is our first draft):

let isLoggedIn = true; // a fake little state variable

const getUser
  : (id: string) => Promise<User> // type definition
  = async (id) => {
    if (!(typeof id === `string`)) {
      throw new Error(`bad query parameter`)
    }
    if (!isLoggedIn) {
      throw new Error(`bad user! that's illegal`);
    }
    const user = await db.get(id);

    if (!user) {
      throw new Error(`user ${id} not found`);
    }
    return user
  }

as we can see, the function will now throw a certain error when we hand it bad data:

await getUser(null)
  .catch(e => console.log(`caught: ${e}`));
caught: Error: bad query parameter

and will throw a different error when the user was not found:

await getUser('76')
  .catch(e => console.log(`caught: ${e}`));
caught: Error: user 76 not found

the code will also throw a completely separate error when the requesting user is not logged in.

All three of these error cases are expected errors. They can happen when something in the environment changed, or when a client library is misusing the function in an expected way.

From the above results, we can see that we have now been handed very userful information back from getUser about why the function failed.

However, it is still difficult for the client application to act on the different types of errors. What we would like to do, in pseudo-Code, is this:

// pseudo-code
getUser(id)
  .then(processResult)
  .catch((e) => {
    if (e === notFoundError) goToNotFound();
    if (e === notLoggedInError) goToLogin();
  });

To do this, we deffinitely need better error types, that are not just defined as strings in the function throwing them.

1.1 Handling Known Errors

The advantage of typescript is in the name: typing. We can easily make a few error types to test our results against.

1.1.1 Error Types

The first thing we need to do is define a few error types. In keeping with our very simple example, let’s define three types, BadInputError, NotFoundError and NoAuthError.

type NotFoundError = { readonly _type: "NotFoundError"; msg: string; };
type BadInputError = { readonly _type: "BadInputError"; msg: string; };
type NoAuthError = { readonly _type: "NoAuthError"; msg: string };

type Err = NotFoundError | BadInputError | NoAuthError

const isNotFoundError =
  (e): e is NotFoundError =>
    e._type === "NotFoundError";

const isNoAuthError =
  (e): e is NoAuthError =>
    e._type === "NoAuthError";

const isBadInputError =
  (e): e is BadInputError =>
    e._type === "BadInputError";

We should also define some error creators, that will pass additional information along to the client about our error types. This allows our errors to be re-used by multiple functions.

const notFoundError
  : (msg: string) => NotFoundError
  = (msg) => ({ _type: "NotFoundError", msg })

const noAuthError
  : (msg: string) => NoAuthError
  = (msg) => ({ _type: "NoAuthError", msg })

const badInputError
  : (msg: string) => BadInputError
  = (msg) => ({ _type: "BadInputError", msg })

(sidenote: there are more DRY ways to accomplish the error creators and types, which I will talk about in another article. We are keeping it simple here because we are illustrating something else)

We should now be able to create an error using one of the error creator functions:

badInputError(`bad user! bad!`);
{ _type: 'BadInputError', msg: 'bad user! bad!' }

Note: Notice that we will not include a stack any more in our errors. With expected errors, the stack should no longer be important, as we (the function), know why it failed, and just want to tell the consumer how they mest up.

1.1.2 Rewrite the API function

Now, let’s rewrite our little getUser function to actually throw these errors, and then re-implement the client pseudo-code as real code. The function does not look so different than before, exept now it throws errors that have types.

// rewrite our function to use error types
const getUser
  : (id: string) => Promise<User | never> // type definition
  = async (id) => {
    if (!(typeof id === `string`)) {
      throw badInputError(`bad query parameter`)
    }
    if (!isLoggedIn) {
      throw noAuthError(`not logged in`)
    }
    const user = await db.get(id);

    if (!user) {
      throw notFoundError(`this user id: ${id} not found. silly you!`)
    }
    return user
  }

we can still get a user that exists:

await getUser("2")
  .then(u => console.log(`got user ${u.name}`))
got user kristina

and a failed try will still throw:

await getUser("27")
  .catch(e => console.log(`caught: ${e._type}: ${e.msg}`))
caught: NotFoundError: this user id: 27 not found. silly you!

however, because the thrown errors are now typed, we can easily test them:

await getUser("666")
  .catch(
    (e) =>
      isNotFoundError(e)
      ? console.log(`do /something/ on not found`)
      : isNoAuthError(e)
      ? console.log(`you are not logged in, caught it`)
      : isBadInputError(e)
      ? console.log(`bad input`)
      : console.log(`something truly unexpected happened`)
  )

do /something/ on not found

Success! we are now able to distinguish between errors and act on them accordingly. In the above code, we catch the function we knew was going to fail, and then did something different depending on the type of error that it was, using the error test functions we wrote before.

Hooooowever

There is still one problem. It would be fantastic if our getUser function were able to tell us in it’s type definition what kind of errors it expects.

In typescript throwing errors always results in the never type. Typescript is supposed to allow for very specific type definitions of functions, but in the case of functions that throw errors, you either get the defined success type or…. well, never.

Take another look at the function definition:

getUser: (id: string) ==> Promise<User | never>

In our small, simple function, it is pretty easy to manually look through the function to see which errors it might throw. However - In more complicated functions, where a function calls nested or imported sub-routines that might throw their own errors, this becomes difficult to track or handle.

Realistically, what we want is the error types in the function definition.

One method of dealing with this issue would be to return the errors instead of throwing them. In this solution, our function definition would look like this:

getUser: (id: string) ==> Promise<User | BadInputError | NoAuthError | NotFoundError>

Personally, I don’t like this solution, because it makes no distinction between the success case of the function and a failure, which may be the first type of test a consuming function might want to do.

Ideally, we want to create an interface similare to Promise.then/catch, that will allow us to reason about the function’s results elegantly.

1.1.3 monads to the rescue!

the change here is that catch is only used any more for unexpected results, everything else is handled as part of the new data-flow type.

this would require our new doThing function to look a lot closer to this:

  • codify expected errors, and include them in the type definitions of functions
  • share firebase function types : put one in functions/src/index and one in your api layer linking to the function by string. this will keep both up to date
type SuccessOrFailure = {
}

… to be continued

Author: John Doe

Created: 2021-01-15 Fri 06:05