Section 2 · Generics in Depth
Generics as Relationships
18 min4 min readCode playground
Lesson at a glance
Write generic functions whose type parameters link inputs to outputs, constrain them with extends, and recognise generics that are really unchecked casts.
- 1A type parameter is a relationship
- 2The appears-twice rule
- 3The caller chooses T
- 4Constraints that use other parameters
- 5Generic or plain union?
No video here — just the real thing: a written lesson, runnable examples, a quiz and practice.
Here is a helper from Cartwheel’s order history page. It is typed, it compiles, and it quietly throws information away:
type DetailedOrder = { id: number; totalCents: number; customer: string };
function sortByTotal(orders: { totalCents: number }[]): { totalCents: number }[] {
return [...orders].sort((a, b) => a.totalCents - b.totalCents);
}
declare const orders: DetailedOrder[];
const sorted = sortByTotal(orders);
sorted[0].customer;
// Error: Property 'customer' does not exist on type '{ totalCents: number; }'.The objects in sorted are still full orders at runtime. But the function’s signature can only describe its output in terms of its input annotation, and that annotation only mentions totalCents. The fix is not a wider annotation. It is a relationship: “whatever kind of object you give me, you get the same kind back”.
A type parameter is a relationship
That relationship is what a generic says:
function sortByTotal<T extends { totalCents: number }>(orders: T[]): T[] {
return [...orders].sort((a, b) => a.totalCents - b.totalCents);
}
const sorted = sortByTotal(orders); // DetailedOrder[]
sorted[0].customer; // stringRead the signature aloud: “for some type T that has a numeric totalCents, take an array of T and return an array of T”. The caller does not write <DetailedOrder>; TypeScript infers T from the argument (the next lesson is about exactly how).
Two parts are doing the work. T appears in the parameter and in the return type, which links them. And extends { totalCents: number } is a constraint: it limits which types T can be, which is what makes a.totalCents legal inside the function. Without it, T could be anything, and TypeScript would reject the property access.
The appears-twice rule
The most useful test for a generic, and the one I apply in every code review: each type parameter should appear at least twice. Once to capture a type, once to use it somewhere else.
function lastItem<T>(items: T[]): T | undefined { // input → output
return items[items.length - 1];
}
function indexById<T extends { id: number }>(items: T[]): Map<number, T> {
return new Map(items.map((item) => [item.id, item]));
}
function merge<A, B>(a: A, b: B): A & B { // two inputs → output
return { ...a, ...b };
}A parameter that appears once links nothing. function log<T>(value: T): void is just function log(value: unknown): void with extra syntax, and function count<T>(items: T[]): number is items: unknown[]. Remove those type parameters; the signatures get easier to read and nothing is lost.
The caller chooses T
This error confuses almost everyone the first time:
type Shippable = { weightGrams: number };
function withDefaultWeight<T extends Shippable>(): T {
return { weightGrams: 500 };
// Error: Type '{ weightGrams: number; }' is not assignable to type 'T'.
// '{ weightGrams: number; }' is assignable to the constraint of type 'T', but 'T'
// could be instantiated with a different subtype of constraint 'Shippable'.
}The constraint says T is at least Shippable. The caller might ask for withDefaultWeight<{ weightGrams: number; sku: string }>(), and your literal has no sku. Inside a generic function, T is a type you do not get to choose; you can only produce a T from values that already are one, such as the arguments. If your function builds the value itself, its return type is the constraint (Shippable), and no generic is needed.
Constraints that use other parameters
A constraint can refer to another type parameter. The classic case picks a property by name:
function pluck<T, K extends keyof T>(items: T[], key: K): T[K][] {
return items.map((item) => item[key]);
}
const customers = pluck(orders, 'customer'); // string[]
pluck(orders, 'email');
// Error: Argument of type '"email"' is not assignable to parameter of type 'keyof DetailedOrder'.keyof T is the union of T’s property names, and T[K] is the type of the property K. You will take both apart in section 3; for now, notice the pattern. K appears in the parameter and in the return type, and its constraint ties it to T. Each parameter appears at least twice, and each one earns its place.
Generic or plain union?
Not every function that accepts several types needs a type parameter. Ask one question: does the output type depend on which input type the caller passed?
// Output is always a number, whatever came in: a union is enough.
function toCents(amount: number | string): number {
return Math.round(Number(amount) * 100);
}
// Output depends on the input: a generic says so.
function firstOrThrow<T>(items: T[]): T {
if (items.length === 0) throw new Error('Expected at least one item');
return items[0];
}toCents returns number no matter what, so making it toCents<T extends number | string>(amount: T): number would add a parameter that appears once and links nothing. firstOrThrow is the opposite: a list of orders must give back an order, and a list of products a product. A union signature (items: unknown[]): unknown) would force every caller to cast.
When you are unsure, start without the generic. If callers start writing as on the result, that is the signal the output depends on the input, and a type parameter will remove those casts.
In the exercise you will turn four lossy helpers into generic ones that keep the caller’s types. Next lesson looks at how TypeScript decides what T is when the caller does not say.
Key takeaways
- A type parameter earns its place when it appears at least twice, linking an input to an output or one input to another.
T extends { totalCents: number }lets the function usetotalCentswhile still returning the caller’s full, more specific type.- A generic that appears only in the return type, like
parse<T>(text): T, is an unchecked cast with nicer syntax. - The caller chooses
T, not the function, which is why you cannot return a literal object whereTis expected.