Generics

Write one function that keeps the caller’s type instead of throwing it away.

07of 14
≈ 13Min read
8Parts

The problem generics solve

T is a parameter for a type: the caller supplies it, usually without noticing, and the compiler carries it through. Without it you are choosing between a function that works on one type and a function that checks nothing.

the type goes in and comes back out
function first(list: any[]): any { return list[0]; }
const door = first(doors);        // any — everything after this is unchecked

function first<T>(list: T[]): T | undefined { return list[0]; }
const door = first(doors);        // Door | undefined

Inference does the work

you rarely write the type argument
const names = first(["a", "b"]);      // T is string
const nums = first([1, 2]);            // T is number
const doors = first<Door>([]);         // and you can say so when inference cannot

Constraints

extends here means "at least"
function byId<T extends { id: string }>(list: T[], id: string): T | undefined {
  return list.find((item) => item.id === id);
}

byId(doors, "a");     // Door | undefined — the full type, not { id: string }
byId([1, 2], "a");    // Error: number does not have an id
Constrain to what you use, and no more

The function above only reads id, so that is all it asks for. It then works for doors, orders, users — anything with an id.

keyof, and a typed property getter

keyof Door is the union "id" | "name" | "days" | "price", and T[K] is the type of that property. This is where generics stop being a syntax and start being useful.

two type parameters, one of them a key
function pluck<T, K extends keyof T>(items: T[], key: K): T[K][] {
  return items.map((item) => item[key]);
}

pluck(doors, "name");    // string[]
pluck(doors, "price");   // number[]
pluck(doors, "nope");    // Error: "nope" is not a key of Door

Defaults

a type argument with a fallback
interface Result<T = unknown> {
  status: number;
  data: T;
}

const a: Result = …;          // data: unknown
const b: Result<Door[]> = …;  // data: Door[]

Generic types, not only functions

the same idea in a type or a class
type Box<T> = { value: T; at: Date };

class Store<T extends { id: string }> {
  #items: T[] = [];
  add(item: T): this { this.#items.push(item); return this; }
  byId(id: string): T | undefined { return this.#items.find((i) => i.id === id); }
}

const doors = new Store<Door>();

Where it goes wrong

a type parameter used once is not a type parameter
// pointless: T appears once, so nothing is connected
function log<T>(value: T): void { console.log(value); }
function log(value: unknown): void { … }        // say what you mean

// and the classic mistake
function wrap<T>(value: T): T[] { return [value]; }   // good: in and out
function wrap<T>(value: any): T[] { … }               // bad: T is a lie the caller tells
A generic is a relationship

It earns its place when it ties two things together: an argument to a return, or one argument to another. If it appears only once in the signature, replace it with unknown.

Reading a generic signature

Long generic errors read the same way: find the type parameters first, then what each was inferred as, and the mismatch is usually in the second line from the bottom.

from the inside out
function groupBy<T, K extends keyof T>(items: T[], key: K): Map<T[K], T[]>;
//                ↑ the item   ↑ one of its keys      ↑ keyed by that property's type

Practice