DEV Community

Cover image for Exhaustive Type Checking with TypeScript!
Babak
Babak

Posted on • Edited on

Exhaustive Type Checking with TypeScript!

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
}


Enter fullscreen mode Exit fullscreen mode

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
}


Enter fullscreen mode Exit fullscreen mode

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
}


Enter fullscreen mode Exit fullscreen mode

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
}


Enter fullscreen mode Exit fullscreen mode

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'
  }
}


Enter fullscreen mode Exit fullscreen mode

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
}


Enter fullscreen mode Exit fullscreen mode

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`
}


Enter fullscreen mode Exit fullscreen mode

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.
}


Enter fullscreen mode Exit fullscreen mode

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' )
}


Enter fullscreen mode Exit fullscreen mode

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'
})


Enter fullscreen mode Exit fullscreen mode

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

Top comments (7)

Collapse
 
codefinity profile image
Manav Misra

You can see here that this part of the 'Beta manual' is missing. Maybe you want to contribute there by doing a pull request?

Collapse
 
vimkin profile image
Vadim Kalinin

I came up with the idea, that throwing an error inside the function solely created for this purpose (in your case exhaustiveCheck) feels somehow odd. Throwing the error directly (e.g. UnsupportedValueError(x)) or declaring the output type for the function explicitly feels more natural. For interested, both cases are described in detail here: 2ality.com/2020/02/typescript-exha....

imho using a helper library that replicates the switch for exhaustive checking which is already supported by the typescript feels unnecessary.

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 • Edited

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.

Collapse
 
mrispoli24 profile image
Mike Rispoli

I've been trying this method myself but my typescript compiler won't let me use the never even if there is no possible way for it to be hit. Is there a tsconfig option missing here?