Frontend / Typescript / 01_generics.md

TypeScript Generics

Updated 5 interview angles 6 min read source
On this page12
  1. What’s a generic, and why use one over any?
  2. Generic function vs generic interface vs generic class — when each?
  3. How do generic constraints (extends) work?
  4. What are default type parameters good for?
  5. What does keyof do, and how do you use it with lookup types?
  6. How do you type a function that maps an object’s values?
  7. How do generic constraints interact with inference?
  8. When shouldn’t you reach for generics?
  9. Gotchas
  10. Related
  11. Further reading
  12. Interview angle

TypeScript Generics

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.

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

  • 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-constraining — T 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.

Further reading

Interview angle 5

  • “What is a generic for, beyond reuse?” - encoding a relationship between types: input shape to output shape, key to value type, request to response. “Makes code reusable” is the junior answer; a generic that does not tie two positions together is not doing anything a plain parameter could not.
    • Follow-up: “How do you spot one that is not earning its place?” - the type parameter appears only in the return position. function parse<T>(json: string): T looks type-safe and is a polite any - nothing checks that the parsed value is a T, and the caller picked the type. Return unknown and make the caller validate.
  • “What is the workhorse pattern in real codebases?” - extends constraints plus keyof T and lookup types T[K]. That combination is what lets a form library know that field("email") returns a string, and it is the backbone of query builders, ORMs and typed routers.
  • “When does a constraint go wrong?” - when you over-constrain. T extends string because the implementation happened to use strings leaks into the public API and blocks legitimate callers. Constrain to what the function genuinely requires, not to what it currently receives.
  • “Why does <T>(x: T) => x fail in a .tsx file?” - the parser reads <T> as JSX. Write <T,>(x: T) => x with the trailing comma, or <T extends unknown>, which both disambiguate it.
  • “What can TypeScript’s generics not express?” - higher-kinded types. You cannot write F<T> where F is itself a type parameter, so there is no way to write a generic Functor<F> abstraction. Libraries work around it with helper types and it stays awkward.