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

Enum generic does not restrict union of possible objects where enum is defined #58634

Closed
noahtallen opened this issue May 23, 2024 · 9 comments
Closed
Labels
Working as Intended The behavior described is the intended behavior; this is not a bug

Comments

@noahtallen
Copy link

noahtallen commented May 23, 2024

🔎 Search Terms

generic type does not get inferred, generic type is not restricted

🕗 Version & Regression Information

  • This is the behavior in every version I tried (5.5 beta, 5.x, 4.x, 3.x), and I reviewed the FAQ for entries about generic type inference

⏯ Playground Link

https://www.typescriptlang.org/play/?ts=5.4.5#code/KYOwrgtgBAyg9hYAVAFgSxAcwM5QN4BQUUA8iMFALxQDkc5NANEVEgO5xW0AuHTBAXwIEM3YACcAZgEMAxhQCC4zAFlpAB3wsA2vESoMOAHRlgAXQBc+AB5XwEAEYSBzYroTJ0WbEfZxL+ACeVtjc4oZCQiIgYlJyispIgerAADysUMDWYiAAJrh6noa4AHxaAJDcXphWSADcBOW50tzSVkqqGtpIZg1RAPT9UADCcOLiwLLcUNLKUNzJFGi4GJISE7lQAG5o0lAOcFVQHWqa0nnHiYsWBAsprMChHQBiY6ZcJ12FBt4m5L23RYPJ5Xe7UDpJFKpb7VHymEraGjNVo0AEEQasRa4WYUCahcJTYCbFASYB1KBobg0XDYYDACCGAA2gQpEHUcGw2DQDkZFG4nHU0k5M220kZYD5KBaUFycEeIBo0wgLVkKEumEhaRhxT+wBKRgIsnooSgkjgnGoeCqhis2t+pkYMpabRsVgAjAIBA1ZYVrVgAOqUlAAEWdAAozXAAJQNAiSMAgKZoegyuC+6qBqqh1qpJCZbKgfKwDw-HAlMNW6qO5HSATtUFpJAlKNaYhoSRQMN+zBUSjUO3GUwtwjEYhGkDYOC8oyMuCYMM1ozWGNQDEwFBwMCMzZOKB4sJoQmbfnq07ufSw3VmR0OMDTZYK7hGYszdSCiYxZkU6YASRgUHZTluV5eYBSFbEQCgOAHAAK0maY2BJCYoAAIm7FCZguFCaww2VHygZVuFVeYSSgccwjke9IIhRYAH4WEiAggA

💻 Code

Let's say you have an enum, and you create a map of those enums to another type. You want a function such that when you pass a specific enum, you must also pass the data associated with that enum.

I know this example is possible to achieve with discriminated unions, but the real-world code makes it a lot harder to use those. This example just demonstrates the problem in a small way:

enum SomeThings {
  One = 'one',
  Two = 'two',
}

interface ArgMap {
  [SomeThings.One]: {x: number},
  [SomeThings.Two]: {y: string}
}

interface ArgType< T extends SomeThings > {
  thing: T;
  data: ArgMap[T];
}

// It is not possible to call this with values that will actually cause an error.
function doSomethingWithData<T extends SomeThings>({thing, data}: ArgType<T>) {
  if (thing === SomeThings.One) {
    // Error: TS infers "data" as the union "{x: number} | {y: string}"
    // when it should be restricted to "ArgMap[T]", so "data.x" is not accessible
    console.log(data.x);
  }
}

🙁 Actual behavior

Typescript reports an error, because it thinks that data.x is a union of the possible data types. However, it should always be theoretically possible to restrict the type of data based on the enum associated with it. You can even use ArgType as expected by itself:

type TestData = ArgType<SomeThings.One>['data']; // correctly restricts to "{x: number}"

🙂 Expected behavior

As far as I can tell, the types are correct, because it is impossible to pass an ArgType<T> such that data does not match ArgMap[T]. So it should be possible to restrict the type here

Additional information about the issue

No response

@MartinJohns
Copy link
Contributor

Because within the function the compiler doesn't know which type is passed along. It could be SomeThings.One, it could be SomeThings.Two, it could also be the type SomeThings.One | SomeThings.Two. There's definitely many duplicates of this, just don't have one at hand right now.

@noahtallen
Copy link
Author

noahtallen commented May 23, 2024

The compiler should be able to know that SomeThings.One was passed within this block. It should also be know (based on the contracted specified by the ArgType interface) that it's impossible to pass mismatched arguments where the data doesn't match the enum:

// We know which one was passed now:
  if (thing === SomeThings.One) {
    console.log(data.x);
  }

// Correctly shows an error, so it does narrow the type of `data` based on the enum here:
doSomethingWithData({thing: SomeThings.One, data: {y: 'xyz'});

I think it evaluates ArgType<T> too early into a union of all possible values, where if it evaluated it within the if statement, it should know that it resolves to a single possibility

@MartinJohns
Copy link
Contributor

You misunderstand how generics work. Just because thing equals SomeThings.One does not mean you're dealing with a { x: number } data.

This is a perfectly valid and legal call:
doSomethingWithData<SomeThings.One | SomeThings.Two>({ thing: SomeThings.One, data: { y: "abc" }})

@whzx5byb
Copy link

whzx5byb commented May 23, 2024

There's definitely many duplicates of this

#27808, extends oneof issue, I've seen too many of them.

@noahtallen just use the distributive behavior to make it work

function doSomethingWithData<T extends SomeThings>({thing, data}: T extends unknown ? ArgType<T> : never) {
  if (thing === SomeThings.One) {
    console.log(data.x); // OK
  }
}

Note that you also have to make the literal object foo declared as const.
const foo = {thing: SomeThings.One, data: {x: 1}} as const;

@noahtallen
Copy link
Author

That's great, thanks for sharing! looks like as const isn't needed if you do:

const foo: ArgType<SomeThings.One> = {thing: SomeThings.One, data: {x: 1}};

if (someCondition) {
  foo.data.x = 100;
}

// works!
doSomethingWithData(foo);
doSomethingWithData({ thing: SomeThings.Two, data: {y: 'hi'}});

Anyways, I'm curious how this works. Why T extends unknown when T is known?

ts playground

@whzx5byb
Copy link

Why T extends unknown when T is known?

Because unknown is the top type that anything can be assigned to it, to make sure that the conditional type always get in the "true" branch. You can replace it with T extends any or T extends T, all of them are OK.

And for "distributive" behavior, see https://www.typescriptlang.org/docs/handbook/2/conditional-types.html#distributive-conditional-types.

@noahtallen
Copy link
Author

Hm interesting. It's odd to me that something like T extends T ? ... : never is required to get inference to work, given that T always extends T. So this is basically just tricking/forcing the compiler to distribute here?

Either way, happy that there's a solution!

@fatcerberus
Copy link

So this is basically just tricking/forcing the compiler to distribute here?

Yes. Conditional types distribute over type parameters, so:

type ArrayOf<T> = T extends T ? T[] : never;
type Test = ArrayOf<string | number>;  // string[] | number[];

whereas without distribution you’d get (string | number)[] instead.

@RyanCavanaugh RyanCavanaugh added the Working as Intended The behavior described is the intended behavior; this is not a bug label Jun 7, 2024
@RyanCavanaugh
Copy link
Member

#58634 (comment)

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