DEV Community

Cefn Hoile
Cefn Hoile

Posted on • Updated on

Typescript `satisfies never`: Exhaustiveness checking in Typescript

Image description

Use Case: Handle every case of a union

Exhaustively handling every case of a union is a common pattern needed in a Typescript codebase. This could be a simple 'string literal' union like...

type Flavour = "vanilla" | "chocolate" | "mint";
Enter fullscreen mode Exit fullscreen mode

...or a more complex discriminated union.

Note: This article describes a specific and valuable use case for satisfies - for exhaustiveness checking in unions. If you need a background introduction to the operator, you can see my Medium article on satisfies .

Discriminated Unions

In a discriminated union a named property of an object is used to differentiate between distinct shapes that an object's state can take. In the example below, different shapes have a different value of the kind property.

Code paths at runtime will use item.kind === to check the shape of an item before processing it. But what if we forget to handle one of the different shapes? Is the only way to handle this throwing an Exception at runtime? Surely that's too late!

Satisfies to the rescue

Typescript can check which cases you have handled at compile time. It can ensure we don't find out the hard way at runtime that we forgot to handle one of the valid shapes of an object's state.

I was really pleased to see the satisfies keyword added to the Typescript language. It has enabled a whole load of new code patterns, and one of them is checking exhaustiveness of unions - that you haven't missed a case.

Worked Example: The 'Tech Layoffs' Game

As a worked example, let's define a user action in an imaginary 'Tech Layoffs' game.

In the game you first have to run around the office, trying to find any co-workers who have been forced to come into the office today, and say goodbye to them before they are summarily fired.

type Action =
  | {
      kind: "runaround";
      speed: number;
    }
  | {
      kind: "saygoodbye";
      phrase: "Hasta la vista" | "I'll be back";
    }
Enter fullscreen mode Exit fullscreen mode

I use kind as a discriminating property if nothing else comes to mind. The word type already has a meaning in Typescript.

Each action structure has a distinct payload, so you can't write code that accesses the phrase property of a "runaround" action or the speed property of a "saygoodbye" action.

In fact the union itself has neither speed nor phrase properties, because those properties are not defined on all members of the union. The kind property is the only one in common.

Let's write an action handler which exploits the property in common, and allows Typescript to reason about which kind of action we are dealing with.

function handleAction(action: Action) {
  const { kind } = action;
  if (kind === "runaround") {
    setSpeed(action.speed);
    return;
  }
  if (kind === "saygoodbye") {
    say(action.phrase);
    return;
  }
}
Enter fullscreen mode Exit fullscreen mode

It handles all the cases - you checked. Both paths compile and can access a number or string to call setSpeed and say respectively. So far so good.

However, it really isn't good enough for an enterprise codebase. You want contributors to be able to make changes 2 years from now without having to trace every possible implicit bug.

The failure case

Spaghetti oops - photo by Neal Fowler

The game designer is getting really clear user feedback from the players of "Tech Layoffs". The users know the rules, they know the game and they're going to play it. But honestly they don't like the endless cycle of fearful running and saying farewell to departing colleagues around a soulless maze of cubicles.

As new feature work, an exit journey is added to the game. The type was extended with a "giveup" case...

type Action =
  | {
      kind: "runaround";
      speed: number;
    }
  | {
      kind: "saygoodbye";
      phrase: "Hasta la vista" | "I'll be back";
    }
  | {
      kind: "giveup";
      reason: "Too difficult" | "Made me cry";
    }
Enter fullscreen mode Exit fullscreen mode

But...oh no, the corresponding change wasn't made to our handler. It's silently ignoring one of the Actions in the game, and the change raised no compiler errors!

Wouldn't it be nice to have the handler's exhaustiveness enforced by the compiler. Well since Typescript 4.9 there is a really elegant way!

The example below shows a handler that breaks as soon as anyone adds a case to the union which isn't handled, by simply adding the line kind satisfies never (and maybe a helpful comment).

function handleAction(action: Action) {
  const { kind } = action;
  if (kind === "runaround") {
    setSpeed(action.speed);
    return;
  }
  if (kind === "saygoodbye") {
    say(action.phrase);
    return;
  }
  // every `kind` already handled
  // line should never be hit
  kind satisfies never;
}
Enter fullscreen mode Exit fullscreen mode

Typescript can reason about the control flow as our procedure eliminates values from the union because we return when any value is matched.

The first if clause eliminated "runaround" in this way. The second eliminated "saygoodbye". The line kind satisfies never then acts as a guard that there are no further values.

If every value has been eliminated then the value of kind can indeed be assigned to type never. That is to say the control flow analysis has eliminated that path - there's no further value that kind could ever be. This handler is never going to let you down.

How to know what's been going on?

Here you can see the presentation of the compiler error in an editor. Since we added an extra action, it warns you that there's a possible value of kind that might reach the guard.

Never gonna give you up

Finally the fix is easy - ensuring exhaustiveness makes the redline go away...

Never gonna let you down

You wouldn't get this with any other guy

For those who read this far, I'm open to work, and you can see my CV at https://cefn.com/cv

Top comments (0)