Skip to content

Indirect narrowing of destructured discriminated union types works only for trivial cases. #48846

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
DaviDevMod opened this issue Apr 26, 2022 · 12 comments

Comments

@DaviDevMod
Copy link

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.

@DaviDevMod
Copy link
Author

DaviDevMod commented Apr 26, 2022

Just noticed that removing ? from cutlery 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 as cutlery: never is a required property.

@whzx5byb
Copy link

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.

type Meal = { food: 'TANGERINE'; cutlery?: never } | { food: 'PIZZA'; cutlery?: never } | { food: 'PASTA'; cutlery: Cutlery };

@MartinJohns
Copy link
Contributor

See also: #43026

@DaviDevMod
Copy link
Author

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.

type Meal = { food: 'TANGERINE'; cutlery?: never } | { food: 'PIZZA'; cutlery?: never } | { food: 'PASTA'; cutlery: Cutlery };

Oh I see. But anyway it's worth noting that if you always use direct guard clauses if (food === 'TANGERINE') rather rely on returns, 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
Copy link
Contributor

Since unions of literals as discriminant already work, under certain circumstances, wouldn't be reasonable to have them fully implemented?

See specifically: #43026 (comment)

@DaviDevMod
Copy link
Author

@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
Copy link

fatcerberus commented Apr 26, 2022

The way to think about it is that, in this type:

type Meal = { food: 'TANGERINE' | 'PIZZA'; cutlery?: never } | { food: 'PASTA'; cutlery: Cutlery }

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:

function eat({ food, cutlery }: Meal) {
  if (food === 'TANGERINE')  return cutlery;
  // what's for dinner?  it's not tangerine, but might be pizza.  therefore, still TangerineOrPizza | Pasta
  if (food === 'PIZZA')  return cutlery;
  // again, what's for dinner?  it's not pizza, but (as far as TS knows) might be tangerine;
  // the fact that it isn't tangerine couldn't be preserved in terms of types alone (see above)
  // therefore, we STILL have TangerineOrPizza | Pasta
  return cutlery;  // end result: this is not narrowed
}

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 synthesizing Tangerine | Pizza from TangerineOrPizza), 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
Copy link
Author

@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
Copy link

fatcerberus commented Apr 26, 2022

What are the troubles you are foreseeing?

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), while TangerineOrPizza 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
Copy link
Author

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 or TangerineOrPizza.

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
Copy link

fatcerberus commented 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 while meal 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
Copy link
Author

@fatcerberus I'm rooting for you.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

4 participants