Typescript: Type narrowing/guard together with nullish coalescing/optional chaining

Created on 23 Apr 2020  ·  6Comments  ·  Source: microsoft/TypeScript

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.

Design Limitation

Most helpful comment

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.

All 6 comments

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!

Was this page helpful?
0 / 5 - 0 ratings

Related issues

blendsdk picture blendsdk  ·  3Comments

fwanicka picture fwanicka  ·  3Comments

seanzer picture seanzer  ·  3Comments

kyasbal-1994 picture kyasbal-1994  ·  3Comments

manekinekko picture manekinekko  ·  3Comments