Error handling in Typescript 2019.11.07

Introduction

In Javascript, we often use Error objects to manage errors.

new Error("my error");

The main reason is because it provides you information about the context where it has been created. There are other standard objects that extends from Error, like SyntaxError, and you can also create your own error objets.

class MyError extends Error {}

You can then use the throw keyword to raise an exception and a try / catch block to handle errors thrown. It’s something you do in a lot languages for error handling. In some, like Java, it’s nice. But when you use Typescript, there are some issues with this practice. In fact, you can throw anything.

try {
  throw 1;
} catch (error) {
  // `error` will be equal to `1`
}

So every time you catch an error, the caught error can be anything: an instance of Error, a string, a number, … You don’t know the type of the exception. Typescript will type it as any, and there is no way to narrow the type. But in the catch clause, there are 2 types of errors you’ll catch:

  • expected errors A case that may happen and that you handle in your code, like an input validation error.
  • unexpected errors It’s the kind of error that should never happen, but if it does, you want to catch it, like a failure when reading from the database. It makes sense for unexpected errors to be caught as any in a try/catch and have kind of a default way to process them in the code. But for the expected errors, you want to type them for many reasons, but mainly to:

    • know the errors that the function you are calling will raise
    • make sure you handle all the errors

Let’s see an example

This piece of code tries to register a credit card, but make sure it’s a valid credit card before.

const getIsCreditCardNumberValid = (cardNumber: string): boolean => {
  return Boolean(cardNumber.match("^[0-9]{16}$"));
};
const getIsDateInTheFuture = (expirationDate: Date): boolean => {
  return new Date() < expirationDate;
};
const registerCreditCard = (cardNumber: string, expirationDate: Date): void => {
  const isCreditCardNumberIsValid = getIsCreditCardNumberValid(cardNumber);
  if (!isCreditCardNumberIsValid) {
    throw new Error("invalid_card_number");
  }
  const isDateInTheFuture = getIsDateInTheFuture(expirationDate);
  if (!isDateInTheFuture) {
    throw new Error("card_expired");
  }
  // ...
};
try {
  registerCreditCard("5168441223630339", new Date(2021, 11));
} catch (error) {
  switch (error.message) {
    case "invalid_card_number":
      console.log("invalid card number");
      break;
    case "card_expired":
      console.log("card expired");
      break;
    default:
      console.log("unknown error");
  }
}

To know what are the errors we need to handle when calling registerCreditCard, we have to read the code of the function and determine the errors we have to catch or to rely on some documentation that may not be up to date.
And it’s not type-safe. If we don’t handle an error that we should, it will still compile. If a developer changes invalid_card_number to card_number_invalid in registerCreditCard, the error will not be handled anymore the way it should be handled, but the code will still compile.
We can solve these problems by no longer using throw. Instead, we can include error handling in the return value of the function. This way, all the errors to handle are enumerated in the return type so you don’t have to look at the implementation of the function to find them; it’s self documented, and it’s type-safe.

The “outcome pattern”

In our example, registerCreditCard can return the following type instead of returning void

type RegisterCreditCardResult =
  | { outcome: "success" }
  | { outcome: "error"; reason: "invalid_card_number" }
  | { outcome: "error"; reason: "card_expired" };

The code then becomes

const registerCreditCard = (
  cardNumber: string,
  expirationDate: Date
): RegisterCreditCardResult => {
  const isCreditCardNumberIsValid = getIsCreditCardNumberValid(cardNumber);
  if (!isCreditCardNumberIsValid) {
    return { outcome: "error", reason: "invalid_card_number" };
  }
  const isDateInTheFuture = getIsDateInTheFuture(expirationDate);
  if (!isDateInTheFuture) {
    return { outcome: "error", reason: "card_expired" };
  }
  // ...
  return { outcome: "success" };
};
try {
  const result = registerCreditCard("5168441223630339", new Date(2021, 11));
  if (result.outcome === "error") {
    if (result.reason === "invalid_card_number") {
      console.log("invalid card number");
    } else {
      console.log("card expired");
    }
  }
} catch (error) {
  console.log("unknown error");
}

We now have type-safe error handling, but there is still an issue. Say we add another error outcome in RegisterCreditCardResult because we want to be sure that the credit card issuer is YoloCard.

{
  outcome: "error";
  reason: "issuer_not_yolocard";
}

How do we make sure every caller of registerCreditCard handles this new error ?

The switch guard

We can do that by handling the different values of reason by adding a switch and a switch guard. The switch guard will tell Typescript that the default case must never be executed. So if you have a new value for reason, your code will not compile until you handle it because Typescript will complain.

const rejectUnexpectedErrorOutcomeReason = (result: never): never => {
  const reason =
    result && typeof result === "object"
      ? (result as any).reason
      : "<not an object>";
  throw new Error(
    `Unexpected value for reason attribute in error outcome: ${reason}`
  );
};
try {
  const result = registerCreditCard("5168441223630339", new Date(2021, 11));
  if (result.outcome === "error") {
    switch (result.reason) {
      case "card_expired":
        console.log("card expired");
        break;
      case "invalid_card_number":
        console.log("invalid card number");
        break;
      default:
        rejectUnexpectedErrorOutcomeReason(result);
    }
  }
} catch (error) {
  console.log("unknown error");
}

Now if you try to remove the case invalid_card_number or if you add the YoloCard outcome in the return type of registerCreditCard, you’ll see this kind of error

alt text

Conclusion

When you catch an exception, the caught value can be anything.
To have a type-safe error handling, you can handle the expected errors in the return value of the function. Then, by using a switch guard, you can make sure that every error that a function can throw is handled by the callers of this function.

It does not means you must not throw anymore: it depends on the situation. Also make sure to still use try/catch block to handle unexpected errors.