Typescript Exhaustive Type Checking

Published: 15.Sep.2024

Food

Let's open with a problem. You have some Food and you need to perform some action based on the type of food.


interface Food {
    type: "pizza" | "burger" | "salad";
}

const dinner: Food = { type: "pizza" };

if (dinner.type === "pizza") {
    log("🍕");
} else if (dinner.type === "burger") {
    log("🍔");
} else if (dinner.type === "salad") {
    log("🥗");
}

While the code is delicious, it's not easily extensible. When a new Food type is added, the developer must remember to add a new conditional branch.

Taking a traditional object-oriented approach, one could be polymorphic and abstract over the types with concrete implementations.


interface Food {
    getIngredients(): void;
}

class Pizza implements Food {
    getIngredients() {
        console.log("🍕");
    }
}
class Burger implements Food {
    getIngredients() {
        console.log("🍔");
    }
}
class Salad implements Food {
    getIngredients() {
        console.log("🥗");
    }
}
const dinner = new Pizza();

dinner.getIngredients();

That is arguably more extensible at the expense of being a little overkill. In the first example it was kind of nice that we could just work with primitive values. Since we're talking about Typescript, these primitive objects work well within React, but also with more data-oriented architectures.

Even worse, it's not always possible to create a nice class or object for each entity. What if you're receiving a CustomerOrder from a different service or an async (Kafka) event, and need to create dinner based on what is in the order? Argh! We're back to the if-else problem.


type CustomerOrder = "i_want_pizza" | "i_want_burger" | "i_want_salad";

function getDinner(order: CustomerOrder): Food {
    if (order === "i_want_pizza") {
        return new Pizza();
    } else if (order === "i_want_burger") {
        return new Burger();
    } else if (order === "i_want_salad") {
        return new Salad();
    }
}

The Solution

Let's jump straight into what a better solution may look like.


interface Food {
    type: "pizza" | "burger" | "salad";
}

const MATCH_FOOD_TO_INGREDIENTS: Record<Food["type"], string> = {
    pizza: "🍕",
    burger: "🍔",
    salad: "🥗"
};

const dinner = { type: "pizza" };
const ingredients = MATCH_FOOD_TO_INGREDIENTS[dinner.type];

There (hopefully) shouldn't be any crazy new syntax here. And while this may look similar to the if-else example, and we're just using a regular object, the key insight is that Record type.

The Record<K, V> essentially turns our object into a mapping of K : V. This is immensely powerful because all possible permutations of K must be present in the map. If there is a missing key, the Typescript compiler will throw an error. When the keys are a discrete union type or enum, we can ensure that all possible values of that type are considered in the map.

So in our Food example, if we were to add a new Food type, we MUST add a new entry to the mapping.


type FoodType = "pizza" | "burger" | "salad" | "donut";

const MATCH_FOOD_TO_INGREDIENTS: Record<FoodType, string> = {
    pizza: "🍕",
    burger: "🍔",
    salad: "🥗"
    // Property 'donut' is missing in type 'Record<FoodType, string>'
};

Back to OOP

Let's revisit the CustomerOrder example. Maybe we do want to keep the ingredients within our Food because that seems to make sense. We can use a mapping, as a Factory method, to handle the creation of our Food based on the Order.


type CustomerOrder = "i_want_pizza" | "i_want_burger" | "i_want_salad";
type FoodConstructor = new () => Food;

function foodFactory(order: CustomerOrder): Food {
    const MATCH_ORDER_TO_FOOD: Record<CustomerOrder, FoodConstructor> = {
        i_want_pizza: Pizza,
        i_want_burger: Burger,
        i_want_salad: Salad
    };
    return new MATCH_ORDER_TO_FOOD[order]();
}

const dinner = foodFactory("i_want_pizza");
dinner.getIngredients();

A factory is a great way to integrate with some object-oriented approaches.

One of the advantages of the mapping is that it takes a more functional approach. By mapping values to values there can be multiple different maps throughout the codebase. Rather than force into a single object that knows how to handle all operations for a type, we can spread out concerns knowing that when a new type is added, the compiler will complain until all mapping instances are updated.


// In OOP we pass around large objects that know how to
// do everything to a specific food instance
const pizza = {
    getIngredients(): string[];
    cook(): void;
    // Does this go here?
    isThisFoodBetterThanThatFood(otherFood: Food): boolean;
};

// Instead we can have more primitive objects and define mappings in
// locations where they make the most sense in the codebase
const pizza = { type: "pizza" };

const MATCH_FOOD_TO_INGREDIENTS: Record<Food, string[]> = {...};
const MATCH_FOOD_TO_COOKING: Record<Food, () => {}> = {...};
const COMPARE_FOOD: Record<Food, (otherFood: Food) => boolean> = {...};

Conditionals

This is more or less a drop-in replacement for most switch statements. The mapping may be slightly more verbose than a switch because each case (K in the map) must be explicitly handled. I see that as a good thing.

Remember, the map's values can be functions, allowing for more complex logic and branching. This allows us to use this pattern to replace many common if-else chains.


type Food = "pizza" | "burger" | "salad";
type FoodCritic = string;
type FoodRater = (critic: FoodCritic) => number;

const MATCH_FOOD_RATING: Record<Food, FoodRater> = {
    pizza: getPizzaRating,
    burger: getBurgerRating,
    salad: getSaladRating,
};

const dinner = "pizza";
const getRating = MATCH_FOOD_RATING[dinner];
const dinnerRating = getRating("Gordon Ramsay");

Matrices

Can we take it up a notch? This pattern works well as a replacement for switch statements and basic if-else (or really any conditionals), but often times the branching is multi-variable. For instance, say we have some Food and want to make a full Meal based on the food. Again, we're sent back to if-else.


type MainDish = "steak" | "chicken" | "fish";
type SideDish = "rice" | "potatoes" | "veggies";
type Meal = {
    chefsMessage: string;
}

function makeFoodWithTwoIngredients(main: IngredientA, side: SideDish): Meal {
    let chefsMessage: string;
    if (main === "steak" && side === "rice") {
        chefsMessage = "Not a common dish. Steak and rice.";
    } else if (main === "steak" && side === "potatoes") {
        chefsMessage = "A classic. Steak and potatoes.";
    } else if (main === "steak" && side === "veggies") {
        chefsMessage = "Steak and veggies are delicious.";
    } else if (main === "chicken" && side === "rice") {
        chefsMessage = "Another classic. Chicken and rice.";
    }
    // You get the idea! This is a nightmare.

    return { chefsMessage };
}

What a mess! I know it's a rather silly example, but I have seen code that resembles this in most organizations I've been a part of. I think part of the issue is that our traditional OOP fails to nicely encapsulate this kind of logic. We could maybe move this into some Factory or maybe use a Strategy pattern? But again, it feels like overkill. Let's try the mapping approach.


type MainDish = "steak" | "chicken" | "fish";
type SideDish = "rice" | "potatoes" | "veggies";
type Meal = {
    chefsMessage: string;
}

type MealKey = `${MainDish}__${SideDish}`;

const MATCH_MEAL: Record<MealKey, Meal> = {};

As you might expect by now, there's that Record again. But the keys are a bit more complex. Using a template literal type, we can create a new discrete union type that is a combination of MainDish and SideDish. And with exhaustive type checking, we can ensure that all possible combinations are handled. The above code will not actually compile because the mapping is not exhaustive. The expanded map will look like below.


// Same types as above

const MATCH_MEAL: Record<MealKey, Meal> = {
    steak__rice: {
        chefsMessage: ""
    },
    steak__potatoes: {
        chefsMessage: ""
    },
    steak__veggies: {
        chefsMessage: ""
    },
    chicken__rice: {
        chefsMessage: ""
    },
    chicken__potatoes: {
        chefsMessage: ""
    },
    chicken__veggies: {
        chefsMessage: ""
    },
    fish__rice: {
        chefsMessage: ""
    },
    fish__potatoes: {
        chefsMessage: ""
    },
    fish__veggies: {
        chefsMessage: ""
    }
};

Now that is cool! Every possible permutation is covered. No more forgetting to add a new logic branch as new Dish types are added.

Wrapping Up

If you're working on a team (most of us are) and you want to try this out - show them. Don't just start replacing all your conditionals with mappings. Not only is this not idiomatic Typescript, it just feels different, and can invite pushback. Have a conversation and get buy-in from your team to find the places where this pattern makes sense.

In my personal projects I leverage some additional abstractions that make the mappings feel more like pattern matching. I wouldn't recommend this for most teams, for the same reason retrofitting RxJS into a codebase doesn't make sense, but it's a fun exercise.