Generic Functions & Constraints
A type parameter is inferred, not usually written
Section titled “A type parameter is inferred, not usually written”Most of the time you never write the type argument — inference fills it in from the value you pass:
function first<T>(arr: T[]): T | undefined { return arr[0];}
const a = first([1, 2, 3]);// ^? number | undefined — T inferred as numberconst b = first(["x", "y"]);// ^? string | undefined — T inferred as stringYou can pass it explicitly (first<number>([1, 2, 3])), but you rarely need to. The type parameter flows from the call site.
Constraints: extends limits what T can be
Section titled “Constraints: extends limits what T can be”An unconstrained T can be anything, so you can’t assume it has any properties. When your function needs T to have some shape, constrain it with extends:
// @expect-error TS2339 TS2345// ❌ without a constraint, T might not have .lengthfunction longestLoose<T>(a: T, b: T) { return a.length > b.length ? a : b; // Error: Property 'length' does not exist on type 'T'}
// ✅ constrain T to things that have a numeric lengthfunction longest<T extends { length: number }>(a: T, b: T): T { return a.length >= b.length ? a : b;}
longest([1, 2], [1, 2, 3]); // ✅ arrays have length → returns number[]longest("ab", "abc"); // ✅ strings have length → returns stringlongest(1, 2); // ❌ number has no lengthRead T extends { length: number } as “T must be assignable to { length: number }” — i.e. T is some subtype that has at least a numeric length. Crucially, the return type is still T, so you keep the specific type (number[], string) rather than widening to the constraint.
keyof constraints: a safe property getter
Section titled “keyof constraints: a safe property getter”A constraint can reference another type parameter. The classic example is a type-safe get:
// @expect-error TS2345function getProp<T, K extends keyof T>(obj: T, key: K): T[K] { return obj[key];}
const user = { name: "Ada", age: 36 };const label = getProp(user, "name");// ^? stringconst age = getProp(user, "age");// ^? numbergetProp(user, "email"); // ❌ "email" is not a key of userK extends keyof T says the key must be one of T’s actual keys, and the return type T[K] is the type of that specific property. This is the backbone of typed object utilities.
Default type parameters
Section titled “Default type parameters”Like default function arguments, a type parameter can have a default that’s used when it can’t be inferred and isn’t supplied:
interface Container<T = string> { value: T;}
const c1: Container = { value: "hi" }; // T defaults to stringconst c2: Container<number> = { value: 1 }; // T explicitly numberDefaults are common on generic classes and interfaces where a sensible fallback exists.
Don’t over-generalize — but “used once” needs a nuance
Section titled “Don’t over-generalize — but “used once” needs a nuance”Generics are a tool, not a goal. A type parameter that appears only once in the entire signature, and nowhere else is usually a mistake — it’s not linking anything, so it might as well be a concrete type or unknown:
// pointless generic: T is used once, links nothingfunction logLoose<T>(x: T): void { console.log(x); }// just write:function log(x: unknown): void { console.log(x); }But “used once” needs to be read carefully. The rule isn’t “counted in the parameter list” — it’s “does this type parameter connect an input to an output at all?” A T that appears exactly once among the parameters, but also shows up in the return type, is still linking two positions, and that makes it a real generic, not a smell:
// @expect-error TS2339interface Tagged<T> { value: T }
// T appears once in the parameter list — but it ALSO appears in the return// type, tying the argument to the result. That IS a link, so this is fine.function tag<T>(value: T): Tagged<T> { return { value };}
const t = tag(42);// ^? Tagged<number>t.value.toUpperCase(); // ❌ number has no toUpperCase — the link still catches real bugsCompare the two: logLoose<T>(x: T): void has T in exactly one position, period — the return type is void, so nothing downstream is ever checked against it. tag<T>(value: T): Tagged<T> also has T written once among the parameters, but T reappears in the return type, so a caller who does something wrong with t.value still gets caught. Count links, not raw appearances.
const type parameters (5.0)
Section titled “const type parameters (5.0)”Inference through a generic call widens literals the same way let does: first(["a", "b"]) infers T as string, not "a" | "b". Callers used to fix that with as const at every call site; a const modifier on the type parameter asks for literal inference by default:
function tuple<const T extends readonly unknown[]>(...items: T): T { return items;}
const t = tuple("a", 1, true);// ^? readonly ["a", 1, true] — without const: [string, number, boolean], same tuple shape, literals widenedThe constraint usually needs readonly (the inferred literal is a readonly tuple or object). Use it for APIs that key off literal values — route tables, enum-like configs, builders — and leave ordinary generics alone.
const T still widens — it isn’t as const retroactively
Section titled “const T still widens — it isn’t as const retroactively”const on a type parameter only changes how that call’s own arguments get inferred. It cannot reach back and un-widen a value that was already widened somewhere else before it arrived:
function firstOf<const T>(items: T[]): T { return items[0];}
const arr = ["a", "b"]; // arr is already string[] — an ordinary mutable array, not a tupleconst f = firstOf(arr);// ^? string — NOT "a" | "b": arr was widened at its own declaration, long before firstOf saw itconst on tuple’s T worked because the rest parameter (...items: T) captures each argument as its own literal at the call site, before anything widens it. firstOf’s T is inferred from arr’s element type, and arr was already string[] by the time it got there — there is no literal left for const to preserve. const T preserves literals it can see arriving; it doesn’t reverse widening that already happened upstream.
Overload vs generic
Section titled “Overload vs generic”Both let one name serve several call shapes, but they solve different problems. A generic is right when the same formula applies uniformly across every type — T in, T (or something built from T) out. An overload set is right when you have a small, fixed number of specific (input, output) pairings that don’t reduce to one formula.
Overloads can also produce a clearer error than a poorly-chosen generic. Compare two versions of a function that must receive two arguments of the same type:
// @expect-error TS2345function both<T>(a: T, b: T): T { return a; }
const y = both(1, "two"); // the first argument fixes T to the literal 1, not number// @expect-error TS2769function bothOverload(a: string, b: string): string;function bothOverload(a: number, b: number): number;function bothOverload(a: unknown, b: unknown): unknown { return a; }
bothOverload(1, "two");A generic can give a worse error than an overload
// @expect-error TS2345 TS2769function both<T>(a: T, b: T): T { return a; }const y = both(1, "two");// TS2345: Argument of type '"two"' is not assignable to parameter of type '1'.// -- confusing: "type '1'"? T was inferred from the literal 1, not widened to number,// because nothing in this signature asks for the number the literal belongs to.
function bothOverload(a: string, b: string): string;function bothOverload(a: number, b: number): number;function bothOverload(a: unknown, b: unknown): unknown { return a; }bothOverload(1, "two");// TS2769: No overload matches this call. The last overload gave the following error.// Argument of type 'string' is not assignable to parameter of type 'number'.// -- clearer: it names the actual types, string and number, not a literal. Click Check types above: both’s error reads Argument of type '"two"' is not assignable to parameter of type '1' — a reader has to stop and wonder why a parameter’s type is the literal 1 rather than number. It happens because T was pinned to the literal type of the first argument before the second was even checked. bothOverload’s error, No overload matches this call. ... Argument of type 'string' is not assignable to parameter of type 'number', names the real types involved. Neither message is wrong, but the overload’s is easier for a reader to act on immediately — this is the case the “generics are always better than overloads” instinct gets backwards.
The reverse is common too: once you have more than two or three pairings, or the relationship between input and output is uniform (an array in, an array of the same element type out), an overload set turns into unmaintainable repetition and a generic is clearly right — every function you’ve seen so far in this lesson (first, longest, getProp) is exactly that case.
Mental Model
Section titled “Mental Model”A type parameter is a variable whose “value” is a type, solved once per call from the positions it’s written into. Ask two questions about any T you read: where does it come from (which argument position feeds it), and where does it go (which other positions — parameters, return type — does it constrain once it’s fixed)? If the answer to “where does it go” is “nowhere else,” the type parameter is decorative. If a constraint doesn’t actually get used inside the body, it’s also worth asking whether the constraint is doing anything beyond documentation.
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
Unconstrained T | Works for literally any type | Can’t assume any shape — no property access, no arithmetic |
T extends { shape } | Lets the body use the shape while keeping the specific type in the return | Every caller’s type must satisfy the constraint, which can reject otherwise-valid values that just don’t have that exact shape |
const T | Preserves literal/tuple shape without as const at every call site | Only helps when the literal is still visible to that call — can’t undo widening that already happened |
| Overload set | Clear, per-case error messages; documents exact valid pairings | Doesn’t scale — each new valid combination is a new signature to write and maintain |
Common Misconceptions
Section titled “Common Misconceptions”- A type parameter used once in the parameter list is always a smell. Only if it’s used once in the whole signature. If it also appears in the return type, it’s linking input to output — that’s a real generic.
- A generic function’s error messages are always clearer than an overload’s. Not necessarily — a generic can pin a type parameter to a narrow literal type early, producing a confusing “not assignable to type ‘1’“-style message that an equivalent overload set would state in plain, named types.
conston a type parameter reaches back and un-widens whatever you pass, no matter how it got there. It only captures literals that are still literals at the call site. A value already widened by its ownletdeclaration, or read out of an already-widened array, arrives at the generic call pre-widened, andconsthas nothing left to preserve.
Reading AI-generated code
Section titled “Reading AI-generated code”A generic that exists purely to wrap a cast is a common AI-generated shape — it looks like type safety, but the type parameter is unconstrained and never checked against anything real:
- Look for a generic helper whose body is
return value as T(oras unknown as T) withTunconstrained. This compiles for anyTthe caller names, exactly like a plainascast — the generic adds nothing but the appearance of having “typed” the value. - Check whether the function ever narrows, validates, or otherwise inspects the value before returning it as
T. If it doesn’t, the function’s real type is(value: unknown) => any, no matter what its signature claims. - A chain of two such helpers (one parses to
unknown, the next castsunknowntoT) has the exact same problem as a single cast, just spread across two calls that each look individually reasonable.
🐛 Spot the bug
interface Config { retries: number; timeout: number }
function assertType<T>(value: unknown): T {return value as T;}
function loadConfig(raw: unknown): Config {return assertType<Config>(raw);}
const cfg = loadConfig({ retries: 3 });console.log(cfg.timeout.toFixed(0));Show the answer
This compiles with zero errors. assertType<T> is unconstrained and its body is a bare value as T — it will “successfully” claim any shape for any input, because as from unknown to a concrete type is always allowed with no runtime check attached. loadConfig({ retries: 3 }) has no timeout field at all, but assertType<Config>(raw) asserts it into a Config anyway, so loadConfig’s return type lies about what it actually validated. Calling cfg.timeout.toFixed(0) then crashes with Cannot read properties of undefined (reading 'toFixed'), because cfg.timeout really is undefined at runtime — the generic never checked. The fix is the same theme as the module’s index lesson: a generic type parameter is only as trustworthy as an actual runtime check somewhere in the chain — replace the cast with real validation (a schema library, or explicit field checks) before trusting the shape.