Description
Bug Report
Narrowing destructured discriminated union types using indirect control flow, works only for trivial cases in which the discriminant is a union of at most two types.
I found many issues related to discriminated unions, but they describe different problems.
🔎 Search Terms
discriminated
🕗 Version & Regression Information
Control Flow Analysis for Destructured Discriminated Unions has been introduced (or fixed) in TypeScript 4.6
The bug has been tested with the currently most recent version in the TypeScript Playground, which is v4.6.2
⏯ Playground Link
The discriminant is a union of at most two types: indirect narrowing works as expected.
The discriminant is a union of at least three types: indirect narrowing fails.
💻 Code
The discriminant is a union of at most two types and indirect narrowing works as expected:
type Food = 'TANGERINE' | 'PASTA';
type Cutlery = 'FORK' | 'SPOON';
type Meal = { food: 'TANGERINE'; cutlery?: never } | { food: 'PASTA'; cutlery: Cutlery };
function eat({ food, cutlery }: Meal) {
if (food === 'TANGERINE') return cutlery;
return cutlery; // Correctly narrowed
}
The discriminant is a union of at least three types and indirect narrowing fails:
type Food = 'TANGERINE' | 'PASTA' | 'PIZZA';
type Cutlery = 'FORK' | 'SPOON';
type Meal = { food: 'TANGERINE' | 'PIZZA'; cutlery?: never } | { food: 'PASTA'; cutlery: Cutlery };
function eat({ food, cutlery }: Meal) {
if (food === 'TANGERINE') return cutlery;
if (food === 'PIZZA') return cutlery;
return cutlery; // Not narrowed
}
🙁 Actual behavior
Indirect narrowing doesn't work on destructured discriminated union when the discriminant is a union of at least three types.
🙂 Expected behavior
Destructured discriminated union should be narrowable indirectly, no matter how many types are involved in the discriminant.
If an implementation for a discriminant composed by an arbitrarily large number of types is not possible, I hope it is possible to at least raise the bar to five or even twenty types.
Activity
DaviDevMod commentedon Apr 26, 2022
Just noticed that removing
?
fromcutlery
makes the narrowing work as expected.However there was a reason for having that optional
never
: if the property is required, calling the function becomes a mess.Check this Playground, remove the question mark and watch the evil error unfold.
The problem here may not be the one I initially thought of. Should I modify the issue or open a new one?
Edit: also I have to admit that I filled up the issue while these discriminated unions were (are still) not fully clear to me. Now I can see that the problem may be the same as #48522, since in one case the discriminant
food
is not a literal type but a union of two literals'TANGERINE' | 'PIZZA'
. However as noted before, the narrowing works even with the non-literal discriminant as long ascutlery: never
is a required property.whzx5byb commentedon Apr 26, 2022
To create a discriminated union, you must have a common literal property. But
food: 'TANGERINE' | 'PIZZA'
is not a literal, it's a union. Separating them will make your example work.MartinJohns commentedon Apr 26, 2022
See also: #43026
DaviDevMod commentedon Apr 26, 2022
Oh I see. But anyway it's worth noting that if you always use direct guard clauses
if (food === 'TANGERINE')
rather rely onreturn
s, the union of literals as discriminant works fine and it's narrowed.Since unions of literals as discriminant already work, under certain circumstances, wouldn't be reasonable to have them fully implemented? (Mainly to simplify the declaration of the discriminated union, which may get unnecessarily verbose if the discriminant is a union of many literals)
MartinJohns commentedon Apr 26, 2022
See specifically: #43026 (comment)
DaviDevMod commentedon Apr 26, 2022
@MartinJohns thank you for the link. That issue is marked as
Design Limitatoin
and though I find it hard to believe, I can't say anything regarding it's complexity and it surely is not a priority as one can work his way around the problem.I am closing the issue.
Thank you so much for the support!
fatcerberus commentedon Apr 26, 2022
The way to think about it is that, in this type:
Logically, this type represents the disjunction
TangerineOrPizza | Pasta
. The first member of this sum type contains a union type as a property, but is, as a logical predicate, indivisible. So from there the logic goes:In order to deal with this problem, the type system would need to explode object types containing unions, e.g.
{ type: "foo" | "bar" }
becomes{ type: "foo" } | { type: "bar" }
(essentially synthesizingTangerine | Pizza
fromTangerineOrPizza
), but this would be combinatorially explosive for objects containing multiple union-typed properties (as well as possibly unsound, since the two representations are not necessarily equivalent). Hence the design limitation.DaviDevMod commentedon Apr 26, 2022
@fatcerberus thanks for the insight!
The problem of explosive types can be solved by setting a reasonable limit to the number of expanded types (like when your infinite recursion exceeds the maximum call stack size).
Regarding the fact that the expanded types may not be an equivalent representation of the original union, how it comes?
There would be a predictable algorithm to follow (which presumably is the same algorithm I use to get the job done manually). What are the troubles you are foreseeing?
fatcerberus commentedon Apr 26, 2022
The main theoretical one is that "the existential is in the wrong place", that is, uncertainty about the nature of a container implies uncertainty about its contents, but the inverse is not true. By analogy:
Tangerine | Pizza
implies we don't know whether have a bag of tangerines or a pizza box (but if you can figure it out then you also know what's inside without opening it), whileTangerineOrPizza
implies a more universal container that might contain either (that you have to actually open to be sure). Whether that actually matters in practice depends on the nature of the program in question, but is a valid distinction, and sometimes matters to the type system too (e.g. distributivity of conditional types).DaviDevMod commentedon Apr 26, 2022
I think I get what you are saying. A possible solution to this problem may be making available a syntax that gives the expressive power to state whether a union is meant to be considered
Tangerine | Pizza
orTangerineOrPizza
.Something like
Atomic<'TANGERINE' | 'PIZZA'>
would be treated as an indivisible unit during the expansion, while a regular union type'TANGERINE' | 'PIZZA'
would be recursively expanded to its atomic constituents.fatcerberus commentedon Apr 26, 2022
Putting aside the nerdy theoretical details, I agree it’s a bit confusing that negative type guards don’t always compose in the obvious way. They only compose if each individual guard is able to eliminate some member of a union. Hence the seemingly contradictory situation of
meal.food
being correctly narrowed whilemeal
itself isn’t.Good news is that it might be possible to make a utility type that does the necessary expansion to make this work. If I come up with something I’ll report back.
DaviDevMod commentedon Apr 26, 2022
@fatcerberus I'm rooting for you.