Skip to content

Type narrowing/guard together with nullish coalescing/optional chaining #38136

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
LinusU opened this issue Apr 23, 2020 · 6 comments
Closed

Type narrowing/guard together with nullish coalescing/optional chaining #38136

LinusU opened this issue Apr 23, 2020 · 6 comments
Labels
Design Limitation Constraints of the existing architecture prevent this from being fixed

Comments

@LinusU
Copy link
Contributor

LinusU commented Apr 23, 2020

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.

@MartinJohns
Copy link
Contributor

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)
}

@LinusU
Copy link
Contributor Author

LinusU commented Apr 23, 2020

Hehe, I can't believe I didn't see that, thanks! 😄

Would still be great if type narrowing could work with nullish coalescing though ☺️

@ilogico
Copy link

ilogico commented Apr 23, 2020

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.

@LinusU
Copy link
Contributor Author

LinusU commented Apr 24, 2020

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

@LinusU LinusU changed the title Type narrowing/guard together with nullish coalescing Type narrowing/guard together with nullish coalescing/optional chaining Apr 24, 2020
@ilogico
Copy link

ilogico commented Apr 24, 2020

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.

@LinusU
Copy link
Contributor Author

LinusU commented Apr 25, 2020

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!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Design Limitation Constraints of the existing architecture prevent this from being fixed
Projects
None yet
Development

No branches or pull requests

4 participants