Why Typescript can get the exact value when giving a Generics that extends types

Viewed 223

a normal usage:

I only got string[]

function getResult<T>(...v: T[]) {
  return v
}

const str = getResult('str', 'str2') // const str: string[]

// or even
const str = getResult<string>('str' as const, 'str2') // const str: string[]

but if I let the generics extend string, end up with:

// for stirng
function getResultByExtendsString<T extends string>(...v: T[]) {
  return v
}

const str2 = getResultByExtendsString('str', 'str2') // const str2: ("str" | "str2")[]

// for number
function getResultByExtendsNumber<T extends number>(...v: T[]) {
  return v
}

const num2 = getResultByExtendsNumber(123, 456) // const num2: (123 | 456)[]

or even the mixed types

but if I remove the extending of P, Q, it won't get value but its type

type Types = string | number

function getAllResults<P extends Types, Q extends Types>(p: P, v: Q): (P | Q)[] {
  return [p, v]
}

const res = getAllResults('str', 123) // const res: ("str" | 123)[]

so how does this happen?

thanks. see from ts background

2 Answers

This can be determined because the compiler “contextual typing” capability. The TypeScript compiler can figure out the type even if you only have types on one side of the equation (i.e., funciton). Generic Classes | Functions | Variables are typically used as "Complex" Types if the data-type is unknown or "complex".

Resources:

https://www.typescriptlang.org/docs/handbook/typescript-in-5-minutes.html#types-by-inference

https://www.typescriptlang.org/docs/handbook/functions.html#inferring-the-types

This is a consequence of microsoft/TypeScript#10676, "Always use literal types". Generally speaking, the compiler will always initially infer the narrowest, literal type for a string, numeric, or boolean value. So "str" is seen as being of type "str".

However, the compiler will then often, but not always, widen the type, depending on how it is used. So sometimes the type "str" is widened to string; whereas other times it will stay "str". The pull request linked above goes through many different situations where one behavior or the other happens (e.g., const str = "str"; is not widened, but let str = "str" is widened), and to a rough approximation you can say that types are widened, unless the compiler has some idea that you expect a narrower type.

But in particular, the heuristic relevant to this question is how the compiler infers a type parameter when you call a generic function:

During type argument inference for a call expression the type inferred for a type parameter T is widened to its widened literal type if:

  • all inferences for T were made to top-level occurrences of T within the particular parameter type, and
  • T has no constraint or its constraint does not include primitive or literal types, and
  • T was fixed during inference or T does not occur at top-level in the return type.

So T will be widened if it has no constraint, as in getResult<T>(...), or if the constraint does not include primitive or literal types, as in getSomethingElse<T extends Date>(...). So for those calls you get string or number instead of string or numeric literals.

But in the case where there is a constraint including primitive types, such as getResultByExtendsString<T extends string>(...) or getResultByExtendsNumber<T extends number>(...), or even getAllResults<P extends Types, Q extends Types>(...) (since Types includes string and number), the type is not widened. And so "str" or 123 will stay "str" and 123.

Related