Repository navigation
discriminated union type matching behaviour changed starting from 5.2.x #57231
Description
Activity
Andarist commented
on Jan 30, 2024 ContributorMore actionsI have bisected this to #53907 . It means that this code with this change should roughly be equivalent to the one with
vegetablevariable inlined:type Food = 'apple' | 'orange'; type Vegetable = 'spinach' | 'carrot'; type Other = 'milk' | 'water'; type Custom = 'air' | 'soil'; type Target = | { audience: 'earth'; meal: | Custom | `fruit_${Food}` | `vegetable_${Vegetable}` | `other_${Other}`; } | { audience: 'mars' | 'jupiter'; meal: string; } const target: Target = { audience: 'earth', meal: `vegetable_carrot` }; const target2: Target = { meal: `vegetable_carrot`, audience: 'earth' };
This one doesn't error though. I'll investigate further what's the difference and what the behavior should be.
Reacted by Tushar Sharmatype Vegetable = 'spinach' | 'carrot';What, no tomatoes?
...oh no, they didn't turn carnivorous and go on a rampage did they? because if so... run
Reacted by Andrii Dieiev and Tushar SharmaAndarist commented
on Jan 30, 2024 ContributorMore actionsThis is fun 😅
type Food = "apple" | "orange"; type Vegetable = "spinach" | "carrot"; type Other = "milk" | "water"; type Custom = "air" | "soil"; type Target = | { audience: "earth"; meal: | Custom | `fruit_${Food}` | `vegetable_${Vegetable}` | `other_${Other}`; } | { audience: "mars" | "jupiter"; meal: string; }; const vegetable1: Vegetable = "carrot"; // it errors but it shouldn't const target1: Target = { meal: `vegetable_${vegetable1}`, audience: "earth", }; const vegetable2: "carrot" = "carrot"; // it errors but it shouldn't const target2: Target = { meal: `vegetable_${vegetable2}`, audience: "earth", }; const vegetable3 = "carrot"; // ok const target3: Target = { meal: `vegetable_${vegetable3}`, audience: "earth", };
I'll push out a fix for this soon.
Reacted by Tushar Sharma- addedHelp WantedYou can do thisYou can do thisPossible ImprovementThe current behavior isn't wrong, but it's possible to see that it might be better in some casesThe current behavior isn't wrong, but it's possible to see that it might be better in some cases
on Jan 30, 2024 A simpler repro that isn't fixed by #57236:
type Vegetable = 'spinach' | 'carrot'; type Target = | { audience: 'earth', meal: `vegetable_${Vegetable}` } | { audience: 'mars', meal: string }; declare const vegetable: Vegetable; // Ok const target: Target = { audience: 'earth', meal: `vegetable_${vegetable}` }; // Errors const target2: Target = { meal: `vegetable_${vegetable}`, audience: 'earth' };
The root problem is that contextual types are narrowed by discriminant properties in the order those discriminant properties are written (as opposed to some canonical narrowing that considers all of them at the same time). Above, the assignment to
targetsucceeds because the contextual typeTargetis first narrowed by theaudience: "earth"property. However, the assignment totarget2fails because no narrowing as (yet) taken place, so the contextual type for the template literal isstring.In reality,
mealshouldn't really be considered a discriminant property since one of the property types is a supertype of every discriminant. We don't currently reason about it that way, but I'll look into a PR for that.In reality,
mealshouldn't really be considered a discriminant property since one of the property types is a supertype of every discriminant.Given how often people want to be able to write
"foo" | "bar" | stringand have that mean something, I feel like there's almost certainly code in the wild that does stuff liketype DU = | { type: "foo", foo: string } | { type: "bar", bar: string } | { type: string, catchall?: string }
which would likely be broken by
typeno longer being considered a discriminant. And we don't have negated types yet, so...I feel like there's almost certainly code in the wild that does stuff like...
There probably is, but it is hard to see what it accomplishes. The only property that can be made accessible through narrowing is
catchalland only when narrowed to atypethat isn't"foo"or"bar".- addedDomain: check: Control FlowThe issue relates to control flow analysisThe issue relates to control flow analysis
on Oct 16, 2025
🔎 Search Terms
"key order when matching object types" "discriminated unions key order"
🕗 Version & Regression Information
⏯ Playground Link
https://www.typescriptlang.org/play?ts=5.2.2#code/FAFwngDgpgBAYgewQExgXhgcgIYQgGykxgB8sEAnbAOwHMiBuUSWANSnpGwCND0sAzhACW1bAGMAFsTKZx2ChQQhMTcNBgB5EJKgV+mALbD8AaxlYA7thB7VzDQGEArgJAJDB7MIoXMAhBN7B1gYABUFTnRgGFi4sgBvGLiU2OxnZGEoanEoAC4sKAUde1TUwyL8POSylLIXNw8a2tiyAAMAMwpnYRAAfQASBMQUAF825pb2gDcOKC5eKEGE9k4eQnHJ2vblXQpl7T3xphbRrcStlPTM7NyCowUBPwArZxFbXxOWmArsKpg3BRRLQvmUzsBgOIENQ3DBZmtFgVVvN1rAMHIFEoVExIdDYVwKJwChFCfN+DAklcMlkcvlCsVpAAaZq-f5teEoxbLDkLDYTUY4qEwkAwAmcABMxMiZIwFJZlQK7LmvKWQx5qPGzKpN1p9yKFBKwAFwCAA
💻 Code
Output
Compiler Options
{ "compilerOptions": { "strict": true, "noImplicitAny": true, "strictNullChecks": true, "strictFunctionTypes": true, "strictPropertyInitialization": true, "strictBindCallApply": true, "noImplicitThis": true, "noImplicitReturns": true, "alwaysStrict": true, "esModuleInterop": true, "declaration": true, "target": "ES2017", "jsx": "react", "module": "ESNext", "moduleResolution": "node" } }Playground Link: Provided
🙁 Actual behavior
In the code both
targetandtarget2have the correct structure based on theTargettype.But the order of keys is reversed in
target2which used to work but not anymore.🙂 Expected behavior
Both
targetandtarget2should be valid.Additional information about the issue
Discriminated unions didn't rely on the key ordering AFAIK.