5

Consider the following example:

function identity<T>(arr: T[]) { return arr }

identity(["a", "b"])

Here, the generic type T is being inferred as a string, which makes sense to me.

But if a constraint is added on T (as shown below), then T is inferred as "a" | "b", why?

function identity<T extends string>(arr: T[]) { return arr }

identity(["a", "b"])
1
  • I just went down a rabbit hole of reading about widening vs non-widening literal types github.com/microsoft/TypeScript/pull/10676 but don't have an answer. I can also confirm this behavior exists on TS v3.3 in the playground. Following for updates. Cool find. Ultimately, something is triggering a hint to the compiler to use the literal type. TS is full of these deep "if XYZ scenario, infer this way" which makes it harder to identify obvious cause/effects sometimes. Commented Sep 10, 2022 at 21:35

1 Answer 1

3

I've learned some things to share. It's not a definitive answer but worth noting:

When is a type parameter widened, from "a" to string?

https://github.com/microsoft/TypeScript/pull/10676

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.

Type parameter T is widened if:

  • T has no constraint
  • or its constraint does not include primitive or literal types

Widened
<T>(arr:T[]) has no constraint, so the types will be widened. 'a' = string

The unconstrained version is so generic, TS doesn't make too many assumptions about how to treat literals.

Not Widened
<T extends string>(arr:T[]) has a primitive constraint and isn't widened.

Since the constrained version is less generic, it preserves them. See PR for generally wanting to preserve literals as much as possible.

This is also an interesting tidbit:

In cases where literal types are preserved in inferences for a type parameter (i.e. when no widening takes place), if all inferences are literal types or literal union types with the same base primitive type, the resulting inferred type is a union of the inferences. Otherwise, the inferred type is the common supertype of the inferences, and an error occurs if there is no such common supertype.

All inferences of T are literal types ("a", "b") of the same base primitive type (string).

const fn = <T extends string>(arr:T[]) => arr
fn(['a', 'b'])  // ('a' | 'b')[]

If the inference wasn't a literal type, the inferred type is the common supertype, string.

const fn = <T extends string>(arr:T[]) => arr
fn(['a' as string, "b"]) // widened to common supertype `string` 

Generally speaking, TS is going to infer the "best" type for a given input. This function so happens to pass string literals (as an immutable expression) to identity, whose return type is T[] whose T is constrained by string. If it can preserve literals it will.

You can see there is an ocean of scenarios (pasted from that PR) where inference can preserve literals, and where it can't.

I don't have an answer about which combination of inference rules specifically enable this behavior, but generally, typescript is gonna typescript, and generally try to produce the output we want.

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

Comments

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.