Skip to content
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

Type narrowing is not propagated to a nested discriminated union #57218

Closed
undsoft opened this issue Jan 29, 2024 · 12 comments
Closed

Type narrowing is not propagated to a nested discriminated union #57218

undsoft opened this issue Jan 29, 2024 · 12 comments
Labels
Working as Intended The behavior described is the intended behavior; this is not a bug

Comments

@undsoft
Copy link

undsoft commented Jan 29, 2024

πŸ”Ž Search Terms

type narrowing, discriminated union, type narrowing propagation, nested type narrowing

πŸ•— Version & Regression Information

  • This is the behavior in every version I tried, and I reviewed the FAQ.

⏯ Playground Link

https://www.typescriptlang.org/play?ts=5.3.3&ssl=36&ssc=2&pln=27&pc=14#code/KYOwrgtgBAygDsAxgS2AZygbwFBSgEQHsBzAGlygGEBDAF2wF9ttkRbgAnAM2sWAJJYKaBCnQAuWKNRoAdEWIBuCgCNCHANaSAFAEooAXgB8UAG6FkAE2VMWbTjz5U6QvCKQzJ8D+lk1aNsy0AJ4IUACCIMgQ1AA2hs60UAA+AkpBofyR0XEA8lwAKpkAPDBQwAAe7CCWGN5iaCYGUACiVRy8tMXZMbGkWFDuDV5QDEbK2CFhPXEA6si0ABb5XCIcrMSl5VWgtVI+jQkzsflFCKUmAGQDhFyrcOsgxJLHpyUwJgwTscBJaIQQYDHBKYBhQagYY7zJYrNYbUrSdDjFhcKDaf6A46yIYyQwGZr1GTyEj6HBuAFAqK9WRqTR6RRQAD0jKguQA0sIKVjbvdHsQaeoNPSmSyAAocQgIDghKAAclpGllUEshHQUBAhCSlWQaCShBAUCm-Flr0K70RjVljGYzKgs0F1AlYBq2EqcHUSSNUAAYsgKsBLFCFss7nCniCKABtMqsfYNAC6Lypc2DsIe8I+jGjFvj31+4I1S04wOaoPBGF9-sDydi0JDvI2ymQqO01ELi2LNexFrxBItxOIpIobc1HY4WIVwtt7OH7c7OVish5Yf5k90DOnHIYQA

πŸ’» Code

enum Species {
  Dog,
  Cat
}

interface Dog {
  species: Species.Dog;
  bork: () => void;
}

interface Cat {
  species: Species.Cat;
}

type Animal = Cat | Dog;

type AnimalOfType<S extends Species> = Extract<Animal, { species: S }>;

type AnimalWithOffspring<S extends Species> = AnimalOfType<S> & { offspring: AnimalOfType<S> };

let someAnimal = {} as AnimalWithOffspring<Species>;
if (someAnimal.species === Species.Dog) {
  someAnimal.bork(); // OK
  someAnimal.offspring.bork(); // Property 'bork' does not exist on type 'AnimalOfType<Species>'
}

πŸ™ Actual behavior

I get an error of Property 'bork' does not exist on type 'AnimalOfType<Species>' because type narrowing did not propagate to offspring.

πŸ™‚ Expected behavior

Offspring type is narrowed to the same animal as parent.

Additional information about the issue

No response

@undsoft
Copy link
Author

undsoft commented Jan 29, 2024

This looks similar to #53202 and #18758, but I'm not sure.

Workaround:

export type FixedAnimalWithOffspring = {
  [S in Species]: AnimalWithOffspring<S>
}[Species];

let anotherAnimal = {} as FixedAnimalWithOffspring;
if (anotherAnimal.species === Species.Dog) {
  anotherAnimal.bork(); // OK
  anotherAnimal.offspring.bork(); // OK
}

@fatcerberus
Copy link

fatcerberus commented Jan 29, 2024

I mean, given the types as written, it’s entirely possible for someAnimal.offspring to be a Cat, so narrowing it to Dog would be unsound.

@undsoft
Copy link
Author

undsoft commented Jan 31, 2024

@fatcerberus Could you explain how is it possible that offspring is a Cat after parent was narrowed down to Dog in an if statement?

@fatcerberus
Copy link

fatcerberus commented Jan 31, 2024

Resolving type aliases for clarity, you started with a (Cat | Dog) & { offspring: Animal }. Intersection distributes over union, so that gives you (Cat & { offspring: Animal }) | (Dog & { offspring: Animal }). So there's no guarantee that offspring correlates with species.

Playground for proof

@undsoft
Copy link
Author

undsoft commented Feb 1, 2024

Okay, fair enough, but isn't it weird though?
As a developer when I see:

type AnimalWithOffspring<S extends Species> = AnimalOfType<S> & { offspring: AnimalOfType<S> };

it makes me naturally think that S is going to be the same between two subtypes. And thus my expectation is that when it is narrowed for one subtype, it should be narrowed for another (since it's the same S).

@snarbies
Copy link

snarbies commented Feb 1, 2024

Intuition suggests the types must correlate between AnimalWithOffspring.species and offspring.species, but as demonstrated above the actual nature of that correlation isn't necessarily what you expect. Case in point, the types as defined very much allow this construction:

// Look ma! No errors!
let someAnimal = {
  species: Species.Dog, 
  bork: () => 'woof!',
  offspring: {
    species: Species.Cat
  }
} satisfies AnimalWithOffspring<Species>; 

@undsoft
Copy link
Author

undsoft commented Feb 1, 2024

I get it for when S = Species, but after type narrowing compiler knows that S is not Species, but is in fact Species.Dog.

let someAnimal = {
  species: Species.Dog, 
  bork: () => 'woof!',
  offspring: {
    species: Species.Cat
  }
} satisfies AnimalWithOffspring<Species.Dog>; 

correctly produces an error.

@snarbies
Copy link

snarbies commented Feb 1, 2024

Right, but you don't have the variable defined as AnimalWithOffspring<Species.Dog> and your condition isn't enough to narrow it to AnimalWithOffspring<Species.Dog>.

Rather, your condition can only narrow it to AnimalWithOffspring<Species> & {species: Species.Dog}, in which case it's still technically valid to have {offspring: {species: Species.Cat} }, as shown in my example.

It's confusing, but it's not incorrect.

@fatcerberus
Copy link

fatcerberus commented Feb 1, 2024

@undsoft Simplifying this as far as possible, basically what you have is this:

type Fooey<T> = { a: T, b: T };
type Foo = Fooey<string | number>;

This gives you Foo = { a: string | number, b: string | number }. There's nothing in that type that says that both properties have to be the same member of the union. For that, you need to somehow construct the union { a: string, b: string } | { a: number, b: number } (this is what your FixedAnimalWithOffspring does relative to the OP).

You have to think of type aliases as type-functions - once you've invoked Fooey with some T, the type you get is what you get - no amount of narrowing can change T after the fact.1

Footnotes

  1. Or in other words, Fooey<T | U> is not equivalent to Fooey<T> | Fooey<U> ↩

@undsoft
Copy link
Author

undsoft commented Feb 1, 2024

I see. Thanks for your answers.

I would imagine if some of the linked tickets (#53202 and #18758) get resolved, this behaviour may change.

@fatcerberus
Copy link

No amount of fixes are going to change the fact that { a: T | U, b: T | U } doesn't require the properties to correlate. DiscriminatedUnion & { foo: DiscriminatedUnion } is just never going to do what you want and the correct solution is to distribute over the union and intersect the members individually; #53202 may look relevant, but it's specifically about the case where the types do already correlate and the compiler just can't tell.

@RyanCavanaugh RyanCavanaugh added the Working as Intended The behavior described is the intended behavior; this is not a bug label Feb 1, 2024
@typescript-bot
Copy link
Collaborator

This issue has been marked as "Working as Intended" and has seen no recent activity. It has been automatically closed for house-keeping purposes.

@typescript-bot typescript-bot closed this as not planned Won't fix, can't repro, duplicate, stale Feb 4, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Working as Intended The behavior described is the intended behavior; this is not a bug
Projects
None yet
Development

No branches or pull requests

5 participants