镜像站点 · 本页由第三方 GitHub 只读镜像提供,非 GitHub 官方站点,不接受任何登录或凭据输入。前往 github.com
Skip to content

narrowing in switch doesn't work with non-union types #16976

Description

class A {
    readonly kind = 'a';
}
type U = A; // <-- supposed to be a single case union
declare var u: U;
declare function never(never: never): never;
function fn() {
    switch (u.kind) {
        case 'a': return 1;
        default: return never(u); // <-- u expected to be never, actually is A
    }
}

Activity

  1. RyanCavanaugh commented on Jul 6, 2017

    @RyanCavanaugh
    Member

    Currently there's no logic in effect for "narrowing" of non-unions.

    It'd be good to collect more use cases where behavior like this could be useful

  2. zpdDG4gta8XKpMCd commented on Jul 6, 2017

    @zpdDG4gta8XKpMCd
    Author

    well, my use case is the consistency with other places where U is a true union (has more than one case)

  3. DylanRJohnston commented on Jul 27, 2017

    @DylanRJohnston

    Ryan Cavanaugh (@RyanCavanaugh)

    I've also run into this problem when trying to write error handling code that should be extensible with more error cases in the future.

    const assertNever = (x: never): never => { throw new Error(`Unexpected object ${x}`) }
    
    type Foo =
      | { kind: 'kind1', x: number }
      // | { kind: 'kind2', y: string }
    
    function foo(f: Foo) {
      switch (f.kind) {
        case 'kind1': return ""
        // case 'kind2': return ""
        default: return assertNever(f)
      }
    }

    Type error

    [ts] Argument of type 'Foo' is not assignable to parameter of type 'never'.

    Shouldn't a single value be treated as a union with a single element? So the narrowing here should result in f being narrowed to never?

  4. zpdDG4gta8XKpMCd commented on Aug 7, 2017

    @zpdDG4gta8XKpMCd
    Author

    interestingly narrowing works for single string literals, which is at very least looks inconsistent with this very issue

    type U = 'a'; // <-- supposed to be a single case union
    declare var u: U;
    declare function never(never: never): never;
    function fn() {
        switch (u) {
            case 'a': return 1;
            default: return never(u); // <-- WORKS!
        }
    }
  5. Freak613 commented on Mar 3, 2021

    @Freak613

    Ryan Cavanaugh (@RyanCavanaugh),

    Can be useful when designing API that don't have union initially, but later got it:

    // Initial
    {
      type DbError = {
        tag: "DbError"
      }
    
      const updateDB = (onError: (error: DbError) => void) => { }
    
      updateDB(error => {
        switch (error.tag) {
          case "DbError":
            const e1: DbError = error;
            console.log("DbError happened");
            break;
          default:
            // Problem, all possible errors are handled
            const e2: never = error; // Type 'DbError' is not assignable to type 'never'.
            console.log("Unhandled error");
            break;
        }
      })
    }
    
    // Sometimes later
    {
      type DbError = {
        tag: "DbError"
      }
    
      type NewDbError = {
        tag: "NewDbError"
      }
    
      const updateDB = (onError: (error: DbError | NewDbError) => void) => { }
    
      updateDB(error => {
        switch (error.tag) {
          case "DbError":
            const e1: DbError = error;
            console.log("DbError happened");
            break;
          default:
            // Correct, need to update code to handle new error type
            const e2: never = error; // Type 'NewDbError' is not assignable to type 'never'.
            console.log("Unhandled error");
            break;
        }
      })
    }
  6. jcalz commented on Aug 2, 2021

    @jcalz
    Contributor

    cross-referencing to this relevant comment on #8513

  7. msteen commented on Aug 25, 2021

    @msteen

    I noticed this too when dealing with array types and expecting them to be narrowed, yet unexpectedly they aren't:

    function test(input: (string | number)[]): string[] {
      // The type of .map(...) reports that it returns string[].
      input = input.map(x => x.toString())
      // Type error: Type '(string | number)[]' is not assignable to type 'string[]'.
      return input
    }

    In my current code base I allow many different types for most my arguments to keep them flexible in use, but normalize them to one type at the start. Right now, in the case of my array arguments that allow different element types, I have to workaround it via reassignment or casting on use.

    My Stack Overflow question about it: https://stackoverflow.com/questions/68928896/why-doesnt-typescript-narrow-array-types

  8. jcalz commented on Aug 25, 2021

    @jcalz
    Contributor

    Cross-linking to #10065

  9. DylanRJohnston commented on Aug 27, 2021

    @DylanRJohnston

    I noticed this too when dealing with array types and expecting them to be narrowed, yet unexpectedly they aren't:

    function test(input: (string | number)[]): string[] {
      // The type of .map(...) reports that it returns string[].
      input = input.map(x => x.toString())
      // Type error: Type '(string | number)[]' is not assignable to type 'string[]'.
      return input
    }

    In my current code base I allow many different types for most my arguments to keep them flexible in use, but normalize them to one type at the start. Right now, in the case of my array arguments that allow different element types, I have to workaround it via reassignment or casting on use.

    My Stack Overflow question about it: https://stackoverflow.com/questions/68928896/why-doesnt-typescript-narrow-array-types

    It's because you're reusing the variable input, just return that expression instead and you're all good.

  10. msteen commented on Aug 27, 2021

    @msteen

    Dylan R. Johnston (@DylanRJohnston) I am well aware that would work in my simplified example, but it should have been clear that is not what I meant. If you read the linked Stack Overflow question, you would know that's not what I am after, just one of the available workarounds of the problem. Just imagine I had other code between the reassignment and return. My problem is that it is not narrowing the type as expected, not that I don't have any workarounds available.

  11. terry-bit-io commented on Mar 20, 2022

    @terry-bit-io

    Just ran into this problem (playground):

    type SingleType = { type: "a" };
    function broken(value: SingleType): void {
      switch (value.type) {
        case "a":
          break;
        default:
          const exhaust: never = value; // Compile error: Type 'SingleType' is not assignable to type 'never'.
      }
    }
    
    type UnionType = { type: "a" } | { type: "b" };
    function working(value: UnionType): void {
      switch (value.type) {
        case "a":
          break;
        case "b":
          break;
        default:
          const exhaust: never = value; // Correctly narrowed to `never`. This is what I expect!
      }
    }
  12. schl3ck commented on Mar 20, 2022

    @schl3ck

    Interestingly it works with the typeof opeartor:

    Playground

    type A = number;
    type B = string;
    type U = A;
    
    function never(arg: never): never { throw new TypeError("type union not exhausted"); }
    
    function works(arg: U) {
      if (typeof arg === "number") return true;
      never(arg);
    }
    
    function worksToo(arg: U) {
      switch(typeof arg) {
        case "number": return true;
        default: return never(arg);
      }
    }

    So it seems that exhaustion works with simple types (number, boolean, string, string literals, ...), but not with properties on objects. This is somewhat inconsistent as ZpdDG4gta (@zpdDG4gta8XKpMCd) stated here (comment)

  13. apottere commented on Mar 29, 2023

    @apottere

    For those looking for a way to work around this issue, I played around with trying to force typescript to interpret my type as a union with one element. Simply adding | never doesn't seem to work, example:

    declare const unreachable: (e: never) => never;
    
    type Container<T> = { type: T };
    declare const value: Container<'ONE'> | never;
    
    switch(value.type) {
        case 'ONE':
            break;
        default:
            unreachable(value); // <-- TS2345: Argument of type 'Container"ONE">' is not assignable to parameter of type 'never'.
    }

    However, for whatever reason, if the type you're attempting to narrow is an object type you can union it with an object with never fields. This does correctly narrow (at least in TS v4.9.5)

    declare const unreachable: (e: never) => never;
    
    type Container<T> = { type: T };
    declare const value: Container<'ONE'> | Container<never>;
    
    switch(value.type) {
        case 'ONE':
            break;
        default:
            unreachable(value); // <-- WORKS!
    }

    This still works even when the union is expanded to more than one "valid" element:

    declare const unreachable: (e: never) => never;
    
    type Container<T> = { type: T };
    declare const value: Container<'ONE'> | Container<'TWO'> | Container<never>;
    
    switch(value.type) {
        case 'ONE':
            break;
        case 'TWO':
            break;
        default:
            unreachable(value); // <-- WORKS!
    }

    I ran into this issue when designing a function that takes variadic arguments and returns a mapped union of the types given. When only one argument was given, I would need a different check in the default switch statement. With this "trick" (read: egregious hack) I can add/remove cases without changing the default case.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    In DiscussionNot yet reached consensusSuggestionAn idea for TypeScript

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions