TypeScriptCommon Generic Patterns

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.

TS
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;
}
Tip
Libraries like 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.

TS
// 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 set
3. The Fluent Interface / Method Chaining Pattern

TS
// 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); // 6
4. 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.

TS
// 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

TS
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: 12
6. 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.

TS
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);   // Date
7. Dependency Injection with Generics

TS
// 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); // number
Note
This pattern is the basis for IoC containers like InversifyJS and tsyringe. The token-based approach avoids string key collisions and preserves type information through the container.
8. The Proxy / Middleware Pattern

TS
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

TS
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

Success
You now have a toolkit of generic patterns that power real TypeScript libraries and applications. The remaining pages cover the three type operators — keyof, typeof, and indexed access — that unlock the deepest type-level programming.