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";
...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";
}
I use
kind
as a discriminating property if nothing else comes to mind. The wordtype
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;
}
}
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
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";
}
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;
}
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.
Finally the fix is easy - ensuring exhaustiveness makes the redline go away...
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)