Mindfulness in Typescript code branching. Exhaustiveness, pattern matching, and side effects. 1/2
Mindfulness in Typescript code branching. Exhaustiveness, pattern matching, and side effects. 1/2: “Exhaustive absurd”
This is part one of two in our series about code branching in Typescript. The first post serves as an introduction to the topic and is intended to be entry-level. It shows useful techniques of how to improve branching safety with explicit exhaustiveness checks.
As Developers, we probably all wrote our first if/else statement when we were just newborns (that is, 0-years-experienced newborns in the industry).
if (x > 5) {
console.log('Greater than 5');
} else {
console.log('Not greater than 5');
}
We proceeded with learning switch/case, and usually end here. “We’re ready to hack The Next Facebook. Nobody can stop us now, not even our teamlead. Or even SIGTERM!”
Ok, the last one was a bit dark.
switch (x) {
case 5:
console.log('Five');
break;
case 6:
console.log('Six');
break;
default:
console.log('Not five or six');
}
Then we learn about OOP and inheritance, and that it also can provide branching:
class Animal {
constructor(public name: string) {}
makeSound(): string {
return `${this.name} makes a sound.`;
}
}
class Dog extends Animal {
name = 'Dog';
makeSound(): string {
return `${this.name} barks.`;
}
}
class Cat extends Animal {
name = 'Cat';
makeSound(): string {
return `${this.name} meows.`;
}
}
// see, no if/else or anything
const handleSound = (animal: Animal): string => animal.makeSound();
const dog = new Dog('Storm');
const cat = new Cat('Viper');
// "Storm barks."
handleSound(dog);
// "Viper meows."
handleSound(cat);
We can also invert the control of the above using visitor pattern (https://en.wikipedia.org/wiki/Visitor_pattern) which I’m to detail in the next post.
The most inquisitive of us all probably wondered how we can do more complex branching and encountered pattern matching in such languages as Haskell, Scala, OCaml and Rust:
def listMatch(lst: List[Int]): String = lst match {
case Nil => "Empty list"
case List(0, _, _) => "Starts with zero and has three elements"
case List(x, y) if x == y => "Two identical elements"
case _ => "Other"
}
But is there a deeper meaning to this syntax? And are there underlying intricacies we should be aware of?
In this brief article, I’ll hopefully present a way of deeper thinking about code branching. I’ll show how to improve type safety and composability of branching in Typescript.
Why branch?
We need if/else or equivalents to do anything useful in classic programming. Without it, programs will be static and won’t do much. Making programmatic decisions will be very hard. Most likely you’ll map/reduce some data into some other data and that’ll be it.
Assume that you want to send a notification to a user. A user may use different channels to receive notifications, and you want to dispatch properly:
- emails to an email APIs such as Sendgrid
- Slack to Slack APIs etc.
type Notification =
| { type: 'email'; recipient: string; subject: string; body: string }
| { type: 'sms'; phoneNumber: string; message: string }
| { type: 'push'; deviceId: string; title: string; body: string }
| { type: 'slack'; channelId: string; text: string }
You can handle it with a naive switch/case:
I’m going to use “break” or “return” for switches everywhere in this post; we won’t go into fallthrough logic.
function handleNotification(notification: Notification) {
switch (notification.type) {
case 'email':
sendEmail(notification.recipient, notification.subject, notification.body);
break;
case 'sms':
sendSMS(notification.phoneNumber, notification.message);
break;
case 'push':
sendPushNotification(notification.deviceId, notification.title, notification.body);
break;
case 'slack':
postToSlack(notification.channelId, notification.text);
break;
}
}
The kind of type we match against (
Notification
) is called discriminated union.
This code has some potential issues I’ll talk about later. The main point here is that it shows why we want to branch our code at all.
Playing around with if/else
You also can rewrite the code above to if/else. That won’t change much, it’s just a bit more boilerplate in this case.
if/else is much more powerful since you can give it any expression resulting in a boolean, e.g.
if (x > 5)
, whereas switch/case would only accept exact matches.
Picture source
function handleNotification(notification: Notification) {
if (notification.type === 'email') {
sendEmail(notification.recipient, notification.subject, notification.body);
} else if (notification.type === 'sms') {
sendSMS(notification.phoneNumber, notification.message);
} else if (notification.type === 'push') {
sendPushNotification(notification.deviceId, notification.title, notification.body);
} else if (notification.type === 'slack') {
postToSlack(notification.channelId, notification.text);
}
}
In both cases, Typescript figures out the shape of the notification object after the “type” field check. Additionally you won’t be able to write something like notification.type === 'GIBBERISH'
or case('yes?')
; it will stop you.
A difference with switch/case is that if/else has more boilerplate, but also is more applicable to more general cases because it allows expressions (such as math comparison x > 2
) in its “decision tree”.
Adding new cases
Now, time for the bad news. We want to add a new case like { type: 'discord'; channel: string; message: string }
. We add it to the union type definition but forget to add to handleNotification
function.
handleNotification
works for a week until we notice users aren’t getting notified. They lose their money, the business goes down, marriages break up. All because we forgot to handle type === 'discord'
.
This is a recurring problem. Fortunately, there exists a solution already.
function absurd(x: never): never {
throw new Error(`panic! not reachable: ${x}`);
}
function handleNotification(notification: Notification) {
switch (notification.type) {
// ... switch/case from handleNotification above and then ...
default:
absurd(notification.type);
}
}
This function is also called
assertNever
in TS documentation. I’ll stick toabsurd
because it’s more fun.
How does it work? Each case
(or if/else
) Typescript narrows down the possible type of notification.type
:
// here, notification.type is full 'email' | 'sms' | 'push' | 'slack' | 'discord'
switch (notification.type) {
case 'email':
break;
// if we end with "default" clause here, notification.type would be 'sms' | 'push' | 'slack' | 'discord', so, with no 'email'
case 'sms':
break;
// if we end with "default" clause here, notification.type would be 'push' | 'slack' | 'discord', so, with no 'email' or 'sms'
case 'push':
break;
// if we end with "default" clause here, notification.type would be 'slack' | 'discord', so, with no 'email' or 'sms' or 'push'
case 'slack':
postToSlack(notification.channelId, notification.text);
break;
// finally, we're ending with "default" clause here, and so notification.type is 'discord', with no 'email' or 'sms' or 'push' or 'slack'
default:
absurd(notification.type);
}
But absurd(notification.type)
expects never
type! It won’t allow anything else, yet we’re trying to feed it ‘discord’ string literal.
And so it goes: compiler complains, you realize your code has a bug, you fix it before shipping to your users and not after (that is, unless you also wrote good tests.)
You fix it by adding another case
clause:
function handleNotification(notification: Notification) {
switch (notification.type) {
// ...
case 'discord':
postToDiscord(notification.channelId, notification.message);
break;
// default: ...
}
}
What about more “type-free” comparisons like n > 5? Type narrowing doesn’t apply here. You have to figure yourself whether you covered all cases or not.
A peculiar case of “never”
Artist: Serytama.art
never
is a very special type in Typescript.
It’s assignable to anything, which isn’t very useful in our case, but is useful in more advanced cases.
But another property we can and do leverage: nothing can be assigned to never
, except never
itself.
So, the function absurd
expects only never
type.
function absurd(x: never): never {
throw new Error(`panic! not reachable: ${x}`);
}
absurd('fizzbuzz'); // error: Argument of type '"fizzbuzz"' is not assignable to parameter of type 'never'.
When our notification.type
above is checked, it narrows down gradually to lesser and lesser type, until only discord
literal is left, and finally we narrow discord
literal itself.
When nothing is left out of our poor notification.type
type, only never
remains.
There’s one extra way to show that in code; with an (arguably ugly) ternary:
// some imaginary numeric "code"...
const code: number =
notification.type/*'email' | 'sms' | 'push' | 'slack' | 'discord'*/ === 'email' ? 1 :
notification.type/*'sms' | 'push' | 'slack' | 'discord'*/ === 'sms' ? 2 :
notification.type/*'push' | 'slack' | 'discord'*/ === 'push' ? 3 :
notification.type/*'slack' | 'discord'*/ === 'slack' ? 4 :
notification.type/*'discord'*/ === 'discord' ? 5 :
absurd(notification.type/*never*/);
Importantly, when never
-typed values in code, you can always assume this part of the code is unreachable, assuming your typing has no bugs. In Typescript, it may happen e.g. because of casting with as
. That’s why I accompany the never
check in absurd
with a throw
. Better safe than sorry.
Object key mapping
There’s another technique to map behaviours that’s worth mentioning. It lets us (in some cases) avoid the need for switch/case
, if/else
, and still have exhaustive behaviour without using any absurd
hacks.
export const handlers = {
email: (notification: Notification & {type: 'email'}) => sendEmail(notification.recipient, notification.subject, notification.body),
sms: (notification: Notification & {type: 'sms'}) => sendSMS(notification.phoneNumber, notification.message),
push: (notification: Notification & {type: 'push'}) => sendPushNotification(notification.deviceId, notification.title, notification.body),
slack: (notification: Notification & {type: 'slack'}) => postToSlack(notification.channelId, notification.text),
discord: (notification: Notification & {type: 'discord'}) => postToDiscord(notification.channelId, notification.message),
};
// ...
const notification: Notification = {type: 'email', recipient: 'igor@loskutoff.com', subject: 'hello', body: 'world'};
handlers[notification.type](notification);
Note & {type: 'email'}
in the argument type. It would narrow down the type of notification
, opening up the fields receipient
, subject
and body
to be used in the handler. Same with sms
and push
and slack
and discord
.
With handlers
, you won’t be able to write a new type of notifications without adding a new handler, which is the main benefit of this technique.
Dead code elimination works here as well: if you remove one of the handlers, its & {type: }
won’t compile anymore, inviting you to remove the corresponding case as well, same as with switch/case
or if/else
.
I see certain disdain for this technique in the community lately. The main argument is that it introduces a level of indirection. Although I’m sure this wariness has some grounding, I personally don’t care about these accusations and use the technique whenever I see fit. You can take it or leave it, because there’s a plenty of other ways to achieve the same goal.
Eslint rule
Honorary mention to switch-exhaustiveness-check, which while not a “typescript”-native solution, is still good enough to preserve exhaustiveness for switch/case.
It’s a ESlint rule that enforces exhaustiveness of enums and union types.
Never have I ever…
In this post, I’ve introduced the concept of exhaustiveness checking and explored some of the ways to branch in Typescript.
These tools alone, used properly, will drastically improve your type safety and save you from many runtime bugs.
I haven’t talked about return types yet; i’ll save that for the follow up.
In that post I’ll also talk about more advanced notions, such as pattern matching with ts-pattern and in other languages, expressions and side effects, IIFE, discriminated unions and algebraic data types (spoiler: we used the latter two in the examples above), and what they do in OOP to achieve the same goal (spoiler: Visitor Pattern).
I’ll also present a case that in most situations, we won’t need the absurd
-like function call at all, even if you don’t explicitly declare return type.