Generics

TypeScript · Practical · 14 min

Generics are what make typed collections, promises, and reusable helpers possible; every library API uses them.

Suppose you write function first(items: any[]): any. It works for any array, but the result is any — you've lost the information that a string[] yields a string. Generics fix this with a type parameter, a variable that stands for a type:

function first<T>(items: T[]): T | undefined {
  return items[0];
}
const n = first([1, 2, 3]); // T inferred as number → n: number | undefined

You declare type parameters in angle brackets and use them in the signature. At each call the compiler infers T from the arguments; you can also pass it explicitly — first<string>([]) — which is handy when there's nothing to infer from.

Types can be generic too. You already use them: Array<T>, Promise<T>, Map<K, V>, Set<T>. Define your own with aliases, interfaces and classes:

interface ApiResponse<T> { data: T; status: number }
type Pair<A, B = A> = [A, B];   // B defaults to A

Guidelines for good generic signatures:

  • A type parameter should relate two or more things — an input to an output, or two inputs. If T appears only once, you probably don't need it; use unknown or a concrete type.
  • Use as few type parameters as possible and let inference do the work; callers should rarely need to write <...>.
  • Name them T, K, V, E for simple cases, or descriptive names (TItem) in complex signatures.

Generic arrow functions are written const id = <T,>(x: T) => x; — the trailing comma avoids JSX ambiguity in .tsx files.

Example

function first<T>(items: readonly T[]): T | undefined {
  return items[0];
}

function groupBy<T, K extends PropertyKey>(items: T[], key: (item: T) => K): Record<K, T[]> {
  const out = {} as Record<K, T[]>;
  for (const item of items) {
    (out[key(item)] ??= []).push(item);
  }
  return out;
}
interface ApiResponse<T> { data: T; status: number }
type Pair<A, B = A> = [A, B];
const word = first(["a", "b"]);                     // string | undefined
const byLength = groupBy(["hi", "yo", "hey"], s => s.length);  // Record<number, string[]>
const res: ApiResponse<{ id: number }[]> = { data: [{ id: 1 }], status: 200 };
const range: Pair<number> = [0, 10];
class Stack<T> {
  #items: T[] = [];
  push(item: T): void { this.#items.push(item); }
  pop(): T | undefined { return this.#items.pop(); }
}
const s = new Stack<string>();
s.push("x");
console.log(word, byLength[3], res.data[0]?.id, range, s.pop());

Remember

  • A type parameter <T> links input types to output types
  • Type arguments are usually inferred; pass them explicitly only when needed
  • Interfaces, aliases and classes can be generic too
  • If a type parameter is used only once, you likely don't need it

Watch out for

  • Writing function f<T>(x: T): void where T relates nothing — unknown says the same thing more simply.

Check yourself

  1. Given function wrap<T>(x: T): { value: T }, what is the type of wrap("hi").value?
  2. Which signature makes the best use of a type parameter?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: www.typescriptlang.org/docs/handbook/2/generics.html