InteractiveFrameworks

Generics

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'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

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.