skip to content

In TypeScript, `declare function box<T>(x: T): { value: T }` called as `box("red")` produces `{ value: string }` rather than `{ value: "red" }`. Why does the literal widen, and how would you change the signature to keep it?

level: middleimportance: should knowfreq 50%

answer

  1. a candidate, then a decision
  2. freshness is lost on the way out
  3. the constraint tells the checker what you want
  4. T versus T extends string
  5. const modifier, since 5.0

basics

~20 s

TypeScript infers the fresh literal type "red" and then widens it to string, because nothing in the signature asks for a literal. Constraining the type parameter with T extends string, or marking it const, preserves the literal.

solid answer

~50 s

When TypeScript infers a type argument from a literal expression, the candidate is a *fresh* literal type — `"red"` — and freshness is lost the moment the type lands somewhere with no reason to stay narrow. With an unconstrained `<T>` the candidate widens to its base primitive, so `T` becomes `string` and the call yields `{ value: string }`. The signature-level fix is to tell the checker a literal is wanted: `declare function box<T extends string>(x: T): { value: T }` infers `T = "red"`, because a type parameter constrained to a primitive keeps literal candidates. The same rule applies through arrays — `declare function pick<T extends string>(x: T[]): T` on `["a", "b"]` infers `"a" | "b"` instead of `string`. Since TypeScript 5.0 a `const` type parameter (`<const T>`) is the other lever, inferring as if the argument had been written with a const assertion.

go deeper

for a junior

Know that passing a string into a generic function usually gives you string, not the exact literal, and that writing const on the variable does not change that.

for a middle

Explain fresh versus widened literal types and show the signature-level fix: constraining the type parameter to a primitive keeps the literal inference candidate.

for a senior

Be ready to design library signatures so caller literals survive into return types, and to explain why the widened version fails silently with useless autocomplete rather than an error.

for a principal

Own the trade of pushing precision into signatures every consumer depends on: precise literal inference improves ergonomics but grows inferred types, hover output, error text and check time.

## Two kinds of literal type When you write the expression `"red"`, TypeScript does not immediately give it the type `string`. It gives it the literal type `"red"`, but marks that type as **fresh** — the compiler internally calls it a *widening literal type*. Freshness records the fact "this literal came straight out of an expression, and nobody has yet said it must stay narrow". A fresh literal collapses to its base primitive (`"red"` to `string`, `1` to `number`, `true` to `boolean`) as soon as it flows into a position that has no reason to keep it precise: ```ts let a = "red"; // string — a mutable binding has no reason to stay narrow const b = "red"; // "red" — kept, but still a *widening* literal type const c: "red" = "red"; // "red" — the annotation makes it non-fresh ``` This is not a special generics rule; generics simply inherit it. ## What happens during a generic call Checking a generic call has two phases. First the compiler collects **inference candidates** for each type parameter by matching argument types against parameter types. Then it picks a type argument from the candidates. A candidate that came from a fresh literal is widened at that second step unless something in the signature says otherwise. With a bare `<T>`, nothing does: ```ts declare function box<T>(x: T): { value: T }; const r = box("red"); // { value: string } ``` A detail that surprises people: declaring the argument with `const` first does not help. ```ts const color = "red"; // type "red", but a widening literal type const r2 = box(color); // still { value: string } ``` The variable's own type is still the *widening* literal type, so it widens on the way into `T` exactly as the inline literal did. Only a non-fresh literal type — one produced by an annotation, a const assertion, or a constrained inference — survives. ## Fix one: constrain the type parameter If the type parameter is constrained to a primitive type, TypeScript keeps the literal candidate instead of widening it. This is the cheapest and most portable fix, and it works on any modern compiler: ```ts declare function box<T extends string>(x: T): { value: T }; const r3 = box("red"); // { value: "red" } declare function pick<T extends string>(x: T[]): T; const p = pick(["a", "b"]); // "a" | "b" ``` Note *where* the constraint goes. Constraining the parameter to an array type instead of constraining the element type puts you right back where you started, because the inferred `T` is now an array type built from already-widened elements: ```ts declare function pickWrong<T extends string[]>(x: T): T[number]; const q = pickWrong(["a", "b"]); // string ``` The rule of thumb: the type parameter that must stay literal is the one that has to be constrained to the *primitive*. ## Fix two: a const type parameter Since TypeScript 5.0 you can write `<const T>`, which infers the argument as if the caller had written a const assertion. It handles cases the constraint trick cannot — nested object and array structure — and it also adds `readonly`: ```ts declare function conf<const T>(o: T): T; const c = conf({ mode: "dark", n: 1 }); // { readonly mode: "dark"; readonly n: 1 } ``` It carries the same call-site limitation as above: if the caller passes a variable rather than an inline expression, the modifier changes nothing. ## Fix three: push the burden to the caller The caller can always annotate the parameter type or apply a const assertion at the call site. That keeps the signature simple, but every call site has to remember, and forgetting produces no error — just a silently useless type. For a library API that is usually the wrong trade: the signature is written once, the call sites are written thousands of times. ## Why anyone cares This is what separates an API whose literals round-trip from one whose types are noise. Event emitters, route tables, form-field maps, state-machine definitions and `keyof`-driven helpers all depend on the caller's literal surviving inference: if `on("click", h)` infers `string`, the handler parameter cannot be looked up and autocomplete offers nothing. Finally, remember what layer this lives on. Widening, constraints and const type parameters exist only during checking. The emitted JavaScript is identical either way — no literal type is present at runtime, and nothing is frozen or validated.

  • If the caller writes `const color = "red"` before the call, does the literal survive into `T`?
    No. `color` has type `"red"`, but it is still a *widening* literal type, so it widens to `string` on the way into an unconstrained `T`. Only a non-fresh literal — one produced by an annotation like `const color: "red"`, a const assertion, or an inference into a constrained type parameter — survives. This is why the fix belongs in the signature, not in the caller's variable declaration.
  • With `<T extends string>`, what happens if someone passes an ordinary `string` variable?
    `T` is inferred as `string`, which satisfies the constraint, and the call type-checks normally. The constraint does not require a literal; it only stops the compiler from throwing one away. That is what makes it safe to add to an existing signature — precise callers get precise types, and callers passing a plain `string` are unaffected.
  • Does any of this change the emitted JavaScript?
    No. Literal types, widening, constraints and const type parameters are all part of the type layer and are erased during emit. The output for `box("red")` is the same call regardless of which signature you chose. The only thing that changes is what the checker knows, which errors you get, and what editors can offer for autocomplete.

saying these in an interview costs you the question

  • Says TypeScript never infers literal types from arguments
  • Thinks declaring the variable with const stops the widening
  • Believes only the caller can fix it, never the signature
  • Claims an unconstrained T falls back to unknown here
  • Confuses type widening with the runtime value changing

context