Description
Bug Report
A pattern that I love in TypeScript is the idea of exhaustive pattern matching on a tagged union:
type Expression =
| { tag: "constant", value: number }
| { tag: "add", left: Expression, right: Expression }
| { tag: "multiply", left: Expression, right: Expression };
export function unreachable(x: never): never {
return x;
}
function evaluate(expression: Expression): number {
switch (expression.tag) {
case "constant":
return expression.value;
case "add":
return evaluate(expression.left) + evaluate(expression.right);
case "multiply":
return evaluate(expression.left) * evaluate(expression.right);
default:
return unreachable(expression);
}
}
console.log(
evaluate({
tag: "add",
left: { tag: "constant", value: 2 },
right: { tag: "constant", value: 3 },
})
);
If I add a new case to the Expression
type (e.g., for subtraction), then the type checker kindly points out all the places I need to update to handle that case. I rely on this heavily and greatly appreciate it.
However, TypeScript reports a type error if there is only one case:
type Expression = { tag: "constant", value: number };
export function unreachable(x: never): never {
return x;
}
function evaluate(expression: Expression): number {
switch (expression.tag) {
case "constant":
return expression.value;
default:
return unreachable(expression); // Argument of type 'Expression' is not assignable to parameter of type 'never'.
}
}
console.log(evaluate({ tag: "constant", value: 2 }));
This is surprising to me, since I would expect this situation with only 1 case to behave exactly the same as with N cases in general.
One might be tempted to think that this is not useful. But I think there are situations where it makes sense to start with only one case if it is expected that there will be more added in the future (YAGNI might be a valid counterargument in many situations—but I don't believe the type checker should proclaim that).
On a related note, I would also like this to work on types with zero cases (i.e., never
), which is justified because such a type is not supposed to be able to be instantiated. I think that would look like this:
type Expression = never; // Zero cases!
export function unreachable(x: never): never {
return x;
}
function evaluate(expression: Expression): number {
switch (expression.tag) { // Property 'tag' does not exist on type 'never'.
default:
return unreachable(expression);
}
}
But perhaps I am asking for too much with that example!
Essentially, I want 0 variants and 1 variant to be unremarkable non-special cases of N variants and behave uniformly as such, as they do in languages like Rust and Haskell.
🔎 Search Terms
- "exhaustive pattern matching"
- "pattern matching"
- "algebraic data types"
I apologize if this has been raised before.
🕗 Version & Regression Information
This is the behavior in every version I tried (3.3.3-4.5.4), and I reviewed the FAQ for entries about "Type System Behavior".
⏯ Playground Link
- Working example with 3 cases: https://www.typescriptlang.org/play?ts=4.5.4#code/C4TwDgpgBAogHmAThAziglgewHZQLwBQUUAPlAN5TACGA5gFxQBEAxjijdsEwDRQBu1ADYBXCI2wiAtgCMIiKAF8ipClTqMm1ACbbeUIRABmwRvCSoMOPonS0AFqdgJkaLLmXEylGg2ZSRIWB0MCEQfUMTMxdLdxs7R2iLNxwlAG4CAggETERgKCMRbBZg1KLkahZ7ahlDAAo4CQh+eQBKJpaFchVkYBFEXDgM5QJC4tLcZuERamAIOuzkq2wk12X2qElZeQoVFAB3dGAqqAWYlOwAOl9W3eJiFmoUaFZ2Tm56FXuoXv7J8+Wl0EoggGW+j2ezB0ek+32IvwGUCmolm80Wa3cl0iwFuAGokcCZnMzktMbYHDiwfcIS8AkEQmEmLC4QjJoTUSSMTgscYcVAAFQE6Yc9GxbnkxytKnEbTGaiBUxfe6sqDlCCVaq1NEA9xSlTKEZsbAoTCGLGYWh1FTIonzbrfXyaaG8JXYxg+DTMI0cahcfSE8RQABMSh4SolTg9flexve-umgYAzKH9a0CFKgA
- Non-working example with 1 case: https://www.typescriptlang.org/play?ts=4.5.4#code/C4TwDgpgBAogHmAThAziglgewHZQLxQDeUwAhgOYBcUARAMY4pnbA0A0UAbqQDYCuEatj4BbAEYREUAL4BuAFDyICTImBQAZn2x1gWXNuSk6AC1JieEABRwhETpICUdh1MLyoUZMD6JccBWlFLR09HCh7Xj5SYGtlJFQMHGp4BLR9ZyhhcUkiDygUAHd0YFMoK3jkdJwAOjJyRzzPTzpSFGh6RmZWSnzmrwgfPwiEKqTsGu5+CAV+gBMIDVI+HmBe-s9vXwNsI1NzSwrRxIzZmXkg+QZsFExLGp5Mcgqp6NirYnrqTpvu9i4ooIoAAmGSORyyIA
💻 Code
(This is the problematic example from above.)
type Expression = { tag: "constant", value: number };
export function unreachable(x: never): never {
return x;
}
function evaluate(expression: Expression): number {
switch (expression.tag) {
case "constant":
return expression.value;
default:
return unreachable(expression);
}
}
console.log(evaluate({ tag: "constant", value: 2 }));
🙁 Actual behavior
TypeScript reports this type error:
generated/types.ts:17:26 - error TS2345: Argument of type 'Expression' is not assignable to parameter of type 'never'.
17 return unreachable(expression);
~~~~~~~~~~
Found 1 error.
🙂 Expected behavior
I expect it to type check successfully and print 2
to standard output when executed.
Thank you for reading this bug report!