Generics — TypeScript · Foundations

Write code once for every type it can hold, without giving up the checks: generic functions whose output type follows their input, keyof and indexed access types, and a generic repository constrained to things with an id, the shape of every repository in NestJS.

What you will learn

Read the theory for Generics

All TypeScript lessons

All Foundations courses

loading types…

What you'll learn

  • Write generic functions whose return type follows from their arguments
  • Constrain a type parameter with extends, and use keyof and T[K] to type a key and its value
  • Write a generic class and use it for different types

Generics

A function that returns the first item of a list works the same for numbers, cats and owners. Written with any, it works for all of them and checks none: first(cats).nmae compiles, and fails at runtime. Written once per type, it is three copies of the same code. Generics are the third way: code written once, with a type left open as a parameter, filled in wherever it is used. Every repository in NestJS is Repository<Cat> or Repository<Owner>, one class with its item type as a parameter, and that is where this lesson ends up.

Type parameters

A type parameter is declared in angle brackets and then used like any other type:

function last<T>(items: readonly T[]): T | undefined {
  return items[items.length - 1];
}

last([1, 2, 3]);     // T is number, so the result is number | undefined
last(['a', 'b']);    // T is string

The caller rarely writes T: TypeScript infers it from the arguments, and the return type follows. That is the whole point: the output's type is tied to the input's, where any would cut the tie.

Constraints

Sometimes the code needs something from T. A function that reads .id cannot accept every type, so the parameter is constrained with extends:

function ids<T extends { id: number }>(items: readonly T[]): number[] {
  return items.map((item) => item.id);
}

Any type with a numeric id fits; ids(['a']) is a compile error at the call.

keyof and indexed access

keyof T is the union of T's property names, and T[K] is the type of the property named K:

type Owner = { id: number; email: string };
type OwnerKey = keyof Owner;       // 'id' | 'email'
type Email = Owner['email'];       // string

Together they type a function that reads a property by name, with the key checked and the value's type known:

function sortBy<T, K extends keyof T>(items: readonly T[], key: K): T[] {
  return [...items].sort((a, b) => (a[key] < b[key] ? -1 : a[key] > b[key] ? 1 : 0));
}

sortBy(owners, 'email');   // fine
sortBy(owners, 'mail');    // error: 'mail' is not a key of Owner

K extends keyof T reads as "K is one of T's keys". Because K is its own parameter, the compiler knows which key was passed, not just "some key", so a second argument can be typed T[K], the value at exactly that key.

Generic classes

A class can have type parameters too, available to all its members:

class Cache<K, V> {
  private readonly entries = new Map<K, V>();
  set(key: K, value: V): void { this.entries.set(key, value); }
  get(key: K): V | undefined { return this.entries.get(key); }
}

const sessions = new Cache<string, { userId: number }>();

Each new Cache<…> is its own instance with its own types; nothing leaks between them. A Map is a good store for items keyed by id: lookups are direct, set on an existing key replaces the value, and iteration follows the order keys were first inserted.

Checking generic types from a test

A generic function's type depends on how it is called, so the spec checks it at a particular instantiation. typeof first<number> is first with T fixed to number, and ReturnType of that must be exactly number | undefined. A version that returns any or unknown fails that check, even if every runtime test passes.

Your task

The work is in repository.ts.

  1. first(items) is generic: it returns the first item, typed as the list's item type or undefined.
  2. pluck(items, key) is generic over the item type and one of its keys: pluck(cats, 'name') is a string[], and a key the items lack is a compile error.
  3. Repository takes a type parameter that must have a numeric id. save(item) stores it under its id, replacing any item with the same id, and returns it; findById(id) returns the item or undefined; findBy(key, value) returns every item whose key has that value, with the value typed by the key; all() returns every item in the order it was first saved.

When it fails

  • Type 'false' does not satisfy the constraint 'true' on FirstOfNumbers: first returns unknown or any rather than T | undefined.
  • Type 'Repository' is not generic: the class has no type parameter yet.
  • Unused '@ts-expect-error' directive beside findBy('age', '3'): the value is typed unknown, so anything is accepted. Type it T[K] with K extends keyof T.
  • "replaces an item saved again" finds four items: the repository stores a list and appends. Key the items by id.

Remember

  • A type parameter is a type left open, filled in (usually inferred) where the code is used.
  • extends constrains what a type parameter may be.
  • keyof T is T's keys; T[K] is the type at key K.
  • A generic class carries its parameter to every member, and each instance has its own.
Stuck? Show a hint

A type parameter goes in angle brackets after the name: function first<T>(items: readonly T[]): T | undefined. For a key of T, add a second parameter constrained to keyof T, and the value at that key has the type T[K]. class Repository<T extends Entity> makes every T have an id; a Map<number, T> keeps the items by id, in the order they were first set.