Lesson 14 · Module 2.2 — Generics
<T extends string> preserves the literalPeople 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.
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.
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.
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 for | the element type (one item) | the whole array type |
| so TS matches the argument… | item by item against T → candidates "INFO", "DEBUG", "ERROR" → their union | as one value against T → the array’s default type, string[] |
| literals kept? | yes — constraint is a primitive | no — 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 primitive — string, 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.
as constWhen 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).
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[]).
Recall, don’t re-read. (Answers reveal on click.)
test1<T extends string>(value: T) with "hello" gives a result of type…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.
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.