---
title: "TypeScript: Generics"
slug: "typescript-generics"
category: "coding"
tags: ["coding", "typescript", "generics", "constraints", "inference"]
status: "stable"
last_updated: 2026-05-14
summary: "Generic functions, constraints, inference, and the patterns that pay off: Repository, Result, and typed event maps."
related: ["[[coding/typescript]]", "[[coding/typescript-narrowing]]", "[[coding/typescript-utility-types]]", "[[coding/typescript-types-vs-interfaces]]", "[[coding/general-principles]]"]
---

## 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 [[coding/typescript]] first; for related rules, see [[coding/typescript-utility-types]] and [[coding/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.

```ts
// 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 [[coding/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.

```ts
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 [[coding/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.

```ts
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.

```ts
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.

```ts
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 [[coding/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.

```ts
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 [[coding/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`.

```ts
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 [[coding/typescript-types-vs-interfaces]] for the perf trade-offs in deep type graphs.

## Related

- [[coding/typescript]]
- [[coding/typescript-narrowing]]
- [[coding/typescript-utility-types]]
- [[coding/typescript-types-vs-interfaces]]
- [[coding/general-principles]]
