Generic patterns without over-engineering
Constraints and defaults, where type inference actually looks, generic data structures, and the judgement to stop generalising.
Constraints and defaults
interface User { id: string; role: 'admin' | 'editor' }
// without a constraint the body could not use the type at all
function groupBy<T, K extends keyof T>(items: T[], key: K): Map<T[K], T[]> {
const out = new Map<T[K], T[]>();
for (const item of items) {
const bucket = out.get(item[key]) ?? [];
bucket.push(item);
out.set(item[key], bucket);
}
return out;
}
const byRole = groupBy(users, 'role'); // Map<User['role'], User[]>
// groupBy(users, 'nope'); // error: not a key of User
// a default keeps the common call site short and the unusual one possible
interface RequestOptions<TBody = unknown> {
method: 'GET' | 'POST';
body?: TBody;
}
function request<TResponse, TBody = unknown>(
url: string,
options: RequestOptions<TBody> = { method: 'GET' }
): Promise<TResponse> {
return fetch(url, {
method: options.method,
body: options.body === undefined ? undefined : JSON.stringify(options.body)
}).then(response => response.json() as Promise<TResponse>);
}
// a constraint on a constructor is what makes a factory expressible
function build<T, A extends unknown[]>(Ctor: new (...args: A) => T, ...args: A): T {
return new Ctor(...args);
}extendsmeans "must be assignable to", so it tightens what callers may pass and unlocks the members the body can safely use.- A bound like
{ length: number }is often enough - constrain to the smallest shape the body actually reads. - Defaults apply only when nothing can be inferred, which makes them rare but useful in an options object.
readonly T[]in a parameter accepts more callers thanT[]and documents that the input is not modified.
Where inference actually looks
// inference reads the arguments, so callers usually pass no type arguments
function pair<A, B>(a: A, b: B): [A, B] {
return [a, b];
}
const point = pair(1, 'x'); // [number, string]
// a type parameter that appears only in the return position has nothing to read
function empty<T>(): T[] { return []; }
// const bad = empty(); // unknown[]
const good = empty<string>(); // string[]
// a callback parameter is inferred contextually from the other argument
function zip<A, B>(as: A[], bs: B[]): [A, B][] {
return as.map((a, index) => [a, bs[index]] as [A, B]);
}
// a generic class keeps one type across many operations
class Stack<T> {
private items: T[] = [];
push(item: T): void { this.items.push(item); }
pop(): T | undefined { return this.items.pop(); }
peek(): T | undefined { return this.items[this.items.length - 1]; }
get size(): number { return this.items.length; }
}
const stack = new Stack<number>();
stack.push(1);
// stack.push('one'); // error: string is not a number| Pattern | Use it for | Poor use |
|---|---|---|
<T>(value: T): T | Identity, or storing a value as given | A parameter that is only logged |
K extends keyof T | Field access tied to one object | Keys that never belong to that object |
T extends Record<string, unknown> | Keys of a dictionary | An object type you already know |
<T = unknown> | An override on an uncommon path | Every function, as a habit |
new (...args: A) => T | Factories and decorators | Re-checking an instance you already have |
readonly T[] parameter | Transforms that do not mutate input | A list you intend to modify |
Knowing when to stop
// T appears once, so it links nothing and adds only noise
function logValue<T>(value: T): void {
console.log(value); // unknown would do exactly the same job
}
// two parameters with a relationship are worth one type parameter
function firstOr<T>(items: T[], fallback: T): T {
return items.length > 0 ? items[0] : fallback;
}
const count = firstOr([1, 2, 3], 0); // number, not number | string
// a named helper type keeps a signature readable
type Predicate<T> = (value: T) => boolean;
function partition<T>(items: T[], test: Predicate<T>): [T[], T[]] {
const pass: T[] = [];
const fail: T[] = [];
for (const item of items) (test(item) ? pass : fail).push(item);
return [pass, fail];
}
// if a signature cannot be explained in one sentence, two concrete
// functions will be clearer for every caller you have- List the positions where the type parameter appears. Fewer than two means it is not connecting anything.
- Ask whether
unknownwould accept the same callers. If it would, the parameter is decoration. - Try removing it. If only the return type widens, add a default instead of keeping the parameter.
- Prefer two concrete signatures over one clever one: a cast at a call site is cheaper than a signature nobody can read.
💡
A useful rule: add a type parameter when it links two positions, remove it when it appears once. Generic code nobody can read is a maintenance cost paid by everyone who calls it, not a sign of sophistication.
FAQ
Why does empty() return unknown[] instead of my type?
A type parameter that appears only in the return position has no argument to infer from, so it falls back to its constraint. Give it a default or pass the type argument at the call site.
Do generics cost anything at runtime?
Nothing. They are checked and then erased during compilation, so a generic function ships as the plain JavaScript version of the same body with the type arguments removed.
Related
Utility types, keyof and mapped types Typing functions, callbacks and this
Last refreshed 2026-09-18.