TypeScript Version: 3.9.0-dev.20200422
Search Terms: type narrowing guard nullish coalescing switch exhaustive optional chaining
Code
enum MenuType { EatIn, TakeAway }
declare function unreachable (value: never): never
declare let menuTypeFilter: MenuType | null | undefined
// Works :)
switch (menuTypeFilter) {
case MenuType.EatIn: console.log(1); break
case MenuType.TakeAway: console.log(2); break
case null: console.log(3); break
case undefined: console.log(3); break
default: unreachable(menuTypeFilter)
}
// Doesn't work :(
switch (menuTypeFilter ?? null) {
case MenuType.EatIn: console.log(1); break
case MenuType.TakeAway: console.log(2); break
case null: console.log(3); break
default: unreachable(menuTypeFilter)
}
declare let filter: { menuType: MenuType | null | undefined } | null | undefined
// Works :)
if (filter?.menuType != null) {
switch (filter.menuType) {
case MenuType.EatIn: console.log(1); break
case MenuType.TakeAway: console.log(2); break
default: unreachable(filter.menuType)
}
}
// Doesn't work :(
switch (filter?.menuType) {
case MenuType.EatIn: console.log(1); break
case MenuType.TakeAway: console.log(2); break
case null: console.log(3); break
case undefined: console.log(3); break
default: unreachable(filter.menuType)
}
Expected behavior:
I expected TypeScript to figure out that I have covered all possible values of menuTypeFilter, and thus let me have an unreachable default case.
Actual behavior:
Argument of type 'MenuType | null | undefined' is not assignable to parameter of type 'never'.
Playground Link: Playground Link
Related Issues: (I thought this would have come up but didn't manage to find another issue)
Motivation: I would like to both 1) get an error if the enum ever expands in the future, and 2) handle null and undefined in the same way.
A simple workaround is:
switch (menuTypeFilter) {
case MenuType.EatIn: console.log(1); break
case MenuType.TakeAway: console.log(2); break
case null:
case undefined: console.log(3); break
default: unreachable(menuTypeFilter)
}
Hehe, I can't believe I didn't see that, thanks! 😄
Would still be great if type narrowing could work with nullish coalescing though ☺️
Narrowing works with nullish coalescing.
What isn't possible is to narrow one value and expect it to have an effect on another variable.
Your code is equivalent to:
declare const x: 'a' | null | undefined;
const y = x ?? null; // this is correctly inferred as 'a' | null
switch (y) {
case 'a': break;
case null: break;
default: unreachable(x); // y was indeed narrowed down to never, but not x
}
We can see that, in order to reach the default branch, y must've been something other than 'a' | null | undefined, which means x was too. But the compiler doesn't keep track of all these possible connections between different values.
It also doesn't work with optional chaining:
enum MenuType { EatIn, TakeAway }
declare function unreachable (value: never): never
declare let filter: { menuType: MenuType | null | undefined } | null | undefined
// Works :)
if (filter?.menuType != null) {
switch (filter.menuType) {
case MenuType.EatIn: console.log(1); break
case MenuType.TakeAway: console.log(2); break
default: unreachable(filter.menuType)
}
}
// Doesn't work :(
switch (filter?.menuType) {
case MenuType.EatIn: console.log(1); break
case MenuType.TakeAway: console.log(2); break
case null: console.log(3); break
case undefined: console.log(3); break
default: unreachable(filter.menuType)
}
@ilogico I believe that this demonstrates a case where I have not essentially created a new variable? Especially since it works when doing if instead of switch
You're not creating a new variable, but you are creating a new value.
Your if statement works in narrowing filter to not null, but your inner switch works because you're using filter.menuType everywhere, but in the second case, you're using filter?.menuType in one place and filter.menuType in the other. Even though we can see the values are related, CFA doesn't track that connection. It would be great if it did, but it doesn't.
In any case, this has nothing to do with nullish coalescing or optional chaining. If replace one with menuTypeFilter || null and the other with filter && filter.menuType, you will get the same result.
Your if statement works in narrowing filter to not null, but your inner switch works because you're using filter.menuType everywhere, but in the second case, you're using filter?.menuType in one place and filter.menuType in the other.
Ahhhhh, I see now, thanks for the explanation!
Most helpful comment
You're not creating a new variable, but you are creating a new value.
Your
ifstatement works in narrowingfilterto not null, but your innerswitchworks because you're usingfilter.menuTypeeverywhere, but in the second case, you're usingfilter?.menuTypein one place andfilter.menuTypein the other. Even though we can see the values are related, CFA doesn't track that connection. It would be great if it did, but it doesn't.In any case, this has nothing to do with nullish coalescing or optional chaining. If replace one with
menuTypeFilter || nulland the other withfilter && filter.menuType, you will get the same result.