Common Generic Patterns in TypeScript
Beyond individual generic functions or classes, TypeScript codebases rely on a set of recurring patterns — idiomatic structures that solve common problems elegantly. Recognising these patterns makes you a faster reader and a more confident author of professional TypeScript.
1. The Result / Either Pattern
Instead of throwing exceptions, many modern TypeScript codebases represent success or failure as a typed discriminated union. This pattern originates from functional languages (Haskell's Either, Rust's Result) and forces callers to handle both cases.
type Result<T, E = Error> =
| { ok: true; value: T }
| { ok: false; error: E };
function divide(a: number, b: number): Result<number, string> {
if (b === 0) return { ok: false, error: 'Division by zero' };
return { ok: true, value: a / b };
}
const r = divide(10, 2);
if (r.ok) {
console.log(r.value * 2); // 10 — narrowed to number
} else {
console.error(r.error); // 'Division by zero' — narrowed to string
}
// Helper constructors
const ok = <T>(value: T): Result<T, never> => ({ ok: true, value });
const fail = <E>(error: E): Result<never, E> => ({ ok: false, error });
// Chaining results
function chain<T, U, E>(
result: Result<T, E>,
fn: (value: T) => Result<U, E>
): Result<U, E> {
return result.ok ? fn(result.value) : result;
}neverthrow and fp-ts provide production-ready implementations of this pattern with full monadic chaining. Understanding the base pattern helps you use them effectively.2. The Builder Pattern with Generics
The generic builder pattern lets you construct objects step-by-step while the type system tracks which fields have been set. TypeScript can enforce that all required fields are provided before build() is called.
// Type-safe query builder (simplified)
interface QueryState {
table?: string;
conditions: string[];
limit?: number;
offset?: number;
}
class QueryBuilder<TRequired extends Partial<QueryState> = Record<never, never>> {
private state: QueryState = { conditions: [] };
from(table: string): QueryBuilder<TRequired & { table: string }> {
this.state.table = table;
return this as unknown as QueryBuilder<TRequired & { table: string }>;
}
where(condition: string): QueryBuilder<TRequired> {
this.state.conditions.push(condition);
return this;
}
limitTo(n: number): QueryBuilder<TRequired> {
this.state.limit = n;
return this;
}
build(this: QueryBuilder<{ table: string }>): string {
const { table, conditions, limit } = this.state;
let sql = `SELECT * FROM ${table}`;
if (conditions.length) sql += ` WHERE ${conditions.join(' AND ')}`;
if (limit) sql += ` LIMIT ${limit}`;
return sql;
}
}
const query = new QueryBuilder()
.from('users')
.where('age > 18')
.limitTo(10)
.build();
console.log(query); // SELECT * FROM users WHERE age > 18 LIMIT 10
// new QueryBuilder().where('x = 1').build(); // ❌ Type error: table not set3. The Fluent Interface / Method Chaining Pattern
// Generic pipeline that preserves types across transformations
class Pipeline<T> {
private constructor(private readonly value: T) {}
static of<T>(value: T): Pipeline<T> {
return new Pipeline(value);
}
map<U>(fn: (value: T) => U): Pipeline<U> {
return new Pipeline(fn(this.value));
}
tap(fn: (value: T) => void): Pipeline<T> {
fn(this.value);
return this;
}
filter(predicate: (value: T) => boolean): Pipeline<T | undefined> {
return new Pipeline(predicate(this.value) ? this.value : undefined);
}
get(): T {
return this.value;
}
}
const result = Pipeline
.of([1, 2, 3, 4, 5])
.map(arr => arr.filter(n => n % 2 === 0))
.tap(arr => console.log('even numbers:', arr))
.map(arr => arr.reduce((sum, n) => sum + n, 0))
.get();
console.log(result); // 64. Factory Function Pattern
Generic factory functions create typed instances without requiring the caller to use new. They're especially useful for creating objects whose exact type depends on a runtime parameter.
// Generic factory with registry
type Constructor<T> = new (...args: unknown[]) => T;
class Registry<TBase> {
private entries = new Map<string, Constructor<TBase>>();
register<T extends TBase>(key: string, ctor: Constructor<T>): void {
this.entries.set(key, ctor);
}
create(key: string): TBase {
const Ctor = this.entries.get(key);
if (!Ctor) throw new Error(`No factory for key: ${key}`);
return new Ctor();
}
}
interface Logger {
log(message: string): void;
}
class ConsoleLogger implements Logger {
log(message: string): void { console.log(`[LOG] ${message}`); }
}
class FileLogger implements Logger {
log(message: string): void { console.log(`[FILE] ${message}`); }
}
const loggerRegistry = new Registry<Logger>();
loggerRegistry.register('console', ConsoleLogger);
loggerRegistry.register('file', FileLogger);
const logger = loggerRegistry.create('console'); // Logger
logger.log('Hello!'); // [LOG] Hello!5. The Observer / Reactive State Pattern
type Unsubscribe = () => void;
class Signal<T> {
private _value: T;
private subscribers = new Set<(value: T, prev: T) => void>();
constructor(initial: T) {
this._value = initial;
}
get value(): T { return this._value; }
set(newValue: T): void {
const prev = this._value;
this._value = newValue;
this.subscribers.forEach(fn => fn(newValue, prev));
}
update(fn: (current: T) => T): void {
this.set(fn(this._value));
}
subscribe(fn: (value: T, prev: T) => void): Unsubscribe {
this.subscribers.add(fn);
fn(this._value, this._value); // call immediately with current value
return () => this.subscribers.delete(fn);
}
// Derive a read-only computed signal
derived<U>(fn: (value: T) => U): ReadonlySignal<U> {
const derived = new Signal(fn(this._value));
this.subscribe(value => derived.set(fn(value)));
return derived;
}
}
type ReadonlySignal<T> = Readonly<Pick<Signal<T>, 'value' | 'subscribe' | 'derived'>>;
const count = new Signal(0);
const doubled = count.derived(n => n * 2);
doubled.subscribe(v => console.log('doubled:', v)); // doubled: 0
count.set(5); // doubled: 10
count.update(n => n + 1); // doubled: 126. The Mixin Pattern
Mixins are a way to add reusable behaviour to classes without multiple inheritance. TypeScript's generic class constructor constraint makes them fully type-safe.
type GConstructor<T = object> = new (...args: unknown[]) => T;
// Mixin: add timestamp fields
function Timestamped<TBase extends GConstructor>(Base: TBase) {
return class Timestamped extends Base {
createdAt = new Date();
updatedAt = new Date();
touch() {
this.updatedAt = new Date();
}
};
}
// Mixin: add soft-delete
function SoftDeletable<TBase extends GConstructor>(Base: TBase) {
return class SoftDeletable extends Base {
deletedAt: Date | null = null;
softDelete() { this.deletedAt = new Date(); }
restore() { this.deletedAt = null; }
isDeleted() { return this.deletedAt !== null; }
};
}
class User {
constructor(public name: string, public email: string) {}
}
// Compose mixins
const AuditableUser = Timestamped(SoftDeletable(User));
const user = new AuditableUser('Alice', 'alice@example.com');
user.softDelete();
console.log(user.isDeleted()); // true
user.touch();
console.log(user.updatedAt); // Date7. Dependency Injection with Generics
// Simple typed DI container
type Token<T> = { readonly _brand: T };
function token<T>(name: string): Token<T> {
return { _brand: name } as unknown as Token<T>;
}
class Container {
private bindings = new Map<Token<unknown>, unknown>();
bind<T>(token: Token<T>, value: T): void {
this.bindings.set(token as Token<unknown>, value);
}
get<T>(token: Token<T>): T {
if (!this.bindings.has(token as Token<unknown>)) {
throw new Error('Token not registered');
}
return this.bindings.get(token as Token<unknown>) as T;
}
}
// Token declarations
const DB_URL = token<string>('DB_URL');
const MAX_CONNECTIONS = token<number>('MAX_CONNECTIONS');
const LOGGER = token<{ log: (msg: string) => void }>('LOGGER');
const container = new Container();
container.bind(DB_URL, 'postgresql://localhost:5432/mydb');
container.bind(MAX_CONNECTIONS, 10);
container.bind(LOGGER, { log: console.log });
const dbUrl = container.get(DB_URL); // string — fully typed
const maxConn = container.get(MAX_CONNECTIONS); // numberInversifyJS and tsyringe. The token-based approach avoids string key collisions and preserves type information through the container.8. The Proxy / Middleware Pattern
type Middleware<T> = (value: T, next: (value: T) => T) => T;
function applyMiddleware<T>(value: T, middlewares: Middleware<T>[]): T {
const execute = (index: number, current: T): T => {
if (index >= middlewares.length) return current;
return middlewares[index](current, (v) => execute(index + 1, v));
};
return execute(0, value);
}
// Middleware pipeline for HTTP-like requests
interface Request {
path: string;
headers: Record<string, string>;
body: unknown;
}
const logMiddleware: Middleware<Request> = (req, next) => {
console.log(`--> ${req.path}`);
const result = next(req);
console.log(`<-- ${req.path}`);
return result;
};
const authMiddleware: Middleware<Request> = (req, next) => {
if (!req.headers['authorization']) {
throw new Error('Unauthorized');
}
return next(req);
};
const request: Request = {
path: '/api/users',
headers: { authorization: 'Bearer token123' },
body: null,
};
applyMiddleware(request, [logMiddleware, authMiddleware]);9. Generic Validation Pattern
type ValidationError = { field: string; message: string };
type ValidationResult<T> =
| { valid: true; data: T }
| { valid: false; errors: ValidationError[] };
type Validator<T> = (value: unknown) => ValidationResult<T>;
function validate<T>(
value: unknown,
validators: Validator<T>[]
): ValidationResult<T> {
const errors: ValidationError[] = [];
for (const validator of validators) {
const result = validator(value);
if (!result.valid) errors.push(...result.errors);
}
if (errors.length > 0) {
return { valid: false, errors };
}
return { valid: true, data: value as T };
}
// Compose validators
function isString(field: string): Validator<string> {
return (value) => {
if (typeof value === 'string') return { valid: true, data: value };
return { valid: false, errors: [{ field, message: 'Must be a string' }] };
};
}
function minLength(field: string, min: number): Validator<string> {
return (value) => {
if (typeof value === 'string' && value.length >= min) {
return { valid: true, data: value };
}
return { valid: false, errors: [{ field, message: `Minimum ${min} characters` }] };
};
}Pattern Quick Reference
Pattern | Purpose | Key Generic Trick |
|---|---|---|
Result/Either | Type-safe error handling | Discriminated union with T and E params |
Builder | Step-by-step object construction | Accumulate type state via intersection |
Pipeline | Chainable transformations | map<U> returns Pipeline<U> |
Factory Registry | Create instances by key | Constructor<T> constraint |
Signal / Observable | Reactive state | Generic callbacks + derived types |
Mixin | Compose class behaviour | GConstructor<T> + class expressions |
DI Container | Inversion of control | Branded token type preserves T |
Middleware | Pipeline interceptors | next: (value: T) => T |
Validation | Runtime type checking | Validator<T> combinator type |
Choosing the Right Pattern
Use Result when a function can fail and callers must handle both branches
Use Builder when constructing complex objects with many optional settings
Use Pipeline when chaining transformations where each step changes the type
Use Mixin when multiple unrelated classes need the same behaviour
Use Signal/Observable when UI or other consumers need to react to state changes
Use Factory Registry when the concrete type is determined at runtime
keyof, typeof, and indexed access — that unlock the deepest type-level programming.