Lesson 14 · Module 2.2 — Generics

Constraints: how <T extends string> preserves the literal

People often call this “the magic.” Pass "click" to a plain (type: string) parameter and TypeScript widens it to string — the exact word is lost. Add a generic with a constraint, <T extends string>, and TS keeps the literal "click". Reading the constraint tells you which one you’ll get.

The one idea

extends in <T extends string> is not inheritance. It is a bound: “whatever fills T must be assignable to string.” And asking TS to find a T makes it pick the most specific type that fits — which is the literal, not the wide base type.

The widening you’re trying to stop

Without a generic, the parameter’s declared type swallows the literal:

function test2(value: string): string { return value; }
const r2 = test2("hello");  // r2: string  ← widened, "hello" forgotten

With a constrained generic, TS infers the narrowest type that still satisfies the bound — the literal itself:

function test1<T extends string>(value: T): T { return value; }
const r1 = test1("hello");  // r1: "hello"  ← literal preserved

The reasoning, distilled: TS sees the literal "hello", asks “what should T be?”, and answers “the most specific type that extends string” — which is "hello". The extends string bound is what tells it not to widen.

The catch to watch for: constrain the element, not the array

This is the part that surprises readers. Where the constraint sits — on the element type or on the whole array — flips the result:

// Version 1: constraint on the ELEMENT → literals preserved
const makeStatus = <T extends string>(statuses: T[]) => statuses;
const s1 = makeStatus(["INFO", "DEBUG", "ERROR"]);
// s1: ("INFO" | "DEBUG" | "ERROR")[]   ← literal union kept

// Version 2: constraint on the whole ARRAY → elements widen
const makeStatus1 = <T extends string[]>(statuses: T) => statuses;
const s2 = makeStatus1(["INFO", "DEBUG", "ERROR"]);
// s2: string[]   ← T inferred as string[]; each literal lost

The key insight is what T actually stands for in each version — and that changes how TypeScript solves it:

<T extends string>
(statuses: T[])
<T extends string[]>
(statuses: T)
T stands forthe element type (one item)the whole array type
so TS matches the argument…item by item against T → candidates "INFO", "DEBUG", "ERROR" → their unionas one value against T → the array’s default type, string[]
literals kept?yes — constraint is a primitiveno — constraint is an array
result("INFO" | "DEBUG" | "ERROR")[]string[]

Why does the constraint’s shape decide it? TypeScript has one specific rule: when a type parameter is constrained to a primitivestring, number, boolean — and is matched against a literal, it keeps the literal type instead of widening. Version 1 hits that rule exactly: T extends string, and each element ("INFO"…) is matched straight against T, so the literals survive and merge into a union. Version 2 never triggers it: T extends string[] is constrained to an array, not a primitive, so T is matched against the whole array literal at once — and a plain array literal’s default type is the widened string[] (the same widening from Lesson 1.2: a mutable array can be reassigned, so TS broadens its elements to string).

So it isn’t “element vs container” by magic. Constraining the element makes T a primitive-constrained parameter — which switches on literal inference; constraining the array makes T an array-constrained parameter — which doesn’t.

The other lever: as const

When you can’t change the function’s constraint, you can pin the value at the call site instead. as const tells TS “treat this exactly as written, deeply readonly” — so the literals survive even through the widening version:

const s2 = makeStatus1(["INFO", "DEBUG", "ERROR"] as const);
// s2: readonly ["INFO", "DEBUG", "ERROR"]   ← literals + tuple shape kept

So two routes to the same goal: an extends constraint on the element type (author-side), or as const on the value (caller-side).

Reading trap

Seeing <T extends string[]> and expecting a literal union is the classic miss. The bound being an array type is the tell that elements will widen. Look at whether the thing after extends is the element (string) or the array (string[]).

Check yourself

Recall, don’t re-read. (Answers reveal on click.)

Calling test1<T extends string>(value: T) with "hello" gives a result of type…
Which constraint preserves the literal union when you pass an array of strings?
When you can’t change the function, what keeps literals through a widening signature?

Primary source

The official page is TypeScript Handbook — Generic Constraints; for widening vs literals see Everyday Types — Literal Types. Best way to feel it: paste both makeStatus versions into the TS Playground and hover s1 vs s2.

Try it yourself

Got a generic that returns string when you wanted a literal? Ask yourself “element or array constraint?” Next lesson (2.3) builds on this: how TS actually infers T from the argument you pass.