Jay Kim

Typesafe Custom Error Objects with Error Codes

It's pretty useful to be annotating your error objects with error codes. You can add some instrumentation to your root error handler to log error codes to your monitoring system. Your end users can also report the error codes which will make debugging easier. You can also handle the error codes in the UI itself to display different things to the user.

In Fastify, error codes and their corresponding error objects are defined centrally in one file:

// https://github.com/fastify/fastify/blob/main/lib/errors.js
const codes = {
  //...
  FST_ERR_QSP_NOT_FN: createError(
    "FST_ERR_QSP_NOT_FN",
    "querystringParser option should be a function, instead got '%s'",
    500
  ),
  FST_ERR_SCHEMA_CONTROLLER_BUCKET_OPT_NOT_FN: createError(
    "FST_ERR_SCHEMA_CONTROLLER_BUCKET_OPT_NOT_FN",
    "schemaController.bucket option should be a function, instead got '%s'",
    500
  ),
  //...
};

The createError function returns a constructor for FastifyError which creates an object that extends the native Error object. The error map also annotates the error objects with additional data such as the printf-style string template and the HTTP code.

These custom errors are thrown like this:

throw new FST_ERR_QSP_NOT_FN(typeof options.querystringParser);

And depending on the string template, the constructor may accept multiple arguments. I wanted to figure out a way to implement this in TypeScript and leverage the type system so that the error arguments autocomplete and validate themselves.

The easiest approach I found was to define a tagged union so that I could associate the error code with the data needed by the error message:

type ErrorSpec =
  | {
      code: "HTTP_ERROR";
      status: number;
      body: string;
    }
  | {
      code: "UNKNOWN_ERROR";
      message: string;
    };

I would then need to create a mapping of these codes to the error message builders so I created a mapping like this:

const errorMessageBuilders = {
  HTTP_ERROR: (args: { status: number; body: string }) =>
    `HTTP request failed with status ${args.status} and body ${args.body}`,
  UNKNOWN_ERROR: (args: { message: string }) =>
    `Unknown error: ${args.message}`,
};

For each error code in the mapping, I wanted to constrain the builder function so that its arguments correspond to it's corresponding object in the tagged union we defined earlier in ErrorSpec.

TypeScript has an Extract utility type that we can use to pick out one of the objects in the tagged union:

// Extract error codes from the tagged union.
type ErrorCode = ErrorSpec["code"];

// For ErrorCode C, declare a function whose first argument is
// constrained by fields of the tagged union when code is equal to C.
type ErrorMessageBuilder<C extends ErrorCode> = (
  args: Omit<Extract<ErrorSpec, { code: C }>, "code">
) => string;

// For each ErrorCode, map it to it's corresponding builder function.
type ErrorMessageBuilders = { [C in ErrorCode]: ErrorMessageBuilder<C> };

const errorMessageBuilders: ErrorMessageBuilders = {
  //...
};

TypeScript will now complain if we try to introduce an invalid error code or an invalid argument to errorMessageBuilders.

const errorMessageBuilders: ErrorMessageBuilders = {
  // Oops! Invalid ErrorCode.
  TYPE_ERROR: () => "",

  // Oops! Arguments are missing "status" and "body".
  HTTP_ERROR: (args: { data: string }) => "",
};

We now have all the tools available to define a constrained custom error class:

class MyError<C extends ErrorCode> extends Error {
  data: Extract<ErrorSpec, { code: C }>;

  constructor(spec: Extract<ErrorSpec, { code: C }>) {
    const messageBuilder: ErrorMessageBuilders[C] =
      errorMessageBuilders[spec.code];
    const message = messageBuilder(spec);
    super(message);
    this.data = spec;
  }
}

And now when you initialize a new MyError object you will get autocompletion and typechecking which will change depending on what you specify code to be.

const e1 = new MyError({
  code: "HTTP_ERROR",
  status: 401,
  body: "Unauthorized",
});
const e2 = new MyError({
  code: "UNKNOWN_ERROR",
  message: "Oops!",
});

I also store the ErrorSpec object in the data instance member since this data might be useful to have in your error handler:

function errorHandler(e: unknown) {
  if (e instanceof MyError) {
    if (e.data.code === "HTTP_ERROR") {
      console.log(e.data.status);
    }
  }
}

I'm pretty happy about this developer experience. My only gripe is that you need to define two things when creating new errors: you need to add to the tagged union ErrorSpec and you need to define a new message builder. The same information such as the error codes and additional metadata is repeated in two places and I think it could be solely defined in the builder map. The tagged union can be inferred from the builder map keys and function argument types. I've yet to figure out a way to implement this but I've come close so it's likely possible with more effort.