Using TypeScript to check for missing cases
Tuesday, 11 August 2020
TL;DR: use your type system to keep you from forgetting to handle all cases in switch statements and object keys.
Often in programming, you have to deal with a list of distinct options. In some languages, this would be expressed by an enum, but in TypeScript it’s more common to express them as specific strings:
type CarType = “mazda-miata” | “honda-s2000” | “toyota-mr2” | “pontiac-solstice”;
(Let’s say we’re building a racing game featuring old roadsters in this contrived example…)
Rather than enumerate all of the types in one CarType declaration, we might have varying properties for each car, expressed as TypeScript discriminated unions:
interface Miata {
type: "mazda-miata";
engine: "1.6L" | "1.8L";
turbocharger: boolean;
}
interface S2000 {
type: "honda-s2000";
variant: "AP1" | "AP2";
}
interface MR2 {
type: "toyota-mr2";
engine: "2.0L" | "2.2L";
turbocharger: boolean;
}
interface Solstice {
type: "pontiac-solstice";
}
type Car = Miata | S2000 | MR2 | Solstice;
// not necessary to declare, but this is equivalent to the first example
// type CarType = Car['type']
This is great, as TS can understand when we have a type guard and use the appropriate sub-type:
const car = getCar();
if (car.type == "honda-s2000") {
// TS knows that car.variant exists because we checked the type
console.log(car.variant);
}
Don’t forget simple object mappings
But there are cases where we can still get into trouble: sometimes we want to make sure we handle all possible types.
For example, we might have a constant that maps cars to their Wikipedia pages:
const CAR_PAGES = {
"mazda-miata": "https://en.wikipedia.org/wiki/Mazda_MX-5",
"honda-s2000": "https://en.wikipedia.org/wiki/Honda_S2000",
"toyota-mr2": "https://en.wikipedia.org/wiki/Toyota_MR2"
};
function sendUserToWikipedia(car: Car) {
window.location.href = CAR_PAGES[car.type];
}
This works until someone tries going to the page for the Pontiac Solstice, in which case the user gets sent to “undefined”! This kind of problem tends to happen when you add additional sub-types: if we added a “saturn-sky” car, then we’d have to find every place in the code that needs to handle the new car.
You can tell TypeScript that every possible key needs to be specified with the following type definition:
const CAR_PAGES: { [key in Car['type']]: string } = {
"mazda-miata": "https://en.wikipedia.org/wiki/Mazda_MX-5",
"honda-s2000": "https://en.wikipedia.org/wiki/Honda_S2000",
"toyota-mr2": "https://en.wikipedia.org/wiki/Toyota_MR2"
};
// TypeScript:
// Property 'pontiac-solstice' is missing in type '...' but required in type '...'.
(Error message elided for brevity.)
Don’t forget switch cases
Another common place to forget possible types is in switch statements:
const car = getCar();
switch (car.type) {
case "mazda-miata":
return new MiataConfigurator();
case "honda-s2000":
return new S2000Configurator();
case "toyota-mr2":
return new MR2Configurator();
default:
throw new Error(`Car type ${car.type} not handled!`);
}
Throwing in the default case is fine, but it’s a runtime check that we could do statically as well. We can have TypeScript warn us about falling into the default case by using the never
type. In the above example, TypeScript knows that car.type
must be "pontiac-solstice"
in the default case. If we had handled all possibilities, then car.type
would be never
.
The trick is to create a function that takes something of type never
:
function assertIsNever(x: never, errorMsg: string) {
throw new Error(errorMsg);
}
Then we can rewrite the default case to have TypeScript warn us:
switch (car.type) {
...
default:
assertIsNever(car.type, `Car type ${car.type} not handled!`);
// TypeScript:
// Argument of type '"pontiac-solstice"' is not assignable to parameter of type 'never'.
}
By applying these techniques to all places where you handle all sub-types of a type, you make adding new sub-types easy. If we added a “saturn-sky” car to our Car type, then we’d just need to go through and fix all of the new type errors that pop up around the codebase. Without static typing, we’d have to grep the source code for all mentions of the existing types and analyze each one to figure out if it needs to change. This is one of many things you can do to squeeze more value out of your static type system – if you’re going to run the overhead of using TypeScript, you should get as much ROI out of it as you can, right?
It’s also worth pointing out that many other statically type languages have this functionality in various ways. For example, OCaml and other ML-ish languages have pattern matching that isn’t stringly typed:
(* From the OCaml documentation: *)
let rec to_string e =
match e with
| Plus (left, right) ->
"(" ^ to_string left ^ " + " ^ to_string right ^ ")"
| Minus (left, right) ->
"(" ^ to_string left ^ " - " ^ to_string right ^ ")"
| Times (left, right) ->
"(" ^ to_string left ^ " * " ^ to_string right ^ ")"
| Divide (left, right) ->
"(" ^ to_string left ^ " / " ^ to_string right ^ ")"
| Value v -> v;;