1

I have been struggling with the codes below for a few hours. Don't understand why e4 is string not String?

type PropConstructor4<T = any> = { new(...args: any[]): (T & object) } | { (): T }
type e4 = StringConstructor extends PropConstructor4<infer R> ? R : false // why string not String ???

I have tested below and I think I could understand.

type a4 = StringConstructor extends { new(...args: any[]): (infer R & object) } ? R : false // String
type b4 = StringConstructor extends { (): ( String) } ? true : false // true
type c4 = StringConstructor extends { (): (infer R) } ? R : false // string

Also, I couldn't understand why e5 is String not string?

type PropConstructor5<T = any> = { new(...args: any[]): (T ) } | { (): T }
type e5 = StringConstructor extends PropConstructor5<infer R> ? R : false //why String not string??

1 Answer 1

5

TL;DR the compiler uses heuristics to give different priorities to different inference sites, and infers the type from the inference site with the highest priority.


Generally speaking, when TypeScript infers a specific type for a type parameter (the R in all your examples), it considers each inference site, which is where the type parameter occurs in the expression it's trying to match. For example, in

type P = StringConstructor extends 
    (() => infer R) | { new(...args: any[]): (infer R & object) } ? R : never
//         ^^^^^^^  <-- inference sites -->   ^^^^^^^

there are two inference sites for the R type parameter. The compiler's job is to try to match StringConstructor with the full expression by inspecting an inference site, coming up with a candidate specific type for that site, and then checking the full expression to determine if that candidate works when substituted in every site.

Let's test it out with P above, by pretending that we're the compiler and seeing what happens if we change which inference site to inspect.


If the compiler chooses the first inference site to inspect:

type P = StringConstructor extends 
    (() => infer R) | { new(...args: any[]): (infer R & object) } ? R : never
//         ^^^^^^^  <-- inspect this

In this case, string is the candidate it would come up with, since String("hello") produces a string output. It could then check that string works for the whole expression. StringConstructor does indeed extend (() => string) | { new(...args: any[]): (string & object) } because it extends the first member of the union (since A extends B | C is true if A extends B or A extends C), and so R would be inferred as string if the compiler considers only the first inference site.


What about the second inference site?

type P = StringConstructor extends 
    (() => infer R) | { new(...args: any[]): (infer R & object) } ? R : never
//                           inspect this --> ~~~~~~~~

In this case, String is the candidate it would come up with, since new String("hello") produces a String output. It would then check that String works for the whole expression. StringConstructor does indeed extend (() => String) | { new(...args: any[]): (String & object) } because it extends both sides of the union. (string extends String, so () => string extends () => String), and so R would be inferred as String if the compiler considers only the second inference site.


It is also potentially possible that the compiler could consider both inference sites at the same time, and synthesize a union/supertype or intersection/subtype of the candidates depending on variance. In this case the parameters are both in a covariant position, so string | String or just String (since it is a supertype of string) would be my guesses if this happens.


So then for the above expression, one could plausibly imagine that string, String, string | String comes out. What actually happens?

type P = StringConstructor extends
    (() => infer R) | { new(...args: any[]): (infer R & object) } ? R : never
// string

It's string. That means the compiler is giving priority to the first inference site. Compare to what happens in the following:

type O = StringConstructor extends
    (() => infer R) | { new(...args: any[]): (infer R) } ? R : never
// String

Now, the compiler is giving priority to the second inference site instead. Somehow, (infer R & object) has a lower priority than just infer R.


So, how does the compiler assign priorities to different inference sites? I can't pretend to know the full details of this.

It used to be laid out in the TypeScript Specification document, but that is long out-of-date and now archived. See this section if you're curious. Nowadays there is no specification because the language is changing faster than can be rigorously documented.

There are a few issues in GitHub that touch on this idea of inference site priority. See microsoft/TypeScript#14829 for a feature request to allow a site to have zero priority and be never used for inference. See microsoft/TypeScript#39295 and microsoft/TypeScript#32389 for issues that stem from developers having a different intuition from what the compiler actually does.

One common thread is that intersections like (T & {}) have lower priority than types without intersections T. So you can use T & {} to lower the priority of a site if it is getting in the way. And so you that explains why infer R & object is not the chosen site for P.

Again, I don't know the full details of this, and it probably isn't very enlightening to learn. If you go through the type checker code you might be able to piece together whether construct signature return types have higher priority than call signature return types, but I wouldn't recommend writing any programs that depend on such details unless you want to keep revisiting it... one cannot guarantee that such particular inference rules will persist across releases of the language.


Playground link to code

Sign up to request clarification or add additional context in comments.

1 Comment

Anyway, thanks for the answer and the issue link. It gives me a lot of inspiration.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.