Description
I frequently see code like this:
type MyUnion = 'a' | 'b';
declare const myUnion: MyUnion;
const f1 = () => (myUnion === 'a' ? 'a' : 'b');
f1('a') // 'a'
f1('b') // 'b'
The logic in f1
makes an assumption: if myUnion
is not a
, it must be b
.
Later on, someone might update the MyUnion
type and this assumption will breakdown:
-type MyUnion = 'a' | 'b';
+type MyUnion = 'a' | 'b' | 'c';
The runtime behaviour is clearly incorrect, yet TypeScript will not error to remind us that we need to update the logic in f1
:
f1('a') // 'a'
f1('b') // 'b'
f1('c') // 'b' ❌
This problem is not specific to the ternary operator but also if
and switch
statements:
const f2 = () => {
if (myUnion === 'a') {
return 'a';
} else {
return 'b';
}
};
const f3 = () => {
switch (myUnion) {
case 'a':
return 'a';
default:
return 'b';
}
};
As we can see, it is not safe to make assumptions about the value that reaches the else
/default
case because it can change.
Instead we need to explicitly specify all cases:
import assertNever from 'assert-never';
type MyUnion = 'a' | 'b';
declare const myUnion: MyUnion;
const f2 = () => {
if (myUnion === 'a') {
return 'a';
} else if (myUnion === 'b') {
return 'b';
} else {
assertNever(myUnion);
}
};
const f3 = () => {
switch (myUnion) {
case 'a':
return 'a';
case 'b':
return 'b';
}
};
const f3b = () => {
switch (myUnion) {
case 'a':
return 'a';
case 'b':
return 'b';
default:
assertNever(myUnion);
}
};
This way, when the type is eventually widened, TypeScript will generate a type error so we're reminded that we need to update our code:
import assertNever from 'assert-never';
type MyUnion = 'a' | 'b' | 'c';
declare const myUnion: MyUnion;
const f2 = () => {
if (myUnion === 'a') {
return 'a';
} else if (myUnion === 'b') {
return 'b';
} else {
// Argument of type 'string' is not assignable to parameter of type 'never'.
assertNever(myUnion);
}
};
// @noImplicitReturns: true
// Not all code paths return a value.
const f3 = () => {
switch (myUnion) {
case 'a':
return 'a';
case 'b':
return 'b';
}
};
const f3b = () => {
switch (myUnion) {
case 'a':
return 'a';
case 'b':
return 'b';
default:
// Argument of type 'string' is not assignable to parameter of type 'never'.
assertNever(myUnion);
}
};
I would like to propose a rule that enforces this. The rule would report an error inside a ternary or if
/switch
statement if we're switching over a union type (except boolean
) and we have a fallback case (else
/default
). The fix would be to explicitly specify all cases.
I'm really not sure what we would call it.
WDYT?