Everyday TypeScript: Exhaustiveness Checking
Welcome to the Exhaustiveness Checking lesson!
This lesson is shown as static text below. However, it's designed to be used interactively. Click the button below to start!
We often write functions that take union types as arguments. For example, we might write a function that takes either a
Useror aCat.>
interface User {kind: 'user'userName: string}interface Cat {kind: 'cat'catName: string}The
getNamefunction below takes aUser | Cat. It adds theuserNameorcatNameto anamesarray, passed as the second argument.- Note: this code example reuses elements (variables, etc.) defined in earlier examples.
>
function getName(hasName: User | Cat, names: string[]) {switch (hasName.kind) {case 'user':names.push(hasName.userName);break;case 'cat':names.push(hasName.catName);break;}}const names: string[] = [];getName({kind: 'user', userName: 'Amir'}, names);getName({kind: 'cat', catName: 'Ms. Fluff'}, names);names;Result:
['Amir', 'Ms. Fluff']
That function works well enough. (But you may object to
namesbeing passed as an argument. If so, just wait!)Now suppose that we add more alternatives to the
User | Catunion. We want to allow dogs as arguments as well.- Note: this code example reuses elements (variables, etc.) defined in earlier examples.
>
interface Dog {kind: 'dog'dogName: string} Let's say we accidentally forget to handle the
Dogcase in the function body. When we pass aDog, our function won't add its name to the array!- Note: this code example reuses elements (variables, etc.) defined in earlier examples.
>
function getName(hasName: User | Cat | Dog, names: string[]) {switch (hasName.kind) {case 'user':names.push(hasName.userName);break;case 'cat':names.push(hasName.catName);break;}}const names: string[] = [];getName({kind: 'user', userName: 'Amir'}, names);getName({kind: 'cat', catName: 'Ms. Fluff'}, names);getName({kind: 'dog', dogName: 'Woofy'}, names);names;Result:
['Amir', 'Ms. Fluff']
In this small example, the
User | Cat | Dogtype is defined right in the function's signature, close to theswitch. That makes the mistake easy to spot.Now imagine what this looks like in a large software system. We define the union type in one file, but use it in functions spread across dozens of other files. If we add a new type to the union, we might forget to update some of those functions. This kind of error might seem easy to spot and fix in our small example, but it poses a serious danger in large systems.
Fortunately, we can use the type system to prevent this type of bug. The
getNamefunction below is identical to our previous one, except that it now returnstruein everycasethat handles a name. Suddenly, TypeScript gives us a type error, showing us the bug!- Note: this code example reuses elements (variables, etc.) defined in earlier examples.
>
function getName(hasName: User | Cat | Dog, names: string[]): true {switch (hasName.kind) {case 'user':names.push(hasName.userName);return true;case 'cat':names.push(hasName.catName);return true;}}Result:
type error: Function lacks ending return statement and return type does not include 'undefined'.
Check the type error message. The compiler can see that our
switchdidn't handle all possible cases (user, cat, and dog). Since somecases are missing, the compiler knows that control will sometimes reach the end of the function. In JavaScript and TypeScript, that implicitly returnsundefined. But we didn't putundefinedin the function's return type, so we get a type error.We can fix the type error by handling the dog case. Then the function type checks and everything works again.
- Note: this code example reuses elements (variables, etc.) defined in earlier examples.
>
function getName(hasName: User | Cat | Dog, names: string[]): true {switch (hasName.kind) {case 'user':names.push(hasName.userName);return true;case 'cat':names.push(hasName.catName);return true;case 'dog':names.push(hasName.dogName);return true;}}const names: string[] = [];getName({kind: 'user', userName: 'Amir'}, names);getName({kind: 'cat', catName: 'Ms. Fluff'}, names);getName({kind: 'dog', dogName: 'Woofy'}, names);names;Result:
['Amir', 'Ms. Fluff', 'Woofy']
In these examples, we're using TypeScript to do "exhaustiveness checking". "Exhaustive" means "considering all possibilities". By writing our functions in this way, with each
casereturning a value, the TypeScript compiler checks that our code handles all possibilities.Our examples above returned
true, and had an explicit return type oftrue, which is what makes all of this work. Thetruevalue isn't useful to us at runtime, but we have to return something in order to use exhaustiveness checking.Sometimes, a synthetic return value like
trueis fine, but most well-written functions won't need a synthetic return value at all. For example, if ourgetNamefunction simply returns the name as a string, we get exhaustiveness checking for free.- Note: this code example reuses elements (variables, etc.) defined in earlier examples.
>
function getName(hasName: User | Cat | Dog): string {switch (hasName.kind) {case 'user':return hasName.userName;case 'cat':return hasName.catName;case 'dog':return hasName.dogName;}}[getName({kind: 'user', userName: 'Amir'}),getName({kind: 'cat', catName: 'Ms. Fluff'}),getName({kind: 'dog', dogName: 'Woofy'}),];Result:
['Amir', 'Ms. Fluff', 'Woofy']
- Note: this code example reuses elements (variables, etc.) defined in earlier examples.
>
function getName(hasName: User | Cat | Dog): string {switch (hasName.kind) {case 'user':return hasName.userName;case 'cat':return hasName.catName;}}[getName({kind: 'user', userName: 'Amir'}),getName({kind: 'cat', catName: 'Ms. Fluff'}),getName({kind: 'dog', dogName: 'Woofy'}),];Result:
type error: Function lacks ending return statement and return type does not include 'undefined'.
Generally, we associate exhaustiveness checking with
switch. However, it works withifas well.- Note: this code example reuses elements (variables, etc.) defined in earlier examples.
>
function getName(hasName: User | Cat | Dog): string {if (hasName.kind === 'user') {return hasName.userName;} else if (hasName.kind === 'cat') {return hasName.catName;}}[getName({kind: 'user', userName: 'Amir'}),getName({kind: 'cat', catName: 'Ms. Fluff'}),getName({kind: 'dog', dogName: 'Woofy'}),];Result:
type error: Function lacks ending return statement and return type does not include 'undefined'.
Here's a code problem:
The
speciesfunction below uses aswitchstatement to return the species of variousAnimals. Originally, theAnimaltype was a union ofDog | Cat. Now, we've updated it to be a union ofDog | Cat | Horse.When we made that change, our
speciesfunction below started type erroring. The problem is that it doesn't handle all of the possibilities: it's not "exhaustive". Add horse support to the function. Horses' species is "equus ferus caballus".Remember to add a new
case. Don't usedefault:!type Dog = {kind: 'dog'};type Cat = {kind: 'cat'};type Horse = {kind: 'horse'};type Animal = Dog | Cat | Horse;function species(animal: Animal): string {switch (animal.kind) {case 'dog':return 'canis familiaris';case 'cat':return 'felis catus';case 'horse':return 'equus ferus caballus';}}[species({kind: 'dog'}),species({kind: 'cat'}),species({kind: 'horse'}),];- Goal:
['canis familiaris', 'felis catus', 'equus ferus caballus']
- Yours:
['canis familiaris', 'felis catus', 'equus ferus caballus']
We asked you not to use
defaultin that code problem. Depending on how you learned JavaScript, this lesson may seem to violate a rule that you've heard: "always provide adefault:inside everyswitch".That rule exists because JavaScript has no type system, so it can't check for exhaustiveness. Adding a
defaultin JavaScript ensures that we at least consider what happens when none of ourcases match.In TypeScript, providing a
default:inside of ourswitcheffectively disables exhaustiveness checking. The whole point ofdefault:is to handle any case, no matter what it is. With adefault:, there can be no unhandled cases, so TypeScript can't check for exhaustiveness.The reality is that
default:is good in some, but not all, situations. For example, in ourspeciesfunction, we probably don't want a default species name. That idea doesn't even make sense!In some other cases, a default makes perfect sense. For example, imagine that we're writing a
dueForHeartWormMedicinefunction. A few species of animals need periodic medicine to prevent heart worms, but most species don't. It makes sense to write aswitchto handle the few species who need the medicine, then add adefault: return falseto handle the rest.Some TypeScript features look esoteric but are actually important. This is one of those features! Type unions are useful, so complex TypeScript applications are full of them. But a union isn't useful unless you actually unpack it somewhere by running different code for the different cases, like
User | Cat | Dog. Each time we unpack a union, there's a chance we'll forget to handle a case.Fortunately, there's a way to prevent the mistake. When you use
switchon a TypeScript union:- Put the
switchinside of a function where each casereturns. - Give the function an explicit return type.
- Don't add a
defaultunless you're very sure that you need one.
- Put the
Sometimes you'll have to refactor away from this simple design, but in most cases it will work, and it will ensure that you benefit from exhaustiveness checking.
Fortunately, extracting a function like this usually makes the code more readable, as it did for the examples in this lesson. Our original
getNamefunction awkwardlypushed the species name into an array. But our revised version with exhaustiveness checking simply returned the name, which is much easier to work with.