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

Exhaustiveness checking against an enum only works when the enum has >1 member. #23572

Description

@confusingstraw

TypeScript Version: typescript@2.9.0-dev.20180420

Search Terms: discriminated, exhaustiveness, type guard, narrowing

Code

// Legal action types for ValidAction
enum ActionTypes {
  INCREMENT = 'INCREMENT',
//   DECREMENT = 'DECREMENT',
}

interface IIncrement {
  payload: {};
  type: ActionTypes.INCREMENT;
}

// interface IDecrement {
//   payload: {};
//   type: ActionTypes.DECREMENT;
// }

// Any string not present in T
type AnyStringExcept<T extends string> = { [P in T]: never; };

// ValidAction is an interface with a type in ActionTypes
type ValidAction = IIncrement;
// type ValidAction = IIncrement | IDecrement;
// UnhandledAction in an interface with a type that is not within ActionTypes
type UnhandledAction = { type: AnyStringExcept<ActionTypes>; };

// The set of all actions
type PossibleAction = ValidAction | UnhandledAction;

// Discriminates to ValidAction
function isUnhandled(x: PossibleAction): x is UnhandledAction {
    return !(x.type in ActionTypes);
}

type CounterState = number;
const initialState: CounterState = 0;

function receiveAction(state = initialState, action: PossibleAction) {
    // typeof action === PossibleAction
    if (isUnhandled(action)) {
        // typeof action === UnhandledAction
        return state;
    }

    // typeof action === ValidAction
    switch (action.type) {
        case ActionTypes.INCREMENT:
            // typeof action === IIncrement
            return state + 1;
        // case ActionTypes.DECREMENT:
        //     return state - 1;
    }

    // typeof action === IIncrement
    // Since INCREMENT is handled above, this should be impossible,
    // However the compiler will say that assertNever cannot receive an argument of type IIncrement
    return assertNever(action);
}

function assertNever(x: UnhandledAction): never {
    throw new Error(`Unhandled action type: ${x.type}`);
}

Expected behavior: No error would be thrown, as the switch statement is exhaustive. If the ActionTypes.DECREMENT parts are uncommented (resulting in two possible values for ActionTypes) there is no error. An error only occurs when ActionTypes takes on a single value. The error occurs even if the never assertion happens in the default statement, which is obviously unreachable from IIncrement.

Actual behavior: An error is thrown despite the only possible value being explicitly handled. If ActionTypes.DECREMENT is uncommented the expected behavior is present.

Playground Link: (fixed the links)
Error
Working

Related Issues:
#19904
#14210
#18056

Activity

  1. nikeinikei commented on Sep 6, 2019

    @nikeinikei

    This is also the case, when you try to prepare your code for future union types, but currently only use one type.

    If you uncomment everything regarding the second interface (first 3 comment blocks) everything will work as intended.

    interface Interface1 {
      type: "interface1";
    }
    
    // interface Interface2 {
    //   type: "interface2";
    // }
    
    type UnionType = Interface1
      // | Interface2;
    
    function calculateFromInterface(i: UnionType): number {
      switch (i.type) {
        case "interface1":
          return 0;
        // case "interface2":
        //   return 1;
      }
    
      //Error: 'Argument of type 'Interface1' is not assignable to parameter of type 'never'.'
      //typescript version 3.5.1
      //however the code can never get to here
      assertNever(i);
    }
    
    function assertNever(o: never) {
      throw "this should never happen";
    }
    
  2. confusingstraw commented on Sep 6, 2019

    @confusingstraw
    Author

    Yeah, really it just comes down to "Exhaustiveness checks only work when discriminating against a union with size > 1"

  3. sbriais commented on Nov 13, 2019

    @sbriais

    I just ran into this bug too. Here is a simple example:

    function assertNever(x: never): never {
      throw new Error("Unexpected object: " + x);
    }
    
    interface Square {
      kind: "square";
      size: number;
    }
    
    type Shape = Square
    
    function area(s: Shape) {
      switch (s.kind) {
      case "square": return s.size * s.size;
      default:
        assertNever(s);
      }
    }
    
  4. antoineprdhmm commented on Feb 11, 2020

    @antoineprdhmm

    We also ran into this bug today. Here is another code example, if it can help

    const rejectUnexpectedValueOfPropertyInObject = (
      objectName: string,
      propertyName: string,
      object: never
    ): never => {
      throw new Error(
        `Unexpected value for ${propertyName} in ${objectName}: ${
          object && typeof object === "object"
            ? (object as any)[propertyName]
            : "<not an object>"
        }`
      );
    };
    
    type Result =
      | {
          outcome: "success";
        }
      // | {
      //    outcome: "error";
      //    reason: "reason_2";
      //  }
      | {
          outcome: "error";
          reason: "reason_1";
        };
    
    const f = (a: number, b: number): Result => {
      if (a < 0) {
        return {
          outcome: "error",
          reason: "reason_1"
        };
      }
      return { outcome: "success" };
    };
    
    const run = (a: number, b: number) => {
      const result = f(1, 2);
    
      if (result.outcome === "error") {
        switch (result.reason) {
          case "reason_1":
            console.log("error reason 1");
            break;
          // case "reason_2":
          //  console.log("error reason 2");
          //  break;
          default:
            rejectUnexpectedValueOfPropertyInObject("result", "reason", result);
        }
      }
    };

    The error is

    const result: {
                outcome: "error";
                reason: "reason_1";
              }
              Argument of type '{ outcome: "error"; reason: "reason_1"; }' is not assignable to parameter of type 'never'.
    

    By removing the comments for reason_2 in the type definition and in the switch, the error disappear.

  5. 1 remaining item

  6. RyanCavanaugh commented on Dec 15, 2025

    @RyanCavanaugh
    Member

    Naive version of this is very bad (see PR #62114)

  7. RyanCavanaugh commented on Aug 31, 2026

    @RyanCavanaugh
    Member

    Duplicate of #16976. Both reports concern a non-union object with a literal discriminant remaining assignable after every possible switch case has been handled, rather than narrowing to never.

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

    BugA bug in TypeScriptDomain: check: Control FlowThe issue relates to control flow analysis

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions