Generics
The problem generics solve
Section titled “The problem generics solve”Suppose you want a function that returns whatever you give it. Without generics you face a bad choice:
// Option A: one type only — not reusablefunction identityStr(x: string): string { return x; }
// Option B: any — reusable, but throws away all type informationfunction identityAny(x: any): any { return x; }
const a = identityAny(42);a.toUpperCase(); // 💥 no error at compile time — any hides the bugany makes the function reusable but blinds the compiler: the link between the input type and the output type is gone. Generics keep that link.
// @expect-error TS2339function identity<T>(x: T): T { return x; }
const n = identity(42); // T inferred as number → n: numberconst s = identity("hi"); // T inferred as string → s: stringn.toUpperCase(); // ❌ Error: number has no toUpperCase — the bug is caughtT is a type parameter — a placeholder the caller (or inference) fills in. The function works for every type, and the return type still tracks the argument type exactly.
What generics really are
Section titled “What generics really are”A generic is a function at the type level: it takes types as input and produces types as output. identity<T> says “for any type T, take a T and return a T.” This is called parametric polymorphism — one implementation, parameterized over types.
flowchart LR impl["one generic identity of T"] --> callN["called with number returns number"] impl --> callS["called with string returns string"] impl --> callU["called with User returns User"]
What this module covers
Section titled “What this module covers”| Lesson | What you’ll learn |
|---|---|
| Functions & constraints | Type parameters, extends constraints, default type parameters |
| Classes & interfaces | Generic classes, interfaces, per-method type parameters, and variance |
| Conditional types | T extends U ? X : Y and distribution over unions |
Inference & infer | How type arguments are inferred, and extracting types with infer |
| Variance | in/out annotations, Promise/Map/Set/Array variance, and the AI-code smells variance produces |
The link only exists if the implementation keeps it
Section titled “The link only exists if the implementation keeps it”Everything above sells generics on one promise: identity<T> returns exactly the type it received, unlike identityAny. That promise is a property of the signature, and the compiler enforces it at every call site — but nothing forces the implementation to actually honor it internally. A “generic” that appears in the signature purely to look safe, while the body quietly breaks the link with a cast, is no safer than identityAny was:
interface User { id: string; name: string }
// T appears ONLY in the return position — nothing links it to any argument,// so it can never be inferred from a call. This "generic" is really `as T`// with extra steps: the caller supplies T by hand, and the compiler trusts it.function parseAs<T>(json: string): T { return JSON.parse(json);}
const user = parseAs<User>('{"id":"1"}'); // no "name" field at all — still compilesconsole.log(user.name.toUpperCase());// @runinterface User { id: string; name: string }
function parseAs<T>(json: string): T { return JSON.parse(json);}
const user = parseAs<User>('{"id":"1"}');console.log(user.name.toUpperCase());This compiles with zero errors — JSON.parse returns any, and any is assignable to anything, including the caller’s chosen T. Running it crashes:
TypeError: Cannot read properties of undefined (reading 'toUpperCase')Compare that with a generic that keeps its promise, because its type parameter genuinely connects an argument to the result:
A real link between input and output
interface Box<T> { value: T }
// T appears once in the parameter list — but it ALSO appears in the return// type, linking the argument to the result. That is a real generic link,// not a decoration, even though T shows up only once among the parameters.function box<T>(value: T): Box<T> { return { value };}
const b = box("hi");// ^? Box<string>console.log(b.value.toUpperCase()); // ✅ still string — the link survived The difference is not how many times T is written — it is whether T actually ties something the caller supplies to something the caller gets back. box’s T does that. parseAs’s T does not: it is a caller-supplied label glued onto a value the function never inspected, which is exactly what a cast is. The rest of this module is about building generics whose type parameters genuinely link positions — and about the two lessons ahead (classes, and conditional types + inference) that show exactly how far that link can be trusted.
Mental Model
Section titled “Mental Model”flowchart TD sig["Signature: identity of T takes T returns T"] --> promise["Promise: whatever comes in, comes back out unchanged"] promise --> kept["Implementation actually uses the argument (box, identity, first)"] --> real["Link is real — compiler-checked at every call"] promise --> broken["Implementation ignores the argument, casts, or parses untyped data (parseAs, JSON.parse as T)"] --> fake["Link is fake — caller's T is trusted, never verified"]
A generic signature is a contract, but TypeScript only checks that the contract’s shape is consistent — that every place T appears agrees with every other place. It does not, and cannot, check that the function’s body actually derives the output from the input the way the signature implies. identity<T>(x: T): T { return x; } keeps the promise because it is trivially forced to: there is nothing else to return. The moment a function’s body can produce a T-shaped value without deriving it from an argument — parsing JSON, reading any, casting — the signature becomes a claim floated on trust, identical in kind to the custom type guards from the narrowing lesson that the compiler trusts without checking their bodies.
Trade-offs
Section titled “Trade-offs”| Approach | Benefit | Cost |
|---|---|---|
any | Simplest to write, accepts everything | Throws away the input↔output link entirely; every use is silently unchecked |
Generic (T linking real positions) | Compiler enforces the link at every call site, for every concrete type | Requires designing a signature where T is actually inferable, not just declared |
Generic used as a disguised cast (T only in output) | Looks exactly like a safe generic in the signature | Cannot be inferred, must be supplied by the caller, and is only as trustworthy as whatever the caller claims |
| Overload set | Documents a small, fixed set of exact input→output pairings individually | Doesn’t scale past a handful of cases; see Generic Functions & Constraints for a case where it beats a generic |
Common Misconceptions
Section titled “Common Misconceptions”- Any function with
<T>in its signature is automatically type-safe. Only ifTis actually derived from something the caller passes in. ATthat appears only in the return position is filled in by the caller’s own annotation, with nothing to check it against — no different from anascast. - A generic function is always better than one typed with
any. Only if the generic’s type parameter genuinely links a position the caller controls to a position the caller reads back. Otherwise it’sanywearing a safer-looking signature. - Generics exist to make code “more type-safe” in the abstract. They exist for one specific reason: to preserve a link between input and output types that a single concrete type or
anywould lose. If there’s no link to preserve, a generic adds ceremony without adding safety.
Reading AI-generated code
Section titled “Reading AI-generated code”Before trusting a generic function in generated code, find every position T (or any other type parameter) appears in, and ask whether at least one of those positions is a parameter the compiler can actually inspect at the call site:
- A type parameter that appears only in the return type (
function parseAs<T>(json: string): T) can never be inferred — it is supplied by the caller and trusted, exactly like a cast. This is one of the most common shapes in AI-generated data-fetching and parsing code. - A type parameter that appears in a parameter whose value is itself untyped or
any(the result ofJSON.parse,fetch().json(), a third-party callback typedany) gives you the appearance of a link with none of the safety — the “input” side was never actually checked either. - A generic wrapper that immediately casts its own parameter to
anyorunknowninternally, then casts the result back toT, has the same problem asparseAs, just spread across two lines instead of one.
🐛 Spot the bug
interface Product { id: string; price: number }
function fetchTyped<T>(url: string): Promise<T> {return fetch(url).then((r) => r.json());}
async function run() {const product = await fetchTyped<Product>("/api/product/1");console.log(product.price.toFixed(2));}Show the answer
This compiles with zero errors and can still crash or silently misbehave at runtime. T appears only in the return position of fetchTyped — it is never derived from url or from anything the function actually inspects. .json() returns Promise<any>, and any is assignable to the caller’s chosen T with no verification at all. If the endpoint’s real response shape doesn’t match Product (a renamed field, a price sent as a string, a wrapped { data: ... } envelope), fetchTyped<Product> still “returns” a Product as far as the type system is concerned, and product.price.toFixed(2) either crashes or silently does the wrong thing depending on what actually came back. The fix is the same one from the Typing APIs & Boundaries lesson: validate the shape at the boundary (a schema library, or at minimum a runtime check) instead of asserting it through a generic type parameter that nothing ever checks against reality.