Typescript: Error Handling

February 15 2022
Archived

Error Handling

Let's look at a simple try-catch-finally example in a strongly typed language (roughly Java).

try {
  callThatMayThrowVaryingExceptions();
}
catch(FirstException e) {
  System.out.println("First Exception Thrown");
}
catch(SecondException e) {
  System.out.println("Second Exception Thrown");
}
catch (Exception e) {
  System.out.println("Generic Exception");
}

With some programming knowledge, we can see that the above code will:

  1. Attempt to call a function callThatMayThrowVaryingExceptions();.
  2. Defensively guard against 2 user defined Exception types (FirstException , SecondException).
  3. Report error state based on the type of Exception caught.

In a similar vein, one may try (without the help of intellisense screaming at them) to write the above code in Typescript, as follows:

try {
  callThatMayThrowVaryingExceptions();
}
catch(e: FirstException) {
  System.out.println("First Exception Thrown");
}
catch(e: SecondException) {
  System.out.println("Second Exception Thrown");
}
catch (e: ThirdException) {
  System.out.println("Generic Exception");
}

Which is in fact, not valid TypeScript code.

TypeScript does not support multiple catch statements.

Okay, fair enough. So the next attempt at writing an old-school try-catch block may look something like this.

class MyFirstException extends Error {
  ...
}

class MySecondException extends Error {
  ...
}

try {
  callThatMayThrowVaryingExceptions();
}
catch(e: Error) {
  if (e instanceof FirstException) {
    console.log("This is my first exception");
  } else if (e instanceof SecondException) {
    console.log("This is my second exception");
  } else {
    console.log("Generic Exception")
  }
}

It may surprise you, that the above - is also not valid TypeScript code.

You will get the following transpiler complaint:

Catch clause variable type annotation must be 'any' or 'unknown' if specified

This is because the catch block as part of a try...catch...finally block must catch "something" that is unknown or any (if the type annotation is specified) - where unknown is more semantically correct. This may seem like an abnormal design decision, but it is actually rooted in a very well thought out argument.

So the correct version of the above code block, is actually:

class MyFirstException extends Error {
  ...
}

class MySecondException extends Error {
  ...
}

try {
  callThatMayThrowVaryingExceptions();
}
catch(e: unknown) {
  if (e instanceof FirstException) {
    console.log("This is my first exception of type error");
  } else if (e instanceof SecondException) {
    console.log("This is my second exception of type error");
  } else {
    console.log(`Generic 'thing' was thrown`)
  }
}

Which works, but doesn't quite hit the spot.

Let's fix it, and then understand why it needs to be fixed.

The Solution

We're going to rework how we build catch blocks.

  1. Find a way to disseminate between a 'thing' that is Error and a 'thing' that is not Error.
  2. Find a way to:a. Report a consistent message if the 'thing' is an Error.b. Report the 'thing' if it is not an Error.

In code:

class MyFirstException extends Error {
  const message: string = "First Exception"
}

class MySecondException extends Error {
  const message: string = "Second Exception"
}

const message = (err: unknown) => {
  if (err instance of Error) return err.message;
  return String(err)
}

try {
  callThatMayThrowVaryingExceptions();
}
catch(e: unknown) {
  message(e);
}

Which will ensure that we:

  1. Report on errors in a way that uniquely idenitifies them.
  2. Report on 'things' that are thrown, in a way that is non-breaking.

The Why

The above is all good and well, but it doesn't touch on the history behind the need for this workaround.

To start, take a look at throw. With a keen eye for documentation, you may notice that throw can throw anything.

Some examples:

throw 42;
throw { hello: 123 };
throw null;
throw undefined;
throw 1+3;

Although our code may never throw the above, it is impossible that some third-party library will.It is not possible for TypeScript, or even us, to know the type of throw that a catch clause may handle. (hence unknown being the most semantically correct).

In fact, it is also possible that the constructor for Error may be overridden:

Error = () => {
  throw 42;
} as any;

Which further complicates type annotations in a catch block!

The Right Way

It is not clear whether there is a 'correct' way to handle try-catch blocks in TypeScript (or JavaScript) that need to handle multiple exception states, but there are alternative approaches to error handling that lend themselves better to a more functional style.

One of which is Railway-Oriented Programming - a functional pattern related to the sequential execution of functions - the gist of which is that every function or method must yield a success or error.