Yuan

Explicit Error Handling in TypeScript

Here’s an example of error handling done with try catch:

function doStuff(): number {
  if (condition) {
    throw new CustomError("desc");
  }
  return 4;
}

function main() {
  try {
    doStuff();
  } catch (e) {
    // handle the error
  }
}

Problems

Additional cognitive load

The different errors that a function may throw are missing from the function signature. While we can document it with JSDoc, the reality is that most internal functions are not properly documented, and will never be. It is not unusual for a developer to dig inside the called function to figure out what errors it can throw.

No safety guarantee

Unlike Java or C#, there is no way for TypeScript to know what is the type of e in our example. The longer our call stack, the less we are certain about what kind of error might be caught and from where. This generally results in more boilerplate and uncertainty with error handling.

The caller can just ignore it

As the caller is not forced nor hinted to handle the error, the developer might unintentionally forget about it. When a function that used to not throw anything got modified to throw something, the compiler can not help you notice it.

Prior Arts

Recent languages such as Go and Rust have chosen to move away from the try-catch-finally idiom for error handling. While the details vary, they both return the error to the caller.

Solutions

The more functional way

import { Either } from "some/library";

function doStuff(): Either<number, CustomError> {
  if (condition) {
    return Either.left(new CustomError("desc"));
  }
  return Either.right(4);
}

function main() {
  const out = doStuff();
  if (out.isLeft()) {
    const err = out.left;
    // handle the error
  } else {
    const res = out.right;
  }
}

This is the way that is similar to how some functional languages and Rust handle it. While this has seen some popularity in the TypeScript community, it did not end up seeing many adoptions at my previous employer despite being pushed forward by some teams. The teams who did follow it ended up abandoning it due to the additional boilerplate and onboarding cost for new developers.

The golang way

function doStuff(): [Error, null] | [null, number] {
  if (condition) {
    return [new CustomError("desc"), null];
  }
  return [null, 4];
}

function main() {
  const [err, res] = doStuff();
  if (err) {
    // handle the error
  }
}

This looks cleaner and simpler compared to our previous option. However, there is one thing that makes it unattractive: the type of res can not be narrowed to number. TypeScript can not somehow “link” err and res so an assertion on err narrows the type of res.

Alternative approach

Return it instead of throwing it.

Here’s an interesting post by a Deno contributor stating that how errors are handled is tied to a languageā€™s compiler and type system. TypeScript is fundamentally a dynamic programming language. We can leverage that to accomplish the same thing without the additional burden:

function doStuff(): number | CustomError {
  if (condition) {
    return new CustomError("desc");
  }
  return 4;
}

function main() {
  const out = doStuff();
  if (out instanceof Error) {
    // handle the error
  }
  // out's type is inferred to be `number`
}

Advantages

  • Simple, no additional dependency nor sophisticated typing needed.
  • Expressive, you know at a glance what errors a function can return.
  • Type-safe, the compiler will guide you.

Limitations

  • The instanceof way of checking error does not work across multiple execution contexts. You can learn more about it on MDN. Luckily, this is not a problem for server-side applications.
  • Only instances of Error and its subclasses can be used for a catch-all instanceof Error check.
  • No #[must_use] attribute for TypeScript. The caller can still ignore the returned value if they do not need the 4 we returned in our example.

This is in my opinion more elegant and clear in intent than the other options. I have convinced my team at my previous company to adopt this pattern and it has been working great. The business logic is clearer and the number of unexpected 500 server errors that our downstream services receive from us has also decreased.

It is worth noting that this approach depends heavily on TypeScript. An error might pass silently in pure JavaScript if the developer forgets to guard it.