Overview

A generic turns one function into a family of typed functions; the cost is a type parameter, and the win is that callers keep their type information. Add one only when a real shape varies between calls. Read typescript first.

Add a type parameter only when the output depends on the input

T earns its place when it appears in at least two positions, such as a parameter and the return type. If every call site must pass <Foo> explicitly, inference is not working and the generic is a cast in disguise.

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

Constrain with extends

An unconstrained T can be anything. Constrain it to what the body uses; K extends keyof T is the most useful constraint and powers Pick and typed accessors.

function pluck<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key]
}

Steer inference instead of annotating call sites

TypeScript infers type parameters from arguments, so annotate only when it cannot.

  • NoInfer<T> (5.4) blocks a parameter from contributing to inference, so createStore<T>(initial: T, fallback: NoInfer<T>) infers T from initial alone and errors on a mismatched fallback.
  • <const T> (5.0) infers literal types for object and array arguments without the caller writing as const.
  • Give a type parameter a default (<TState = Record<string, unknown>>) when most callers want one shape.

Return Result types at API boundaries

A discriminated Result<T, E> puts failure in the signature, so callers cannot ignore it the way they can ignore a thrown exception. Narrowing on ok is automatic (typescript-narrowing).

type Result<T, E = Error> = { ok: true; value: T } | { ok: false; error: E }
 
async function loadInvoice(id: string): Promise<Result<Invoice, "not-found" | "network">> {
  const res = await fetch(`/api/invoices/${id}`)
  if (res.status === 404) return { ok: false, error: "not-found" }
  if (!res.ok) return { ok: false, error: "network" }
  return { ok: true, value: Invoice.parse(await res.json()) }
}

Use infer when no utility type exists

infer pulls a piece out of a type inside a conditional. ReturnType, Parameters, and Awaited are all built this way, so check typescript-utility-types first.

type ArrayItem<T> = T extends readonly (infer U)[] ? U : never

Keep application generics shallow

Heavy generic types make compiler errors unreadable. Name the intermediate type when an expression needs more than two lines to understand, split a function with four type parameters, and refactor a chain of nested conditional types. Library types can earn complexity; application types rarely do. Abstract over many resources with the same lifecycle only once you have three of them, per general-principles. For compile-time cost of deep type graphs, see typescript-types-vs-interfaces.