Using TypeScript to help with handling all cases

Published on 2024-10-11 by Mark Hanna

TypeScript is a really useful tool for finding potential issues with JavaScript code while it's still in your IDE. It works by letting you annotate your JavaScript with type information, so your IDE has enough information to tell you if you're treating something like it's a different type.

The main downsides of using it are that you need a build system with a step that strips out all the type annotations to leave you with plain JavaScript that browsers can understand, and that it's a new language to learn on top of JavaScript. Personally I think the upsides well outweigh the downsides, and I use TypeScript in all my JavaScript projects now.

In this article, I want to go over a pattern that I've found particularly useful, which relies on a few TypeScript features.

I really like using discriminated unions, where I might have code that needs to handle several different types of object. That code might look something like this (swap out if/else if for switch/case if that's what you prefer):

if (item.type === ItemType.TYPE_A) {
	// Do something to handle type A
} else if (item.type === ItemType.TYPE_B) {
	// Do something to handle type B
}

The danger of this approach, in regular JavaScript, is that if you ever add a new type to your list of types, it needs to be handled somehow in every place where you have a block like this. That's where TypeScript can come in, by taking advantage of function overloads:

export function assertAllUnionMembersHandled(value: never): never;
export function assertAllUnionMembersHandled(value: unknown): never {
	throw new TypeError(`Union member ${value} was not handled.`);
}

When defining function overloads with TypeScript, first you list each function signature (starting with the most specific, as the first ones take priority) and then you write the actual function implementation with its own signature. When you call the function, the implementation signature is essentially ignored, so in this case your IDE will treat it as having the signature (value: never) => never.

This means, if you try to pass in anything that TypeScript thinks has a type other than never, your IDE will highlight it as an error. never is a funny type, I find it's most useful to think of it as a union type with zero members. So as you narrow a member type like 'A' | 'B' | 'C', once all three members have been removed through type narrowing you'll have zero members remaining, and that's never.

You can take advantage of this when handling all members of a union type by calling this function in the else (or default) part of your block:

if (item.type === ItemType.TYPE_A) {
	// Do something to handle type A
} else if (item.type === ItemType.TYPE_B) {
	// Do something to handle type B
} else {
	assertAllUnionMembersHandled(item);
}

Now, if you extend item to have a third possible type, TypeScript will see item.type as having that new member as its type, and highlight it as an error. If you have followed this pattern in every place where you need to handle each different member of a union type, they'll all be identified as errors that can guide you to handling your new type wherever necessary.

Here's an example in the TypeScript Playground where using this function highlights an error where a union member is unhandled. Unfortunately, you'll see that the error message is not exactly clear:

Argument of type '{ type: "C"; value: boolean; }' is not assignable to parameter of type 'never'.
The call would have succeeded against this implementation, but implementation signatures of overloads are not externally visible.
const item: {
	type: typeof ItemType.TYPE_C;
	value: boolean;
}

That's the biggest downside of this function. It highlights where those errors are, but not in a way that's particularly useful if you don't already understand how it's used or what those errors mean. The best way I've found so far for managing this is to add an explanatory JSDoc comment to the assertAllUnionMembersHandled function itself, so if anyone is confused by the error and hovers over the function itself, they can see an explanation of how to resolve errors like this. For example, this is the relevant part of the JSDoc comment I use in the code for Orange Twist:

Investigating an error?

You probably need to add additional code to handle one or more values of an enum or another union type.

You can hover over the argument passed into this function to see the values that aren't handled currently. For example, if you see:

const foo: "A" | "B";

That means the union members 'A' and 'B' were not handled.

If the code is run without fixing any type errors, the default behaviour in my example above is for it to throw an error. That can show up in the browser console during runtime, but it's a lot easier to take advantage of static analysis to find and fix these errors before they ever reach the browser than it is to make sure you're testing every possible code branch so you won't miss a potential thrown error.