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.
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.
{}
is only assignable to A
iff A
is an empty object.props
is an empty object then the type of IsEmptyObject<typeof props>
is unknown
and everything is all right.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
.