Generics and Constraints, Explained
The third version of our groupBy utility accepted any[] and returned Record<string, any[]>. It worked until someone grouped by a numeric key, got back "[object Object]" buckets, and spent an afternoon debugging. The fix was a one-line constraint: T extends Record<string, unknown>. Generics with constraints give you the flexibility of polymorphism and the safety of knowing what shape you're working with. They're the difference between a utility that works in demos and one that survives a codebase.
Generics: the basics
A generic function preserves the type relationship between input and output:
function first<T>(items: T[]): T | undefined {
return items[0];
}
const n = first([1, 2, 3]); // number | undefined
const s = first(["a", "b"]); // string | undefined
Without <T>, you'd write first(items: any[]): any and lose everything.
Constraints with extends
When the function needs to access properties on T:
interface Identifiable {
id: string;
}
function findById<T extends Identifiable>(items: T[], id: string): T | undefined {
return items.find(item => item.id === id);
}
T extends Identifiable means T can be User, Product, or Order — anything with an id: string — and the return type is the specific type passed in, not just Identifiable.
Multiple constraints
Intersect constraint types for combined requirements:
function merge<T extends object, U extends object>(a: T, b: U): T & U {
return { ...a, ...b };
}
Both must be objects (not primitives). The return type is the intersection.
The keyof constraint pattern
One of the most useful patterns in TypeScript:
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const user = { name: "Alice", age: 30 };
const name = getProperty(user, "name"); // string
const age = getProperty(user, "age"); // number
getProperty(user, "email"); // compile error
K extends keyof T ensures the key argument is a valid key of T, and the return type T[K] is the exact property type. This is how Object.getOwnProperty should have been typed.
Extending with keyof for pick/pluck
function pluck<T, K extends keyof T>(items: T[], key: K): T[K][] {
return items.map(item => item[key]);
}
const names = pluck(users, "name"); // string[]
Constraining to literal types
Require a type parameter to be a string literal union:
function createState<S extends string>(
initial: S
): { value: S; setValue: (v: S) => void } {
let value = initial;
return {
value,
setValue(v: S) { value = v; },
};
}
const status = createState("idle" as const);
status.setValue("loading"); // OK
status.setValue("invalid"); // compile error
Generic inference: let TypeScript figure it out
TypeScript infers type arguments from function arguments when possible:
function pair<A, B>(a: A, b: B): [A, B] {
return [a, b];
}
const p = pair("hello", 42); // [string, number] — inferred
You can also infer from return position with satisfies or explicit annotation:
const result = pair<string, number>("hello", 42);
Inferring from array elements
function asTuple<T extends readonly [unknown, ...unknown[]]>(arr: T): T {
return arr;
}
const t = asTuple(["a", 1, true] as const);
// readonly ["a", 1, true]
Building a type-safe event bus
Combining generics and constraints for a real utility:
interface EventMap {
"user:created": { id: string; name: string };
"user:deleted": { id: string };
"order:placed": { orderId: string; total: number };
}
class TypedEventBus<Events extends Record<string, unknown>> {
private listeners = new Map<string, Set<Function>>();
on<K extends keyof Events>(
event: K,
handler: (payload: Events[K]) => void
): () => void {
const set = this.listeners.get(event as string) ?? new Set();
set.add(handler);
this.listeners.set(event as string, set);
return () => set.delete(handler);
}
emit<K extends keyof Events>(event: K, payload: Events[K]): void {
const set = this.listeners.get(event as string);
set?.forEach(handler => handler(payload));
}
}
const bus = new TypedEventBus<EventMap>();
bus.on("user:created", (payload) => {
console.log(payload.name); // typed as { id: string; name: string }
});
bus.emit("user:created", { id: "1", name: "Alice" }); // OK
bus.emit("user:created", { id: "1" }); // Error: missing name
K extends keyof Events constrains event names to the map's keys. Events[K] resolves the payload type for each key. Add a new event to the map and the compiler enforces it everywhere.
Common constraint patterns
| Pattern | Constraint | Use case |
|---|---|---|
T extends string |
T is a string or literal | String manipulation utilities |
T extends keyof U |
T is a key of U | Property access, pick, pluck |
T extends unknown[] |
T is an array | Array utilities |
T extends (...args: any[]) => any |
T is a function | Function wrappers, decorators |
T extends Record<string, unknown> |
T is an object | Object manipulation |
Defaults for generic parameters
Provide fallback types when inference isn't possible:
type ApiResult<T = unknown, E = string> =
| { data: T; error: never }
| { data: never; error: E };
type StringResult = ApiResult<string>;
type DefaultResult = ApiResult; // ApiResult<unknown, string>
Defaults reduce annotation noise at call sites while keeping the generic flexible for cases that need specificity.
Common production mistakes
Teams get generics constraints wrong in predictable ways:
- Skipping failure-mode rehearsal — run a game day or fault injection exercise before peak traffic, not after the first outage.
- Missing correlation context — every error path should carry request, trace, or tenant identifiers so incidents are debuggable.
- Optimizing for demo, not steady state — load tests, cache warm-up, and cold-start paths matter more than local dev latency.
- Undocumented trade-offs — if you chose speed over strict correctness (or vice versa), write that down for the next engineer.
TypeScript patterns for generics constraints erode when any escapes during deadlines, generic constraints are loosened instead of modeling domain invariants, and strict mode is disabled file-by-file without a migration plan.
Resources
- TypeScript Handbook: Generics
- TypeScript Handbook: Generic Constraints
- TypeScript Handbook: keyof Type Operator
- TypeScript Deep Dive: Generics
- TypeScript 4.7 extends constraints on infer
Frequently asked questions
What is a generic constraint in TypeScript?
A generic constraint limits what types can be passed as a type argument by requiring the type to extend a specific shape. The syntax T extends SomeType means T must be assignable to SomeType. This lets you access properties on T inside the function body — for example, T extends { id: string } lets you read item.id — while still preserving the specific type the caller passes in.
What is the difference between generics and the any type?
any disables type checking entirely — you lose safety and IDE support. Generics preserve the relationship between input and output types while remaining flexible. A function identity<T>(value: T): T with any becomes identity(value: any): any, which tells the compiler nothing. With generics, passing a string returns a string, passing a number returns a number, and misuse is caught at compile time.
When should I add a constraint versus leaving the generic unbounded?
Leave generics unbounded when the function truly works with any type — identity, array wrapping, Promise creation. Add a constraint when the function accesses specific properties or methods on T — sorting requires T extends Comparable, grouping requires T extends Record<string, unknown>. If you find yourself casting inside a generic function, you probably need a constraint.
Hiring a senior Android / Flutter engineer?
I architect and ship production mobile software — Kotlin, Jetpack Compose, Flutter — for robotics, EV infrastructure, fintech, and real-time systems. Open to remote roles in Europe and the US.
Get in touch →