Overview

Generics turn a single function into a family of typed functions. The cost is one type parameter; the win is that callers do not lose type information at the boundary. Read typescript first; for related rules, see typescript-utility-types and typescript-narrowing.

Add a generic only when a real shape varies

A function that takes and returns unknown is not a candidate for a generic; it has no shape to vary. A function that returns “whatever I was given” is the textbook case.

// Bad: any is a hole; loses the type.
function first(items: any[]): any { return items[0] }
 
// Better: T flows through.
function first<T>(items: readonly T[]): T | undefined {
  return items[0]
}
 
const n = first([1, 2, 3])   // number | undefined
const s = first(["a", "b"])  // string | undefined

If every call site has to pass the type parameter explicitly, the generic is not earning its keep; the value is shaped by inference from arguments. See typescript-strict-mode for why T | undefined is the honest return type.

Constrain generics with extends

An unconstrained T can be anything, including void. Constrain it to the shape your function actually requires.

function pluck<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key]
}
 
const user = { id: "u1", name: "Ada", age: 31 }
const id = pluck(user, "id")     // string
const age = pluck(user, "age")   // number
// pluck(user, "missing")        // Error: "missing" not in keyof T

K extends keyof T is the most useful constraint in TypeScript. It powers Pick, Omit, and most of the typed accessor patterns. See typescript-utility-types for the built-ins.

Let inference do the work; default parameters when it cannot

TypeScript infers type parameters from arguments. Only annotate the call site when inference fails or when an explicit hint reads better.

function group<T, K extends string>(items: readonly T[], key: (item: T) => K): Record<K, T[]> {
  const out = {} as Record<K, T[]>
  for (const item of items) {
    const k = key(item)
    ;(out[k] ??= []).push(item)
  }
  return out
}
 
// T inferred from items, K inferred from key's return type.
const byStatus = group(invoices, (i) => i.status)

Use default type parameters for “most callers want this shape” cases.

function createStore<TState = Record<string, unknown>>(initial: TState) {
  return { state: initial }
}

Result types beat throw-and-catch at API boundaries

A typed Result<T, E> makes failure visible in the signature; callers cannot ignore it the way they can ignore an exception.

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: await res.json() }
}
 
const r = await loadInvoice("inv_1")
if (r.ok) {
  render(r.value)
} else if (r.error === "not-found") {
  show404()
}

The narrowing on r.ok is automatic; see typescript-narrowing for the discriminated-union mechanics.

Repository pattern with a typed entity map

A typed entity map keeps every CRUD function aligned on the entity shape without copy-pasting types per resource.

type Entities = {
  user: { id: string; email: string }
  invoice: { id: string; amountCents: number }
}
 
interface Repo<K extends keyof Entities> {
  get(id: string): Promise<Entities[K] | null>
  list(): Promise<readonly Entities[K][]>
  create(input: Omit<Entities[K], "id">): Promise<Entities[K]>
}
 
function makeRepo<K extends keyof Entities>(table: K): Repo<K> {
  // ...
}
 
const users = makeRepo("user")
const invoices = makeRepo("invoice")

This pattern wins where you have many resources with the same lifecycle. For two resources, write two repos by hand; do not abstract early. See general-principles on premature abstraction.

Use infer in conditional types for surgical extraction

Conditional types with infer pull a piece out of a complex type. The built-ins (ReturnType, Parameters, Awaited) are all conditional + infer.

type ArrayItem<T> = T extends readonly (infer U)[] ? U : never
 
type FirstParam<F> = F extends (a: infer A, ...rest: never) => unknown ? A : never
 
type Item = ArrayItem<string[]>     // string
type Q = FirstParam<(q: string, opts: Options) => void>  // string

Reach for infer when a utility type does not exist. If you write infer more than twice on the same shape, name the helper.

Avoid generic gymnastics in application code

Heavy generic types make compile errors unreadable. If a type expression takes more than two lines to understand, simplify.

  • Five nested conditional types in a row: refactor.
  • A function with four generic parameters: split into smaller functions.
  • A type that the compiler reports as a 200-character intersection: name the intermediate.

Library types can earn the complexity. Application types rarely do. See typescript-types-vs-interfaces for the perf trade-offs in deep type graphs.