Skip to content

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

Closed
@DaviDevMod

Description

@DaviDevMod

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

DaviDevMod commented on Apr 26, 2022

@DaviDevMod
Author

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

whzx5byb commented on Apr 26, 2022

@whzx5byb

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

MartinJohns commented on Apr 26, 2022

@MartinJohns
Contributor

See also: #43026

DaviDevMod

DaviDevMod commented on Apr 26, 2022

@DaviDevMod
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

MartinJohns commented on Apr 26, 2022

@MartinJohns
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

DaviDevMod commented on Apr 26, 2022

@DaviDevMod
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

fatcerberus commented on Apr 26, 2022

@fatcerberus

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

DaviDevMod commented on Apr 26, 2022

@DaviDevMod
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

fatcerberus commented on Apr 26, 2022

@fatcerberus

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

DaviDevMod commented on Apr 26, 2022

@DaviDevMod
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

fatcerberus commented on Apr 26, 2022

@fatcerberus

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

DaviDevMod commented on Apr 26, 2022

@DaviDevMod
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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

      Development

      No branches or pull requests

        Participants

        @fatcerberus@MartinJohns@whzx5byb@DaviDevMod

        Issue actions

          Indirect narrowing of destructured discriminated union types works only for trivial cases. · Issue #48846 · microsoft/TypeScript