EduFlow

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.

  1. 1A type parameter is a relationship
  2. 2The appears-twice rule
  3. 3The caller chooses T
  4. 4Constraints that use other parameters
  5. 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:

TypeScript
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:

TypeScript
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;                 // string

Read 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.

TypeScript
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:

TypeScript
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:

TypeScript
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?

TypeScript
// 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 use totalCents while 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 where T is expected.

Further reading

Check your understanding

3 questions

Question 1 of 3

What is the type of sorted?

TypeScript
type DetailedOrder = { id: number; totalCents: number; customer: string };
declare const orders: DetailedOrder[];
function sortByTotal(xs: { totalCents: number }[]) { return [...xs].sort((a, b) => a.totalCents - b.totalCents); }
const sorted = sortByTotal(orders);
Question 2 of 3

Which of these signatures uses its type parameter in a way that adds real type safety?

Question 3 of 3

Why is this an error?

TypeScript
function emptyCart<T extends { items: string[] }>(): T {
  return { items: [] };
}

Practice

Code playground

Make the helpers keep their types

These four helpers from Cartwheel’s order history page work at runtime but lose type information. Make each one generic so the type tests pass:

  • sortByTotal returns a sorted copy typed as the caller’s element type, and still rejects objects without totalCents.
  • indexById returns a Map from id to the caller’s element type.
  • pluck(items, key) only accepts real property names and returns an array of that property’s type.
  • lastItem returns the last element, typed as the element type or undefined.

Keep the runtime behaviour the same, and do not use any or as.

function sortByTotal(orders: { totalCents: number }[]): { totalCents: number }[] {
  return [...orders].sort((a, b) => a.totalCents - b.totalCents);
}

function indexById(items: { id: number }[]): Map<number, { id: number }> {
  return new Map(items.map((item) => [item.id, item]));
}

function pluck(items: any[], key: string): any[] {
  return items.map((item) => item[key]);
}

function lastItem(items: unknown[]): unknown {
  return items[items.length - 1];
}

// ---- Type tests (do not edit) ----
type DetailedOrder = { id: number; totalCents: number; customer: string };
const orders: DetailedOrder[] = [
  { id: 1003, totalCents: 4500, customer: 'Mona' },
  { id: 1001, totalCents: 18900, customer: 'Karim' },
  { id: 1002, totalCents: 1200, customer: 'Lea' },
];

const sorted = sortByTotal(orders);
const byId = indexById(orders);
const customers = pluck(orders, 'customer');
const last = lastItem(orders);

type T1 = Expect<Equal<typeof sorted, DetailedOrder[]>>;
type T2 = Expect<Equal<typeof byId, Map<number, DetailedOrder>>>;
type T3 = Expect<Equal<typeof customers, string[]>>;
type T4 = Expect<Equal<typeof last, DetailedOrder | undefined>>;

// @ts-expect-error: products have no totalCents
sortByTotal([{ sku: 'TENT-2P' }]);

// @ts-expect-error: 'email' is not a property of DetailedOrder
pluck(orders, 'email');

console.log(sorted.map((o) => o.customer), byId.get(1001)?.customer, customers, last?.id);
Loading the editor…

Ctrl/⌘ + Enter to run · Runs in a sandbox in your browser. Nothing leaves your device.

Console

(no output)

Checks

  • The file type-checks with zero errors, including all type tests
  • sortByTotal sorts ascending without mutating its input
  • indexById maps each id to its item
  • pluck and lastItem still work at runtime