Skip to content

Conversation

Copy link
Contributor

Copilot AI commented Dec 15, 2025

Exhaustiveness checking failed when an enum had exactly one member. The type would not narrow to never in the default case even when all cases were handled:

enum Single {
  VALUE = 'VALUE'
}

function test(x: Single) {
  switch (x) {
    case Single.VALUE:
      return 1;
  }
  const n: never = x; // Error before, works now
}

Changes:

  • Modified narrowTypeBySwitchOnDiscriminant in checker.ts to narrow non-union types and single-member unions to never when exhaustively handled in switch default case
  • Added check in narrowTypeByDiscriminant to return never when narrowed property type is never for non-union types
  • Added test coverage for single-member enums, numeric enums, and literal types

Implementation:

After computing the default type, check if:

  1. The case type is never (indicating we're in a default-like position), AND
  2. The input type is either:
    • A non-union unit-like type that's been handled in the switch cases, OR
    • A single-member union where the member is unit-like and handled

Additionally, in narrowTypeByDiscriminant, if the narrowed property type is never and the original type is not a union, return never directly to handle discriminant property narrowing.

Current Scope:

The fix successfully handles:

  • ✅ Direct switches on single-member enums
  • ✅ Direct switches on single literal types
  • ✅ Single-member numeric enums

Known Limitation:

Discriminant property narrowing for non-union object types (e.g., type obj = { kind: "a", payload: string }) requires additional investigation and is being discussed with maintainers.

Fixes #23155

Original prompt

This section details on the original issue you should resolve

<issue_title>Exhaustiveness checking against an enum only works when the enum has >1 member.</issue_title>
<issue_description>
TypeScript Version: [email protected]

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](https://www.typescriptlang.org/play/index.html#src=%2F%2F%20Legal%20action%20types%20for%20ValidAction%0D%0Aenum%20ActionTypes%20%7B%0D%0A%20%20INCREMENT%20%3D%20'INCREMENT'%2C%0D%0A%2F%2F%20%20%20DECREMENT%20%3D%20'DECREMENT'%2C%0D%0A%7D%0D%0A%0D%0Ainterface%20IIncrement%20%7B%0D%0A%20%20payload%3A%20%7B%7D%3B%0D%0A%20%20type%3A%20ActionTypes.INCREMENT%3B%0D%0A%7D%0D%0A%0D%0A%2F%2F%20interface%20IDecrement%20%7B%0D%0A%2F%2F%20%20%20payload%3A%20%7B%7D%3B%0D%0A%2F%2F%20%20%20type%3A%20ActionTypes.DECREMENT%3B%0D%0A%2F%2F%20%7D%0D%0A%0D%0A%2F%2F%20Any%20string%20not%20present%20in%20T%0D%0Atype%20AnyStringExcept%3CT%20extends%20string%3E%20%3D%20%7B%20%5BP%20in%20T%5D%3A%20never%3B%20%7D%3B%0D%0A%0D%0A%2F%2F%20ValidAction%20is%20an%20interface%20with%20a%20type%20in%20ActionTypes%0D%0Atype%20ValidAction%20%3D%20IIncrement%3B%0D%0A%2F%2F%20type%20ValidAction%20%3D%20IIncrement%20%7C%20IDecrement%3B%0D%0A%0D%0A%2F%2F%20UnhandledAction%20in%20an%20interface%20with%20a%20type%20that%20is%20not%20within%20ActionTypes%0D%0Atype%20UnhandledAction%20%3D%20%7B%20type%3A%20AnyStringExcept%3CActionTypes%3E%3B%20%7D%3B%0D%0A%0D%0A%2F%2F%20The%20set%20of%20all%20actions%0D%0Atype%20PossibleAction%20%3D%20ValidAction%20%7C%20UnhandledAction%3B%0D%0A%0D%0A%2F%2F%20Discriminates%20to%20ValidAction%0D%0Afunction%20isUnhandled(x%3A%20PossibleAction)%3A%20x%20is%20UnhandledAction%20%7B%0D%0A%20%20%20%20return%20!(x.type%20in%20ActionTypes)%3B%0D%0A%7D%0D%0A%0D%0Atype%20CounterState%20%3D%20number%3B%0D%0Aconst%20initialState%3A%20CounterState%20%3D%200%3B%0D%0A%0D%0Afunction%20receiveAction(state%20%3D%20initialState%2C%20action%3A%20PossibleAction)%20%7B%0D%0A%20%20%20%20%2F%2F%20typeof%20action%20%3D%3D%3D%20PossibleAction%0D%0A%20%20%20%20if%20(isUnhandled(action))%20%7B%0D%0A%20%20%20%20%20%20%20%20%2F%2F%20typeof%20action%20%3D%3D%3D%20UnhandledAction%0D%0A%20%20%20%20%20%20%20%20return%20state%3B%0D%0A%20%20%20%20%7D%0D%0A%0D%0A%20%20%20%20%2F%2F%20typeof%20action%20%3D%3D%3D%20ValidActio...


💬 We'd love your input! Share your thoughts on Copilot coding agent in our 2 minute survey.

@typescript-bot typescript-bot added the For Milestone Bug PRs that fix a bug with a specific milestone label Dec 15, 2025
Copilot AI changed the title [WIP] Fix exhaustiveness checking against a single member enum Fix exhaustiveness checking for single-member enums in switch statements Dec 15, 2025
Copilot AI requested a review from RyanCavanaugh December 15, 2025 20:06
@rubiesonthesky
Copy link

The description links to wrong issue with Fixed comment. The correct issue is below it, but it was surprising to end up in an issue that was already closed and didn’t seem related.

@RyanCavanaugh
Copy link
Member

@typescript-bot test it

@typescript-bot
Copy link
Collaborator

typescript-bot commented Dec 15, 2025

Starting jobs; this comment will be updated as builds start and complete.

Command Status Results
test top400 ✅ Started ✅ Results
user test this ✅ Started ✅ Results
run dt ✅ Started ✅ Results
perf test this faster ✅ Started 👀 Results

@typescript-bot
Copy link
Collaborator

Hey @RyanCavanaugh, the results of running the DT tests are ready.

Everything looks the same!

You can check the log here.

@typescript-bot
Copy link
Collaborator

@RyanCavanaugh Here are the results of running the user tests with tsc comparing main and refs/pull/62900/merge:

There were infrastructure failures potentially unrelated to your change:

  • 1 instance of "Git clone failed"

Otherwise...

Everything looks good!

@typescript-bot
Copy link
Collaborator

@RyanCavanaugh
The results of the perf run you requested are in!

Here they are:

tsc

Comparison Report - baseline..pr
Metric baseline pr Delta Best Worst p-value
Compiler-Unions - node (v18.15.0, x64)
Errors 1 1 ~ ~ ~ p=1.000 n=6
Symbols 62,370 62,370 ~ ~ ~ p=1.000 n=6
Types 50,387 50,387 ~ ~ ~ p=1.000 n=6
Memory used 194,934k (± 0.97%) 194,266k (± 0.89%) ~ 192,461k 196,101k p=0.336 n=6
Parse Time 1.30s (± 0.58%) 1.31s (± 0.62%) ~ 1.29s 1.31s p=0.206 n=6
Bind Time 0.76s 0.76s ~ ~ ~ p=1.000 n=6
Check Time 9.85s (± 0.27%) 9.84s (± 0.11%) ~ 9.83s 9.85s p=0.663 n=6
Emit Time 2.72s (± 0.40%) 2.73s (± 0.59%) ~ 2.71s 2.75s p=0.187 n=6
Total Time 14.63s (± 0.16%) 14.63s (± 0.08%) ~ 14.62s 14.65s p=0.679 n=6
angular-1 - node (v18.15.0, x64)
Errors 2 2 ~ ~ ~ p=1.000 n=6
Symbols 955,823 955,823 ~ ~ ~ p=1.000 n=6
Types 415,853 415,853 ~ ~ ~ p=1.000 n=6
Memory used 1,253,926k (± 0.01%) 1,253,927k (± 0.00%) ~ 1,253,864k 1,254,025k p=0.689 n=6
Parse Time 6.50s (± 0.47%) 6.50s (± 0.57%) ~ 6.45s 6.56s p=0.627 n=6
Bind Time 1.95s (± 0.21%) 1.96s (± 0.38%) +0.01s (+ 0.68%) 1.95s 1.97s p=0.010 n=6
Check Time 32.43s (± 0.33%) 32.33s (± 0.07%) ~ 32.29s 32.36s p=0.077 n=6
Emit Time 14.99s (± 0.29%) 14.95s (± 0.52%) ~ 14.82s 15.03s p=0.469 n=6
Total Time 55.88s (± 0.27%) 55.74s (± 0.17%) ~ 55.59s 55.85s p=0.093 n=6
mui-docs - node (v18.15.0, x64)
Errors 0 0 ~ ~ ~ p=1.000 n=6
Symbols 2,723,786 2,723,786 ~ ~ ~ p=1.000 n=6
Types 937,507 937,507 ~ ~ ~ p=1.000 n=6
Memory used 3,051,363k (± 0.00%) 3,051,335k (± 0.00%) ~ 3,051,270k 3,051,387k p=1.000 n=6
Parse Time 8.59s (± 0.23%) 8.59s (± 0.25%) ~ 8.57s 8.63s p=0.683 n=6
Bind Time 2.31s (± 0.35%) 2.31s (± 0.35%) ~ 2.30s 2.32s p=0.204 n=6
Check Time 93.13s (± 0.59%) 92.93s (± 0.52%) ~ 92.01s 93.35s p=0.149 n=6
Emit Time 0.31s (± 2.44%) 0.31s (± 2.60%) ~ 0.30s 0.32s p=0.306 n=6
Total Time 104.34s (± 0.51%) 104.14s (± 0.46%) ~ 103.23s 104.54s p=0.149 n=6
self-build-src - node (v18.15.0, x64)
Errors 0 0 ~ ~ ~ p=1.000 n=6
Symbols 1,252,023 1,252,029 +6 (+ 0.00%) ~ ~ p=0.001 n=6
Types 259,854 259,855 +1 (+ 0.00%) ~ ~ p=0.001 n=6
Memory used 2,387,271k (± 0.03%) 2,569,580k (±11.83%) ~ 2,386,793k 3,115,196k p=0.173 n=6
Parse Time 5.16s (± 0.84%) 5.17s (± 1.87%) ~ 5.07s 5.34s p=1.000 n=6
Bind Time 1.87s (± 0.62%) 1.86s (± 0.81%) ~ 1.83s 1.87s p=0.115 n=6
Check Time 35.48s (± 0.34%) 35.39s (± 0.86%) ~ 34.87s 35.80s p=0.689 n=6
Emit Time 3.01s (± 2.17%) 3.03s (± 1.97%) ~ 2.95s 3.10s p=0.471 n=6
Total Time 45.53s (± 0.36%) 45.45s (± 0.87%) ~ 44.93s 46.06s p=0.521 n=6
self-build-src-public-api - node (v18.15.0, x64)
Errors 0 0 ~ ~ ~ p=1.000 n=6
Symbols 1,252,023 1,252,029 +6 (+ 0.00%) ~ ~ p=0.001 n=6
Types 259,854 259,855 +1 (+ 0.00%) ~ ~ p=0.001 n=6
Memory used 2,940,928k (±12.73%) 3,182,506k (± 0.03%) ~ 3,181,008k 3,183,131k p=0.471 n=6
Parse Time 6.72s (± 1.67%) 6.82s (± 0.48%) ~ 6.76s 6.85s p=0.066 n=6
Bind Time 2.27s (± 0.82%) 2.30s (± 2.53%) ~ 2.24s 2.40s p=0.688 n=6
Check Time 43.03s (± 0.52%) 43.25s (± 0.26%) ~ 43.09s 43.39s p=0.066 n=6
Emit Time 3.46s (± 1.95%) 3.51s (± 2.43%) ~ 3.42s 3.64s p=0.230 n=6
Total Time 55.50s (± 0.62%) 55.87s (± 0.37%) ~ 55.55s 56.10s p=0.066 n=6
self-compiler - node (v18.15.0, x64)
Errors 0 0 ~ ~ ~ p=1.000 n=6
Symbols 264,680 264,686 +6 (+ 0.00%) ~ ~ p=0.001 n=6
Types 104,067 104,068 +1 (+ 0.00%) ~ ~ p=0.001 n=6
Memory used 442,931k (± 0.01%) 443,087k (± 0.02%) +157k (+ 0.04%) 442,944k 443,160k p=0.020 n=6
Parse Time 3.51s (± 1.03%) 3.51s (± 0.74%) ~ 3.48s 3.55s p=0.746 n=6
Bind Time 1.37s (± 0.59%) 1.38s (± 0.85%) ~ 1.36s 1.39s p=0.401 n=6
Check Time 19.15s (± 0.64%) 19.12s (± 0.34%) ~ 19.05s 19.22s p=0.378 n=6
Emit Time 1.54s (± 0.54%) 1.54s (± 0.68%) ~ 1.53s 1.56s p=0.109 n=6
Total Time 25.57s (± 0.40%) 25.55s (± 0.23%) ~ 25.47s 25.65s p=0.467 n=6
ts-pre-modules - node (v18.15.0, x64)
Errors 72 72 ~ ~ ~ p=1.000 n=6
Symbols 225,493 225,493 ~ ~ ~ p=1.000 n=6
Types 94,373 94,373 ~ ~ ~ p=1.000 n=6
Memory used 369,906k (± 0.05%) 369,811k (± 0.02%) ~ 369,722k 369,904k p=0.575 n=6
Parse Time 2.83s (± 0.82%) 2.84s (± 0.73%) ~ 2.80s 2.86s p=1.000 n=6
Bind Time 1.64s (± 0.99%) 1.65s (± 1.07%) ~ 1.62s 1.66s p=0.867 n=6
Check Time 16.62s (± 0.25%) 16.62s (± 0.18%) ~ 16.59s 16.67s p=0.746 n=6
Emit Time 0.00s 0.00s ~ ~ ~ p=1.000 n=6
Total Time 21.10s (± 0.32%) 21.11s (± 0.20%) ~ 21.06s 21.17s p=0.808 n=6
vscode - node (v18.15.0, x64)
Errors 11 11 ~ ~ ~ p=1.000 n=6
Symbols 4,070,569 4,070,569 ~ ~ ~ p=1.000 n=6
Types 1,284,252 1,284,252 ~ ~ ~ p=1.000 n=6
Memory used 3,861,857k (± 0.00%) 3,861,960k (± 0.00%) ~ 3,861,810k 3,862,139k p=0.173 n=6
Parse Time 15.74s (± 0.64%) 15.69s (± 0.22%) ~ 15.63s 15.73s p=0.748 n=6
Bind Time 5.45s (± 2.62%) 5.31s (± 0.35%) ~ 5.28s 5.33s p=0.090 n=6
Check Time 113.35s (± 3.22%) 114.33s (± 3.52%) ~ 109.05s 118.87s p=0.810 n=6
Emit Time 41.55s (±15.64%) 43.75s (±18.65%) ~ 38.75s 59.86s p=0.336 n=6
Total Time 176.09s (± 5.74%) 179.08s (± 4.11%) ~ 171.05s 189.94s p=0.298 n=6
webpack - node (v18.15.0, x64)
Errors 41 41 ~ ~ ~ p=1.000 n=6
Symbols 380,718 380,718 ~ ~ ~ p=1.000 n=6
Types 166,796 166,796 ~ ~ ~ p=1.000 n=6
Memory used 539,256k (± 0.01%) 539,331k (± 0.02%) ~ 539,113k 539,459k p=0.230 n=6
Parse Time 4.72s (± 0.92%) 4.70s (± 0.31%) ~ 4.68s 4.72s p=0.195 n=6
Bind Time 2.05s (± 1.53%) 2.06s (± 1.55%) ~ 2.03s 2.12s p=0.419 n=6
Check Time 22.91s (± 0.34%) 23.10s (± 1.19%) ~ 22.86s 23.63s p=0.078 n=6
Emit Time 0.00s 0.00s ~ ~ ~ p=1.000 n=6
Total Time 29.68s (± 0.45%) 29.86s (± 0.85%) ~ 29.62s 30.33s p=0.230 n=6
xstate-main - node (v18.15.0, x64)
Errors 30 30 ~ ~ ~ p=1.000 n=6
Symbols 694,700 694,700 ~ ~ ~ p=1.000 n=6
Types 212,083 212,083 ~ ~ ~ p=1.000 n=6
Memory used 589,869k (± 0.03%) 589,847k (± 0.03%) ~ 589,643k 590,070k p=0.936 n=6
Parse Time 4.19s (± 0.54%) 4.20s (± 0.46%) ~ 4.17s 4.22s p=0.292 n=6
Bind Time 1.39s (± 1.15%) 1.40s (± 1.28%) ~ 1.37s 1.42s p=0.417 n=6
Check Time 21.31s (± 1.71%) 21.24s (± 1.53%) ~ 20.91s 21.63s p=0.936 n=6
Emit Time 0.00s (±244.70%) 0.01s (±167.16%) ~ 0.00s 0.02s p=0.527 n=6
Total Time 26.89s (± 1.46%) 26.84s (± 1.30%) ~ 26.48s 27.27s p=0.810 n=6
System info unknown
Hosts
  • node (v18.15.0, x64)
Scenarios
  • Compiler-Unions - node (v18.15.0, x64)
  • angular-1 - node (v18.15.0, x64)
  • mui-docs - node (v18.15.0, x64)
  • self-build-src - node (v18.15.0, x64)
  • self-build-src-public-api - node (v18.15.0, x64)
  • self-compiler - node (v18.15.0, x64)
  • ts-pre-modules - node (v18.15.0, x64)
  • vscode - node (v18.15.0, x64)
  • webpack - node (v18.15.0, x64)
  • xstate-main - node (v18.15.0, x64)
Benchmark Name Iterations
Current pr 6
Baseline baseline 6

Developer Information:

Download Benchmarks

@typescript-bot
Copy link
Collaborator

@RyanCavanaugh Here are the results of running the top 400 repos with tsc comparing main and refs/pull/62900/merge:

Everything looks good!

@RyanCavanaugh
Copy link
Member

@typescript-bot pack this

@typescript-bot
Copy link
Collaborator

typescript-bot commented Dec 16, 2025

Starting jobs; this comment will be updated as builds start and complete.

Command Status Results
pack this ✅ Started ✅ Results

@typescript-bot
Copy link
Collaborator

typescript-bot commented Dec 16, 2025

Hey @RyanCavanaugh, I've packed this into an installable tgz. You can install it for testing by referencing it in your package.json like so:

{
    "devDependencies": {
        "typescript": "https://typescript.visualstudio.com/cf7ac146-d525-443c-b23c-0d58337efebc/_apis/build/builds/166833/artifacts?artifactName=tgz&fileId=7EBFB2956FF42FC9630D53B9AB18D6B71C389CB7DE7A0CCF21ECCE90FCAD45B602&fileName=/typescript-6.0.0-insiders.20251216.tgz"
    }
}

and then running npm install.


There is also a playground for this build and an npm module you can use via "typescript": "npm:@typescript-deploys/[email protected]".;

Copy link
Member

@RyanCavanaugh RyanCavanaugh left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot you need to make this case work too:

type obj = 
 | { kind: "a",  payload: string }


declare function assertNever(x: never): never;

function fn(o: obj) {
    switch(o.kind) {
        case "a":
            console.log(o.payload);
            break;
        default:
            // Should not error
            assertNever(o);
    }
}

Copy link
Contributor Author

Copilot AI commented Dec 16, 2025

@copilot you need to make this case work too:

...

I've investigated this case and added partial support. The fix now handles direct switches on single-member types (enums, literals) successfully, but your specific test case with discriminant property narrowing (o.kind) on a non-union object type is proving more complex than expected.

After extensive debugging, I found that when switching on o.kind, the property type narrows to never correctly, but the object o itself doesn't narrow. The issue appears to be in how flow analysis treats discriminant properties for non-union object types versus union types.

My current changes in commit 90c2555:

  • Added narrowing in narrowTypeBySwitchOnDiscriminant for non-union types when case type is never
  • Added check in narrowTypeByDiscriminant to return never when narrowed property type is never for non-union types

However, for your test case, narrowTypeByDiscriminant doesn't seem to be called with the narrowed property type as never, suggesting a different code path or timing issue in the flow analysis.

Could you clarify if discriminant property narrowing for non-union object types is a must-have for this PR, or if we can address it in a follow-up? The direct value narrowing (which handles the original issue #23155) is working correctly.

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

Labels

For Milestone Bug PRs that fix a bug with a specific milestone

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Exhaustiveness checking against an enum only works when the enum has >1 member. Type error in Buffer.from()

4 participants