loading...
Cover image for Exhaustive Type Checking with TypeScript!

Exhaustive Type Checking with TypeScript!

babak profile image Babak ・4 min read

Introduction

Wouldn't it be great if you could spot a bug the moment you wrote it down? That's what static type analysis is all about. Catch your bugs at compile-time as you type them. This is why TypeScript is such a major productivity booster; especially at the strictest setting.

TypeScript is designed to be a superset of JavaScript, which is why certain features have to be unlocked through patterns and principals. One such great feature is Exhaustiveness Checking.

In short, Exhaustive Type Checking is when your static compiler checks that you're not leaving a possibility unchecked. It helps to think about how null checking works.

function test(x: string | null) {
    if (x === null) {
        return;
    }
    x; // type of x is string in remainder of function
}

The function test above accepts a parameter x which can be either a string or null. Notice that below the if block with the return statement, TypeScript knows that x can now only be string.

If we now add another if block for the case when x is of type string:

function test(x: string | null) {
  if (x === null) {
    return
  }
  if (typeof x === 'string') {
    return;
  }
  x; // type of x is never the remainder of the function
}

Then x below that block is of type never. All possibilities for x have been exhausted. We're now ready to discuss exhaustiveness checks.

Exhaustive Type Checking

TypeScript supports literal values as types. Suppose we have a function makeDessert that takes a fruit of type Fruit and returns a string value to represent the name of the dessert made.

type Fruit = 'banana' | 'orange'
function makeDessert( fruit: Fruit ) {
  // make dessert from type Fruit
}

How do we get compiler to insure we've covered all possible values for fruit inside the makeDessert function?

We start by checking all possibly values:

type Fruit = 'banana' | 'orange'
function makeDessert( fruit: Fruit ) {
  switch( fruit ) {
    case 'banana': return 'Banana Shake'
    case 'orange': return 'Orange Juice'
  }
  x // x has type never here
}

Since we've checked for both banana and orange, x has a type never at the bottom of this switch statement. Now we want to insure this.

From here, there are two ways to do this. The first is to specify the return type of the function

function makeDessert( fruit: Fruit ): string {
  switch( fruit ) {
    case 'banana': return 'Banana Shake'
    case 'orange': return 'Orange Juice'
  }
}

Because we specify the return type, the compiler will let us know if we are not handling a specific case. However, currently, this benefit goes away if the want to throw below the switch statement. TypeScript will assume that never is an acceptable return situation. It may be important to handle situations where the income type is any and does not match our cases.

The second way--to use a function that only accept a parameter of type never, which is what x is at the bottom of the switch statement. That way, if the type Fruit changes, x will no longer be of type never. That would trigger a compiler error and just like that, we have exhaustiveness checking powered by the compiler.

Let's look at this:

type Fruit = 'banana' | 'orange'

function exhaustiveCheck( param: never ) { }

function makeDessert( fruit: Fruit ) {
  switch( fruit ) {
    case 'banana': return 'Banana Shake'
    case 'orange': return 'Orange Juice'
  }
  exhaustiveCheck( fruit ) // ✅ no error
}

Now if Fruit included a value mango, we would receive a compiler error, informing us that fruit at the bottom of the switch statement can be mango.

type Fruit = 'banana' | 'orange' | 'mango'

function exhaustiveCheck( param: never ) { }

function makeDessert( fruit: Fruit ) {
  switch( fruit ) {
    case 'banana': return 'Banana Shake'
    case 'orange': return 'Orange Juice'
  }
  exhaustiveCheck( fruit ) // 🚫 ERROR! `mango` is not assignable to type `never`
}

To fix this error, we have to handle mango as a possible value

type Fruit = 'banana' | 'orange' | 'mango'

function exhaustiveCheck( param: never ) { }

function makeDessert( fruit: Fruit ) {
  switch( fruit ) {
    case 'banana': return 'Banana Shake'
    case 'orange': return 'Orange Juice'
    case 'mango' : return 'Mango Smoothie'
  }
  exhaustiveCheck( fruit ) // ✅ no error, all values handled.
}

Finally, just in case, we should protect ourselves against the possibility the value for fruit being any, which turns off type checking and allows any value to pass into makeDessert. We do this by throwing an error in the exhaustiveCheck function:

function exhaustiveCheck( param: never ): never { 
  throw new Error( 'should not reach here' )
}

This solution is outlined in this StackOverflow issue:

https://stackoverflow.com/questions/39419170/how-do-i-check-that-a-switch-block-is-exhaustive-in-typescript

Can we achieve Exhaustiveness Checking in TypeScript in a less verbose way? A more functional way?

Yes and yes! 😅

Helper Library

I've put together a helper library

https://github.com/babakness/exhaustive-type-checking

Along with a full video tutorial 🎬

To highlight an example, we can create makeDessert as follows:

const makeFruit = matchConfig<Fruit>()
const makeDessert = match({
  banana: () => 'Banana Shake'
  orange: () => 'Orange Juice'
  mango: () => 'Mango Smoothie'
})

This pattern offers a more compose-able way to do exhaustive checking with the same auto-complete goodness the switch pattern enjoyed.

Conclusion

Exhaustiveness checks can really help cut-down runtime bugs. Indeed, even with Test-Driven Development, if we never the write code to handle a situation, our code coverage statistics wouldn't reflect that we didn't test for it either.

Enjoy and feel free to visit me on Twitter and on my new YouTube Channel

Discussion

pic
Editor guide
Collapse
mindplay profile image
Rasmus Schultz

The error message you get with this "trick" is only useful if you know why you're getting it - adding a function just for the purposes of triggering a confusing compiler error?

I like this approach better:

dev.to/housinganywhere/matching-yo...

This gives more meaningful error messages, with a function that actually does something - and resembles the matching pattern from functional languages quite well. 👍

Collapse
ionutgi profile image
IonutGI

Exhaustiveness checking seems like the 'default' behavior for the above switch statements.

Great article though.

Collapse
cubiclebuddha profile image
Cubicle Buddha

Yes, but it’s more explicit. Even in C# (which doesn’t have exhaustiveness checking like TypeScript), the best practice is to throw an error in the default case that says “unexpected case”. The benefit of TS is that it can catch those unexpected cases at compile time too! :)

Collapse
ionutgi profile image
IonutGI

Agreed. Thanks a lot.