Type Safe and Exhaustive ‘switch’ statements, aka Pattern Matching in TypeScript
How to get TypeScript compiler to ascertain type safe and exhaustive matching in switch statement. Without using any external libraries.
Being a bit inclined towards functional programming, I wanted to try and build a “type safe” and “exhaustive” Pattern Matching in TypeScript, without using any additional libraries, and only using switch statements and the power of Type Inference and Control Flow Analysis provided by TS.
So... a switch statement…
While switch statements don’t need much introduction and are pretty ordinary in themselves, the sections in this blog would:
- Introduce the code snippet (sample problem statement) we’ll be working with,
- Shall discuss what are the limitations of switch statements when used in their naive form
- And, then go on later to discuss a few enhancements (by harnessing native TypeScript features only!) that we can make to make our switch statements more Type-Safe and Exhaustive.
Note: The aim of this post is not to suggest the resulting code as a ‘better’ pattern for implementing calculator application or similar problems. It is merely intended for demonstrating the type safety and exhaustiveness that we can achieve while employing switch statements by harnessing only native (without usage of external libraries) features of TypeScript.
We’ll work with a simple calculator application/function. To begin with, lets assume our calculator does only two operations, viz. Add and Multiply. A simple code for the same would be as follows:
Straight forward, isn’t it. We’ve got two types of Operations (Add and Multiply) and then a simple function using switch statement to perform the appropriate operation.
Now, what happens if we remove either of the case statements, or we add a new Operation type, say Subtract? TS compiler won’t complain in either scenarios because we have a default case as a fallback. We agree returning a 0(zero) from default is a big NO. Not only 0 is a legitimate result from one of the operations, it doesn’t signify that the calculator was not able to perform an unknown operation. We can also return an undefined or a null, or throw Error maybe from our default case. But all of these approaches introduce an extra branch and a possiblity that the client code will have to check for.
We can always write unit tests to make sure either of the above doesn’t go unnoticed.
But what if we can get the compiler to fail itself ?!?!
The ‘default’ case and ‘never’ type
Lets focus on default case. Let’s just log the Operation type that goes unmatched and is caught by the default case.
Notice that TypeScript infers
console.log statement as never type, as shown below.
This is because of control flow analysis done by TS. Since the
operation type can be one of ‘ADD’ and ‘MULTIPLY’, there are naturally no more types left for it to assume in the default case, hence it assumes the never type.
neveris a subtype of and assignable to every type.
No type is a subtype of or assignable to
neveritself). Additionally, it is a bottom-type.
In a function expression or arrow function with no return type annotation, if the function has no
returnstatements, or only
returnstatements with expressions of type
never, and if the end point of the function is not reachable (as determined by control flow analysis), the inferred return type for the function is
Deriving from the above, lets create a function which will signal whether or not our switch case matching is exhaustive.
The function takes in a never type parameter and “never” returns. So in case we try to pass any other type as a parameter, TS compiler would be unhappy and fail. But since the return type is also never, it can be used in conjunction with any other return type within a function.
Stitching it all together
So lets replace the
return 0 in our default case in our code with the
exhaustiveMatchingGuard function we created in previous section.
Notice that TS compiler is happy with our code because (as elaborated in “The ‘default’ case and ‘never’ type” section) there is no other type left for
operation to take on.
Let’s notice what happens if we add another type of Operation:
and Voila!! the compiler is not happy that we have forgotten to match the Subtract case!! It tells us that argument of type
string is not assignable to type
never , as expected!
And hints us that operation: ‘SUBTRACT’ is the culprit. Once we implement it, we’re back to our happy state!
One of the places that this kind of exhaustive matching comes very useful is while using redux and deriving next state based on different action types in reducers.
I hope this article gives some inspiration and inclination towards functional and type system side of TypeScript. I’m listing below some additional resources that you might find useful for futher explorations: