Skip to content

In function argument, applying a generic type that extends an object type to a conditional type operator doesn't specialise #56011

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
johanrosenkilde opened this issue Oct 6, 2023 · 10 comments
Labels
Duplicate An existing issue was already created

Comments

@johanrosenkilde
Copy link

johanrosenkilde commented Oct 6, 2023

🔎 Search Terms

"inference conditional", "automatic inference", "generic function argument", "object type"

🕗 Version & Regression Information

  • This is the behavior in every version I tried, and I reviewed the FAQ for entries about type system behaviour, generics, type guards

⏯ Playground Link

Link

💻 Code

type Vals = string | { [key: string]: Vals };
type ObjVal = { [key: string]: Vals };
type Id<T extends Vals> = T extends ObjVal
  ? { [K in keyof T]: Id<T[K]> }
  : string
type IdObj<T extends ObjVal> = { [K in keyof T]: Id<T[K]> };

type Family = {
  a: Vals;
};

function unexpectedlyDoesntWork<B extends Family>(input: Id<B>): void {
  input.a satisfies Id<B["a"]>;
}

function worksFine<B extends Family>(input: IdObj<B>): void {
  input.a satisfies Id<B["a"]>;
}

🙁 Actual behavior

In unexpectedlyDoesntWork, input seems to be type inferred as Id<Vals> i.e. Vals, and so input.a is inferred as Vals. Hence the satisfies clause fails. This is not the most precise interpretation, since B is known to extend Family and hence be of object type with a member a, and so Id<B> should specialise to { [K in keyof B]: Id<B[K]> }.

🙂 Expected behavior

I expect input.a to be inferred as type Id<B["a"]> . This occurs in the worksFine function, where the only difference is that the function value has applied the non-conditional type operator IdObj to B.

Additional information about the issue

No response

@MartinJohns
Copy link
Contributor

MartinJohns commented Oct 6, 2023

It's a design limitation. There are numerous issues about this. Resolving of conditional types involving unbound generic type arguments are deferred until the type is known. In your example the type of B is not known within the function, so the conditional type is not resolved.

Duplicate of #53455 and others.

@RyanCavanaugh RyanCavanaugh added the Duplicate An existing issue was already created label Oct 6, 2023
@johanrosenkilde
Copy link
Author

johanrosenkilde commented Oct 6, 2023

Thank you for the swift response! I see. And by "design limitation" you mean that there is no intention of trying to support this, even if it is theoretically possible?

I want to add that it seems to me that this case is more resolvable than #53455. In that issue, it's theoretically impossible to resolve the conditional (in isolation within the body of the function) because the unbound generic type U could either extend undefined or not.

Contrarily, in the example of this issue, the unbound generic type B extends Family and so must be an object type, which means that the conditional could be resolved.

In other words, and with naivety of the hairy details of the TypeScript type checker, I don't see why conditionals that can be resolved at type checking time necessarily need to be deferred.

@fatcerberus
Copy link

There are actually cases where the compiler can resolve conditional types over generics if it can prove they always resolve a particular way. However, in your given example, the conditional type is distributive (i.e. it maps over unions) - which to my knowledge is always deferred because the compiler has no way of knowing how many times the conditional has to be applied. It might even be 0 times, if the input type happens to be never!

@fatcerberus
Copy link

btw, your Playground and posted code differ. The issue form tells you not to do this

This code and the Playground code should be the same, do not use separate examples.

@johanrosenkilde
Copy link
Author

btw, your Playground and posted code differ. The issue form tells you not to do this

Sorry, I realized after posting the first link that I could simplify the example. After doing so, I forgot to refresh the link to the playground. Will do so now.

@johanrosenkilde
Copy link
Author

However, in your given example, the conditional type is distributive (i.e. it maps over unions) - which to my knowledge is always deferred because the compiler has no way of knowing how many times the conditional has to be applied. It might even be 0 times, if the input type happens to be never!

In my example the first application of the conditional may be statically determined: since B extends Family and Family is an object type, then B must be an object type. Except if B is never, but that seems like a special case that is anyway treated differently in the conditional (e.g. Id<never> is never despite this not being either of the return types of the conditional).

@fatcerberus
Copy link

Id<never> is never because never is considered an empty union, so there are no types to evaluate the conditional against. It’s like .map() over an empty array.

Anyway, you know that B is an object type, but you don’t know whether it’s a union of object types - in which case Id<B> would also resolve to a union. TS must therefore defer it because it doesn’t know how many times it will have to apply the conditional. Now you might say here, well, the type you get will always be compatible, but assignability checks don’t work that way; there’s no higher-order machinery that can say “this will always resolve to a compatible type and is therefore assignable” without actually being able to resolve it. It’s just not part of the design of the compiler. Hence, Design Limitation.

@typescript-bot
Copy link
Collaborator

This issue has been marked as "Duplicate" 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 Oct 9, 2023
@johanrosenkilde
Copy link
Author

Thanks for explaining this, @fatcerberus

there’s no higher-order machinery that can say “this will always resolve to a compatible type and is therefore assignable” without actually being able to resolve it. It’s just not part of the design of the compiler. Hence, Design Limitation.

Of course, I can't argue with this not knowing the design of the compiler. It's just still surprising to me that such a mechanism should be problematic to implement. It seems to me a matter of implementing a pattern match saying that a type internally represented as:

[T := Possible Union<B extends some object type>] extends object type ? trueCase<T> : falseCase<T>

may immediately be reduced internally to

PossibleUnion<trueCase<B extends some object type>

But anyway, issue closed, I'll just have to work around it, and please don't feel you need to respond to this.

@fatcerberus
Copy link

@johanrosenkilde FWIW, your PossibleUnion abstraction illustrates the issue quite well - notice that still exists in the imagined reduced type. So the partially resolved type still necessarily has a quantifier and TS doesn't reason about that kind of thing in type checks. It just sees a deferred type. That's what I meant about the lack of higher-order machinery. I don't expect the compiler to be rearchitected to support that, but stranger things have happened when @ahejlsberg drops a PR out of nowhere so...1 😅

Footnotes

  1. Nope, Design Limitation isn't necessarily a death sentence for an issue. It just means "we can't implement/fix this without adding new abstractions and possibly rethinking how adjacent features work."

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Duplicate An existing issue was already created
Projects
None yet
Development

No branches or pull requests

5 participants