typescriptpattern-matchingdestructuringtype-safetyalgebraic-data-types

How to check for exhaustiveness of product types in TypeScript?


It's very easy to check for exhaustiveness of sum types in TypeScript.

type Sum =
    | { tag: 'num'; value: number }
    | { tag: 'str'; value: string };

const len = (sum: Sum): number => {
    switch (sum.tag) {
        case 'num': return sum.value;
        case 'str': return sum.value.length;
        default: throw new Error(`Unhandled sum ${sum satisfies never}`);
    }
};

Now, if I add a new variant to the Sum type then sum will no longer be assignable to never. Hence, we'll get a compile time error for non-exhaustiveness.

How can I do the same for product types in TypeScript? Consider the following example.

type Product = {
    num: number;
    str: string;
};

const repeat = (product: Product): string => {
    const { num, str } = product;
    return str.repeat(num);
};

Now, if I add a new property to the Product type then I want the TypeScript compiler to report an error for non-exhaustiveness, because the new property hasn't been de-structured and used. How do I do that?

Plus points if the code throws a runtime error for non-exhaustiveness.


Solution

  • Let's start by throwing a runtime error for non-exhaustiveness. We can do this by destructuring the rest properties, and throwing an error if it has one or more enumerable keys.

    const repeat = (product: Product): string => {
        const { num, str, ...props } = product;
        if (Object.keys(props).length > 0) {
            throw new Error(`Unhandled props ${props}`);
        }
        return str.repeat(num);
    };
    

    Next, in order for TypeScript to check for exhaustiveness at compile time we can do the following.

    type IsEmptyObject<A> = {} extends A ? unknown : never;
    
    const repeat = (product: Product): string => {
        const { num, str, ...props } = product;
        if (Object.keys(props).length > 0) {
            throw new Error(
              `Unhandled props ${props satisfies IsEmptyObject<typeof props>}`
            );
        }
        return str.repeat(num);
    };
    

    Here's how it works.

    1. The empty object type {} is only assignable to A iff A is an empty object.
    2. Hence, when props is an empty object then the type of IsEmptyObject<typeof props> is unknown and everything is all right.
    3. However, when props is not an empty object then the type of IsEmptyObject<typeof props> is never and we get a compile time error.

    Thus, the above code will check for exhaustiveness of the product type at the time of de-structuring. If a new property is added to the Product type then props will no longer be assignable to IsEmptyObject<typeof props> and we'll get a compile time error for non-exhaustiveness.

    Additionally, you can turn on the @typescript-eslint/no-unused-vars rule to make sure that all the de-structured properties are used. Make sure to set the ignoreRestSiblings option to false.