6 min read

Building a better Typescript error

Javascript errors are bad. I think we can all agree that new Error("...") doesn't give you something super useful to work with. Extending the Error class is surprisingly difficult. After that, actually dealing with the errors turns into a bit of a guessing game on what type of error actually happened.

The right way

I have one sort of "north star" here, which is the thiserror crate in Rust. It allows you to define errors as enums, with strongly typed metadata and even defining which errors can cause a specific variant. Here's a quick example, taken from their documentation:

#[derive(Error, Debug)]
pub enum DataStoreError {
    Disconnect(#[from] io::Error),
    Redaction(String),
    InvalidHeader {
        expected: String,
        found: String,
    },
    Unknown,
}

This then allows you to use Rust's wonderful match-statement to parse the error later:

match err {
    DataStoreError::Disconnect(cause) => ...,
    DataStoreError::Redaction(key) => ...,
    DataStoreError::InvalidHeader { expected, found } => ...,
    DataStoreError::Unknown => ...,
}

Since match requires exhaustive options, this prevents any errors from staying unhandled. It also makes it super easy for libraries to describe and pass any error case.

So what do I want in Typescript?

So we just saw that there are two parts to this: easily creating categories of connected errors, and matching on those errors. In the end, our error definitions will look something like this:

const Disconnect = createStrictError<
  "Disconnect",
  IoError
>("Disconnect");
const Redaction = createStrictError<"Redaction", undefined, string>(
  "Redaction"
);
const InvalidHeader = createStrictError<
  "InvalidHeader",
  undefined,
  { expected: string; found: string }
>("InvalidHeader");
const Unknown = createStrictError<"Unknown", Error>("Unknown");

const DataStoreError = createStrictError<
  "DataStoreError",
  | InstanceType<typeof Disconnect>
  | InstanceType<typeof Redaction>
  | InstanceType<typeof InvalidHeader>
  | InstanceType<typeof Unknown>
>("DataStoreError");

It's not ideal[1], but it's easy enough to define strong error types, including typed causes and contexts. Additionally, I want to make sure that it's exhaustively matchable using ts-pattern.

match(err.cause)
  .with({ name: "Disconnect" }, ({ cause }) => (/*...*/))
  .with({ name: "Redaction" }, ({ context: key }) => (/*...*/))
  .with({ name: "InvalidHeader" }, ({ context: { expected, found } }) => (/*...*/))
  .with({ name: "Unknown" }, ({ cause }) => (/*...*/))
  .exhaustive();

How do we build that?

Our new error type needs to carry some type information about the potential causes and contexts, as well as a proper differentiable name property to be matched on. We'll use ts-custom-error to resolve the extends Errors issues we alluded to at the start. Our first step is to create a class that can do all these things. We'll create an abstract class to prevent using this error type directly.

import { CustomError } from "ts-custom-error";

abstract class StrictError<
  const Type extends string,
  const Cause extends Error | undefined = undefined,
  const Context = undefined
> extends CustomError {
  // TODO
}

You'll note I've marked all the type parameters as const, since we (probably?) never want any non-const types defined here. Aside from that it's all pretty basic types and classes. We'll give both Cause and Context an option of being undefined, which we'll assume means you don't want the error to use that.

Now, let's define our properties:

abstract class /* ... */ {
  abstract readonly name: Type;
  readonly cause: Cause;
  readonly context: Context;

  // TODO
}

I think this is pretty self-explanatory. They reference the generic types that the class gets instantiated with, and they're readonly because you should never be allowed to modify an error's properties.

The constructor should be pretty simple also:

class /* ... */ {
  /* ... */

  constructor(
    readonly message: string,
    readonly options: { cause: Cause, context: Context }
  ) {
    super(message, { cause: options.cause });

    this.cause = options.cause;
    this.context = options.context;
  }
}

Now we can create subclasses of this like so:

class Disconnect extends StrictError<"Disconnect", IoError> {
  readonly name = "Disconnect";
}

// and it works!
declare const y: IoError; 
const x = new Disconnect("We disconnected!", { cause: y, context: undefined });

This is still quite explicit. First of all, we need to use the full class syntax, and set the name property manually. Secondly, we can't just omit the context property here even though we know it has to be undefined anyway[2]. Let's fix those one at a time.

Building a class factory

To create these classes repeatedly, it'll be a lot easier if we build what's often called a "factory", or a creator of classes. We'll build up a function. Please don't mind the weird (but valid) formatting here, I've only done that to separate the components.

function createStrictError

  // 1. Type parameters
  <
    const Type extends string,
    const Cause extends Error | undefined = undefined,
    const Context = undefined
  >

  // 2. Function parameters
  (type: Type)

  // 3. Return type
  : new (
    ...params: ConstructorParameters<typeof StrictError<Type, Cause, Context>>
  ) => StrictError<Type, Cause, Context> {
  // TODO
}

To go through that step-by-step:

  1. We simply copy the type parameters from the abstract class, which we'll need to construct the StrictError.
  2. We need to pass the Type as a value here. You can see this in the example, where we pass the name as both a type parameter and a function parameter for each new error definition.
  3. The return type looks a bit daunting, but because of how classes work in Javascript this is how you return a class in Typescript. You can see it says to return a function that:
    • can be called as a constructor (new);
    • takes the parameters of the StrictError constructor;
    • itself returns something of type StrictError.

And then we simply return a new anonymous class:

function /* ... */ {
  return class extends StrictError<Type, Cause, Context> {
    readonly name = type;
  }
}

Except... not quite. In short, an anonymous class has no name. While instances of this class will have names, if we try to analyze the class itself it'll be nameless. This is fine in most cases, but it's not what I would consider complete. Have a look:

class X { name = "X_" }
console.log(X.name) // "X"
console.log(new X().name) // "X_"

const Y = class extends X { name = "Y_" }
console.log(Y.name) // "Y"
console.log(new Y().name) // "Y_"

const z = () => class extends X { name = "Z_" }
const Z = z();
console.log(Z.name) // ""
console.log(new Z().name) // "Z_"

Note that in the const Y = class scenario, the class does get a name, but in the Z scenario, where the class is created by an anonymous function, it stays unnamed (Z.name is empty!). So we need some special magic to assign the class a name:

function /* ... */ {
  const c = class /* ... */ { /* ... */ }
  Object.defineProperty(c, "name", {
    value: type,
    writable: false,
  });
  return c;
}

Required fields only

In our error instantiation new Disconnect("We disconnected!", { cause: y, context: undefined }), we now have to pass context: undefined to the options object. Let's fix that! What we want is a little more complex than making the fields optional though: when Cause is undefined, the cause property on the second parameter should not exist. Same goes for Context and context. This way, there's no fiddling with undefined types.

First, we'll create a simple object type. When Cause is undefined, we want this object to be empty. Otherwise, we'll want the object to have one required key, from: Cause. This can be done with a conditional operator: Cause extends undefined ? object : { from: Cause }.

By mirroring this type for Context, we now have two objects: one that maybe has a from key and one that maybe has a context key. Now all we need to do is intersect those to create an object that could have either or both the from and context keys, based on the generic type parameters Cause and Context.

Let's fix the constructor:

class /* ... */ {
  /* ... */

  constructor(
    readonly message: string,
    readonly options: 
      (
        Cause extends undefined
          ? object
          : { from: Cause }
      ) &
      (
        Context extends undefined
          ? object :
          { context: Context }
      )
  ) {
    super(message, {
      cause: "from" in options ? options.from : undefined,
    });

    this.cause = ("from" in options ? options.from : undefined) as Cause;
    this.context = (
      "context" in options ? options.context : undefined
    ) as Context;
  }
}

Note that the body of the constructor also changed: we're doing some ternary logic to ensure that all the assignments work as intended.

It's all coming together now...

We now have a working version of the example of the start. Go forth and try it! Feel free to include this in your source code. I've also packaged the above as ts-strict-error, licensed under the MIT license.

I'd love to hear any feedback in Github Discussions or on Twitter. I'm also starting a collection of converters from other libraries to StrictErrors, and very much welcome PRs. The distribution of that is still up for debate: I'd prefer not to distribute it along with the library itself, but I'm not sure that making a separate package for each converter is right either. If you have any ideas, please tell me.


  1. It's not great. The most annoying thing to me is the redundant definition of Type as both a type argument and a function argument. There is a workaround:

     function inferredCreateStrictError<
      const Cause extends Error | undefined = undefined,
      const Context = undefined
     >() {
       return <const T extends string>(type: T) => createStrictError<
         T,
         Cause,
         Context
       >(type);
     }
    

    Would allow us to write a curried definition without the redundant type naming:

    inferredCreateStrictError(IoError, { data: string })("MyError")
    

    But that's also not very pretty... I guess we'll have to wait for a solution. ↩︎

  2. This is because in Typescript, an optional property context?: is not the same as a property that can be undefined context: undefined. ↩︎