Skip to content

Conversation

Copy link
Contributor

CopilotAI 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:

enumSingle{VALUE='VALUE'}functiontest(x: Single){switch(x){caseSingle.VALUE: return1;}constn: 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 ValidActionenumActionTypes{INCREMENT='INCREMENT',// DECREMENT = 'DECREMENT',}interfaceIIncrement{payload: {};type: ActionTypes.INCREMENT;}// interface IDecrement{// payload:{};// type: ActionTypes.DECREMENT;// }// Any string not present in TtypeAnyStringExcept<Textendsstring>={[PinT]: never;};// ValidAction is an interface with a type in ActionTypestypeValidAction=IIncrement;// type ValidAction = IIncrement | IDecrement;// UnhandledAction in an interface with a type that is not within ActionTypestypeUnhandledAction={type: AnyStringExcept<ActionTypes>;};// The set of all actionstypePossibleAction=ValidAction|UnhandledAction;// Discriminates to ValidActionfunctionisUnhandled(x: PossibleAction): x is UnhandledAction{return!(x.typeinActionTypes);}typeCounterState=number;constinitialState: CounterState=0;functionreceiveAction(state=initialState,action: PossibleAction){// typeof action === PossibleActionif(isUnhandled(action)){// typeof action === UnhandledActionreturnstate;}// typeof action === ValidActionswitch(action.type){caseActionTypes.INCREMENT: // typeof action === IIncrementreturnstate+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 IIncrementreturnassertNever(action);}functionassertNever(x: UnhandledAction): never{thrownewError(`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-bottypescript-bot added the For Milestone Bug PRs that fix a bug with a specific milestone label Dec 15, 2025
CopilotAI changed the title [WIP] Fix exhaustiveness checking against a single member enumFix exhaustiveness checking for single-member enums in switch statementsDec 15, 2025
@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.

CommandStatusResults
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
MetricbaselineprDeltaBestWorstp-value
Compiler-Unions - node (v18.15.0, x64)
Errors11~~~p=1.000 n=6
Symbols62,37062,370~~~p=1.000 n=6
Types50,38750,387~~~p=1.000 n=6
Memory used194,934k (± 0.97%)194,266k (± 0.89%)~192,461k196,101kp=0.336 n=6
Parse Time1.30s (± 0.58%)1.31s (± 0.62%)~1.29s1.31sp=0.206 n=6
Bind Time0.76s0.76s~~~p=1.000 n=6
Check Time9.85s (± 0.27%)9.84s (± 0.11%)~9.83s9.85sp=0.663 n=6
Emit Time2.72s (± 0.40%)2.73s (± 0.59%)~2.71s2.75sp=0.187 n=6
Total Time14.63s (± 0.16%)14.63s (± 0.08%)~14.62s14.65sp=0.679 n=6
angular-1 - node (v18.15.0, x64)
Errors22~~~p=1.000 n=6
Symbols955,823955,823~~~p=1.000 n=6
Types415,853415,853~~~p=1.000 n=6
Memory used1,253,926k (± 0.01%)1,253,927k (± 0.00%)~1,253,864k1,254,025kp=0.689 n=6
Parse Time6.50s (± 0.47%)6.50s (± 0.57%)~6.45s6.56sp=0.627 n=6
Bind Time1.95s (± 0.21%)1.96s (± 0.38%)+0.01s (+ 0.68%)1.95s1.97sp=0.010 n=6
Check Time32.43s (± 0.33%)32.33s (± 0.07%)~32.29s32.36sp=0.077 n=6
Emit Time14.99s (± 0.29%)14.95s (± 0.52%)~14.82s15.03sp=0.469 n=6
Total Time55.88s (± 0.27%)55.74s (± 0.17%)~55.59s55.85sp=0.093 n=6
mui-docs - node (v18.15.0, x64)
Errors00~~~p=1.000 n=6
Symbols2,723,7862,723,786~~~p=1.000 n=6
Types937,507937,507~~~p=1.000 n=6
Memory used3,051,363k (± 0.00%)3,051,335k (± 0.00%)~3,051,270k3,051,387kp=1.000 n=6
Parse Time8.59s (± 0.23%)8.59s (± 0.25%)~8.57s8.63sp=0.683 n=6
Bind Time2.31s (± 0.35%)2.31s (± 0.35%)~2.30s2.32sp=0.204 n=6
Check Time93.13s (± 0.59%)92.93s (± 0.52%)~92.01s93.35sp=0.149 n=6
Emit Time0.31s (± 2.44%)0.31s (± 2.60%)~0.30s0.32sp=0.306 n=6
Total Time104.34s (± 0.51%)104.14s (± 0.46%)~103.23s104.54sp=0.149 n=6
self-build-src - node (v18.15.0, x64)
Errors00~~~p=1.000 n=6
Symbols1,252,0231,252,029+6 (+ 0.00%)~~p=0.001 n=6
Types259,854259,855+1 (+ 0.00%)~~p=0.001 n=6
Memory used2,387,271k (± 0.03%)2,569,580k (±11.83%)~2,386,793k3,115,196kp=0.173 n=6
Parse Time5.16s (± 0.84%)5.17s (± 1.87%)~5.07s5.34sp=1.000 n=6
Bind Time1.87s (± 0.62%)1.86s (± 0.81%)~1.83s1.87sp=0.115 n=6
Check Time35.48s (± 0.34%)35.39s (± 0.86%)~34.87s35.80sp=0.689 n=6
Emit Time3.01s (± 2.17%)3.03s (± 1.97%)~2.95s3.10sp=0.471 n=6
Total Time45.53s (± 0.36%)45.45s (± 0.87%)~44.93s46.06sp=0.521 n=6
self-build-src-public-api - node (v18.15.0, x64)
Errors00~~~p=1.000 n=6
Symbols1,252,0231,252,029+6 (+ 0.00%)~~p=0.001 n=6
Types259,854259,855+1 (+ 0.00%)~~p=0.001 n=6
Memory used2,940,928k (±12.73%)3,182,506k (± 0.03%)~3,181,008k3,183,131kp=0.471 n=6
Parse Time6.72s (± 1.67%)6.82s (± 0.48%)~6.76s6.85sp=0.066 n=6
Bind Time2.27s (± 0.82%)2.30s (± 2.53%)~2.24s2.40sp=0.688 n=6
Check Time43.03s (± 0.52%)43.25s (± 0.26%)~43.09s43.39sp=0.066 n=6
Emit Time3.46s (± 1.95%)3.51s (± 2.43%)~3.42s3.64sp=0.230 n=6
Total Time55.50s (± 0.62%)55.87s (± 0.37%)~55.55s56.10sp=0.066 n=6
self-compiler - node (v18.15.0, x64)
Errors00~~~p=1.000 n=6
Symbols264,680264,686+6 (+ 0.00%)~~p=0.001 n=6
Types104,067104,068+1 (+ 0.00%)~~p=0.001 n=6
Memory used442,931k (± 0.01%)443,087k (± 0.02%)+157k (+ 0.04%)442,944k443,160kp=0.020 n=6
Parse Time3.51s (± 1.03%)3.51s (± 0.74%)~3.48s3.55sp=0.746 n=6
Bind Time1.37s (± 0.59%)1.38s (± 0.85%)~1.36s1.39sp=0.401 n=6
Check Time19.15s (± 0.64%)19.12s (± 0.34%)~19.05s19.22sp=0.378 n=6
Emit Time1.54s (± 0.54%)1.54s (± 0.68%)~1.53s1.56sp=0.109 n=6
Total Time25.57s (± 0.40%)25.55s (± 0.23%)~25.47s25.65sp=0.467 n=6
ts-pre-modules - node (v18.15.0, x64)
Errors7272~~~p=1.000 n=6
Symbols225,493225,493~~~p=1.000 n=6
Types94,37394,373~~~p=1.000 n=6
Memory used369,906k (± 0.05%)369,811k (± 0.02%)~369,722k369,904kp=0.575 n=6
Parse Time2.83s (± 0.82%)2.84s (± 0.73%)~2.80s2.86sp=1.000 n=6
Bind Time1.64s (± 0.99%)1.65s (± 1.07%)~1.62s1.66sp=0.867 n=6
Check Time16.62s (± 0.25%)16.62s (± 0.18%)~16.59s16.67sp=0.746 n=6
Emit Time0.00s0.00s~~~p=1.000 n=6
Total Time21.10s (± 0.32%)21.11s (± 0.20%)~21.06s21.17sp=0.808 n=6
vscode - node (v18.15.0, x64)
Errors1111~~~p=1.000 n=6
Symbols4,070,5694,070,569~~~p=1.000 n=6
Types1,284,2521,284,252~~~p=1.000 n=6
Memory used3,861,857k (± 0.00%)3,861,960k (± 0.00%)~3,861,810k3,862,139kp=0.173 n=6
Parse Time15.74s (± 0.64%)15.69s (± 0.22%)~15.63s15.73sp=0.748 n=6
Bind Time5.45s (± 2.62%)5.31s (± 0.35%)~5.28s5.33sp=0.090 n=6
Check Time113.35s (± 3.22%)114.33s (± 3.52%)~109.05s118.87sp=0.810 n=6
Emit Time41.55s (±15.64%)43.75s (±18.65%)~38.75s59.86sp=0.336 n=6
Total Time176.09s (± 5.74%)179.08s (± 4.11%)~171.05s189.94sp=0.298 n=6
webpack - node (v18.15.0, x64)
Errors4141~~~p=1.000 n=6
Symbols380,718380,718~~~p=1.000 n=6
Types166,796166,796~~~p=1.000 n=6
Memory used539,256k (± 0.01%)539,331k (± 0.02%)~539,113k539,459kp=0.230 n=6
Parse Time4.72s (± 0.92%)4.70s (± 0.31%)~4.68s4.72sp=0.195 n=6
Bind Time2.05s (± 1.53%)2.06s (± 1.55%)~2.03s2.12sp=0.419 n=6
Check Time22.91s (± 0.34%)23.10s (± 1.19%)~22.86s23.63sp=0.078 n=6
Emit Time0.00s0.00s~~~p=1.000 n=6
Total Time29.68s (± 0.45%)29.86s (± 0.85%)~29.62s30.33sp=0.230 n=6
xstate-main - node (v18.15.0, x64)
Errors3030~~~p=1.000 n=6
Symbols694,700694,700~~~p=1.000 n=6
Types212,083212,083~~~p=1.000 n=6
Memory used589,869k (± 0.03%)589,847k (± 0.03%)~589,643k590,070kp=0.936 n=6
Parse Time4.19s (± 0.54%)4.20s (± 0.46%)~4.17s4.22sp=0.292 n=6
Bind Time1.39s (± 1.15%)1.40s (± 1.28%)~1.37s1.42sp=0.417 n=6
Check Time21.31s (± 1.71%)21.24s (± 1.53%)~20.91s21.63sp=0.936 n=6
Emit Time0.00s (±244.70%)0.01s (±167.16%)~0.00s0.02sp=0.527 n=6
Total Time26.89s (± 1.46%)26.84s (± 1.30%)~26.48s27.27sp=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)
BenchmarkNameIterations
Currentpr6
Baselinebaseline6

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.

CommandStatusResults
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

@RyanCavanaughRyanCavanaugh 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:

typeobj=|{kind: "a",payload: string}declarefunctionassertNever(x: never): never;functionfn(o: obj){switch(o.kind){case"a": console.log(o.payload);break;default: // Should not errorassertNever(o);}}

Copy link
ContributorAuthor

CopilotAI 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 freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

For Milestone BugPRs 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

@rubiesonthesky@RyanCavanaugh@typescript-bot