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.
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-proneThe contract “what comes out has the same type as what went in” is encoded.
Generic function vs generic interface vs generic class — when each?
// 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.
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 .lengthConstraints 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.
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 = UserDefaults 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:
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 userThis 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?
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.
// 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.
// 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 valueA generic that appears only on the return is just a polite any.
Gotchas / edge cases
- Arrow generics in
.tsx—<T>(x: T) => xlooks like JSX. Use<T,>(x: T) => x(trailing comma) or<T extends unknown>(x: T) => x. - Over-constraining —
T extends stringwhen you really meant “any value”; the constraint leaks into the public API. Objectvs{}vsobjectvsRecord<string, unknown>— all different. PreferRecord<string, unknown>or a specific shape.- Inference can collapse to a base type — passing two different object types into a function generic on
Tmay inferTas their union, not what you wanted; use a constraint to anchor it. - Generic defaults don’t fix everything — a default of
unknownstill 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 (noFunctor<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
- Conditional types and
inferbuild on this: Conditional and Mapped Types (and infer) - Utility types reimplemented with these primitives: Built-in Utility Types — Reimplemented from Scratch
- React component generics: Typing React Components