TypeScript: exhaustiveness checks via exceptions

[2020-02-14] dev, javascript, typescript
(Ad, please don’t block)

TypeScript supports exhaustiveness checking for enums and similar types. This blog post shows how to use idiomatic JavaScript for this kind of check.

Pattern: exhaustiveness checks via exceptions  

Consider the following enum:

enum NoYes {
  No = 'No',
  Yes = 'Yes',
}

The switch statement in the following code is idiomatic JavaScript:

function toGerman(x: NoYes) {
  switch (x) {
    case NoYes.No:
      return 'Nein';
    case NoYes.Yes:
      return 'Ja';
    default:
      throw new UnsupportedValueError(x); // (A)
  }
}

Can we also get static errors in TypeScript?

In line A, the inferred type of x is never because we have already taken care of all values that it can have. Therefore, we can instantiate the following exception in line A:

class UnsupportedValueError extends Error {
  constructor(value: never) {
    super('Unsupported value: ' + value);
  }
}

If, however, we forget one of the switch cases, then the type of x isn’t never anymore and we get a static error:

function toGerman(x: NoYes) {
  switch (x) {
    case NoYes.Yes:
      return 'Ja';
    default:
      //@ts-ignore: Argument of type 'NoYes.No' is not assignable to parameter of type 'never'. (2345)
      throw new UnsupportedValueError(x);
  }
}

Benefit: also works with if statements  

TypeScript also warns us if we use if statements:

function toGermanNonExhaustively(x: NoYes) {
  if (x === NoYes.Yes) {
    return 'Ja';
  } else {
    // @ts-ignore: Argument of type 'NoYes.No' is not assignable to parameter of type 'never'. (2345)
    throw new UnsupportedValueError(x);
  }
}
function toGermanExhaustively(x: NoYes) {
  if (x === NoYes.No) {
    return 'Nein';
  } else if (x === NoYes.Yes) {
    return 'Ja';
  } else {
    throw new UnsupportedValueError(x); // No warning
  }
}

Another way of checking exhaustiveness  

Instead of using a default case, we can also specify a return type. Then TypeScript warns us if we forget a case (because we implicitly return undefined):

enum NoYes {
  No = 'No',
  Yes = 'Yes',
}

//@ts-ignore: Function lacks ending return statement and return type does not include 'undefined'. (2366)
function toGerman(x: NoYes): string {
  switch (x) {
    case NoYes.Yes:
      return 'Ja';
  }
}

Downside: doesn’t work with if statements  

Alas, with this approach, we get a warning even if we exhaustively handle all cases (see second function, toGermanExhaustively()):

// @ts-ignore: Function lacks ending return statement and return type does not include 'undefined'. (2366)
function toGermanNonExhaustive(x: NoYes): string {
  if (x === NoYes.Yes) {
    return 'Ja';
  }
}

// @ts-ignore: Function lacks ending return statement and return type does not include 'undefined'. (2366)
function toGermanExhaustive(x: NoYes): string {
  if (x === NoYes.No) {
    return 'Nein';
  } else if (x === NoYes.Yes) {
    return 'Ja';
  }
}

Comparing the two approaches  

How does this approach compare to using an exception?

  • Upside: less verbose
  • Downsides:
    • No protection at runtime
    • Doesn’t work with if statements

Can this pattern be used elsewhere, too?  

This pattern works for:

  • Enums
  • Type unions
  • Discriminated unions