Frontend / Typescript / 01_generics.md

TypeScript Generics

Updated 5 min read source
On this page6
  1. TL;DR
  2. In depth
  3. Gotchas / edge cases
  4. What a senior is expected to say
  5. Cross-references
  6. Further reading

TypeScript Generics

TL;DR

Generics are type-level parameters. They let a function, class, or type be reused across many concrete types without losing type information (the alternative is any, which loses everything). The senior moves: constraining generics with extends, using keyof + lookup types to make APIs type-safe against an object’s actual shape, and knowing when not to add a generic.

In depth

What’s a generic, and why use one over any?

A generic introduces a type variable that’s bound at the call site. The type relationship between input and output is preserved; with any it’s erased.

ts
function identity<T>(value: T): T { return value }

const n = identity(42)        // n: number
const s = identity('hello')   // s: string

// vs any:
function identityAny(value: any): any { return value }
const x = identityAny(42)     // x: any — error-prone

The contract “what comes out has the same type as what went in” is encoded.

Generic function vs generic interface vs generic class — when each?

ts
// Generic function: each call binds T fresh.
function first<T>(arr: T[]): T | undefined { return arr[0] }

// Generic interface: T is bound when the interface is used.
interface Box<T> { value: T }
const a: Box<number> = { value: 1 }

// Generic class: T is bound at instantiation.
class Stack<T> {
  private items: T[] = []
  push(item: T) { this.items.push(item) }
  pop(): T | undefined { return this.items.pop() }
}
const s = new Stack<string>()

Rule of thumb: use a generic function when the type flows through one call; a generic interface/type to describe a parameterized shape; a generic class when state with that shape is held over time.

How do generic constraints (extends) work?

T extends X means “T must be assignable to X.” You get to use X’s members on T inside the function.

ts
function getLength<T extends { length: number }>(x: T): number {
  return x.length  // allowed — T provably has .length
}

getLength('hello')   // ok
getLength([1, 2, 3]) // ok
getLength(42)        // error — number has no .length

Constraints narrow what T can be and unlock its members. Without the constraint, x.length would error.

What are default type parameters good for?

A default lets a generic be omitted at the call site and still be useful — common for “container” types and React component generics.

ts
interface ApiResponse<T = unknown> {
  data: T
  status: number
}

const r1: ApiResponse = { data: 'something', status: 200 }       // T = unknown
const r2: ApiResponse<User> = { data: { id: 1 }, status: 200 }   // T = User

Defaults also avoid breaking callers when you add a generic later.

What does keyof do, and how do you use it with lookup types?

keyof T is the union of the property names of T. T[K] is the type at key K. Combined, they give you a type-safe “get any property” API:

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

const user = { id: 1, name: 'Ada', email: 'a@b.com' }
const id = pluck(user, 'id')       // id: number
const name = pluck(user, 'name')   // name: string
pluck(user, 'nope')                // error — 'nope' not in keyof typeof user

This is the single most common generic pattern in real code (form libraries, ORMs, validators).

How do you type a function that maps an object’s values?

ts
function mapValues<T extends object, U>(
  obj: T,
  fn: <K extends keyof T>(value: T[K], key: K) => U,
): Record<keyof T, U> {
  const out = {} as Record<keyof T, U>
  for (const k in obj) out[k] = fn(obj[k] as T[Extract<keyof T, string>], k)
  return out
}

The generic K inside fn lets the callback know exactly which key’s value it received.

How do generic constraints interact with inference?

TS infers from arguments. If you constrain too loosely, you lose information; too tightly, you over-restrict callers.

ts
// Too loose: T inferred as { id: number } only — loses the rest of the object.
function withId<T extends { id: number }>(x: { id: number }): T { return x as T }

// Right: T preserved fully through the function.
function withId2<T extends { id: number }>(x: T): T { return x }

// u: { id: number; name: string }
const u = withId2({ id: 1, name: 'Ada' })

The shape “argument is T, return is T” is what preserves narrow inference.

When shouldn’t you reach for generics?

When the generic is never actually used as a type relationship. If T appears only once in the signature, it’s almost certainly wrong — you wanted a constraint, not a generic.

ts
// Smell — T appears only in return position. Caller must specify it.
function parse<T>(json: string): T { return JSON.parse(json) }
// looks safe; isn't. T is unverified.
const u = parse<User>('{}')

// Better:
function parse(json: string): unknown { return JSON.parse(json) }
// caller validates with zod/io-ts/etc and gets a typed value

A generic that appears only on the return is just a polite any.

Gotchas / edge cases

  • Arrow generics in .tsx<T>(x: T) => x looks like JSX. Use <T,>(x: T) => x (trailing comma) or <T extends unknown>(x: T) => x.
  • Over-constrainingT extends string when you really meant “any value”; the constraint leaks into the public API.
  • Object vs {} vs object vs Record<string, unknown> — all different. Prefer Record<string, unknown> or a specific shape.
  • Inference can collapse to a base type — passing two different object types into a function generic on T may infer T as their union, not what you wanted; use a constraint to anchor it.
  • Generic defaults don’t fix everything — a default of unknown still requires narrowing before use.
  • Higher-kinded types don’t exist in TS — you can’t write F<T> where F is itself a generic parameter (no Functor<F>). You work around with helper types.

What a senior is expected to say

A junior says “generics make code reusable.” A senior says: generics encode relationships between types (input shape ↔ output shape, key ↔ value type, request ↔ response shape). Constraints (extends) plus keyof T + lookup types T[K] are the workhorse pattern in real codebases — form libraries, query builders, ORMs are all built on it. The senior also flags the anti-pattern: a generic that only appears in the return position is a polite any masquerading as type safety.

Cross-references

Further reading