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.
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
- 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.
Related
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): Tlooks type-safe and is a politeany- nothing checks that the parsed value is aT, and the caller picked the type. Returnunknownand make the caller validate.
- Follow-up: “How do you spot one that is not earning its place?” - the type
parameter appears only in the return position.
- “What is the workhorse pattern in real codebases?” -
extendsconstraints pluskeyof Tand lookup typesT[K]. That combination is what lets a form library know thatfield("email")returns astring, and it is the backbone of query builders, ORMs and typed routers. - “When does a constraint go wrong?” - when you over-constrain.
T extends stringbecause 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) => xfail in a.tsxfile?” - the parser reads<T>as JSX. Write<T,>(x: T) => xwith 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>whereFis itself a type parameter, so there is no way to write a genericFunctor<F>abstraction. Libraries work around it with helper types and it stays awkward.