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);
}
  • extends means "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 than T[] 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
PatternUse it forPoor use
<T>(value: T): TIdentity, or storing a value as givenA parameter that is only logged
K extends keyof TField access tied to one objectKeys that never belong to that object
T extends Record<string, unknown>Keys of a dictionaryAn object type you already know
<T = unknown>An override on an uncommon pathEvery function, as a habit
new (...args: A) => TFactories and decoratorsRe-checking an instance you already have
readonly T[] parameterTransforms that do not mutate inputA 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
  1. List the positions where the type parameter appears. Fewer than two means it is not connecting anything.
  2. Ask whether unknown would accept the same callers. If it would, the parameter is decoration.
  3. Try removing it. If only the return type widens, add a default instead of keeping the parameter.
  4. 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.

Utility types, keyof and mapped types Typing functions, callbacks and this

Last refreshed 2026-09-18.