Skip to content
digital garden
Back to Blog

Behavioral Design Patterns — From Concept to TypeScript

21 min read
design-patternstypescriptsoftware-architectureoop

Behavioral Design Patterns — From Concept to TypeScript

This is the fourth article in the Design Patterns series. If you haven't read the overview yet, start there first.

Behavioral patterns answer: how do objects communicate and distribute responsibilities? These patterns are about algorithms, workflows, and the assignment of roles between objects. They're the largest category (11 patterns) because communication is the hardest part of software design.

PatternProblem It Solves
Chain of Responsibility"I need to pass a request through a pipeline of handlers"
Command"I need to encapsulate actions as objects"
Iterator"I need to traverse a collection without exposing its internals"
Mediator"I need to reduce chaotic dependencies between objects"
Memento"I need to save and restore an object's state"
Observer"I need to notify multiple objects when something changes"
State"I need an object to change its behavior based on its internal state"
Strategy"I need to swap algorithms at runtime"
Template Method"I need a fixed algorithm skeleton with customizable steps"
Visitor"I need to add new operations to existing classes without modifying them"
Interpreter"I need to evaluate sentences in a simple language"

Chain of Responsibility

The Problem

You have a request that could be handled by multiple handlers, but you don't know which one in advance. You want each handler to either process the request or pass it to the next handler.

When this comes up: Middleware pipelines (Express.js, NestJS), event bubbling, approval workflows, error handling chains.

The Analogy

Customer support escalation. You call support, the first agent tries to help. If they can't, they pass you to a supervisor. If the supervisor can't help, they escalate to a manager.

TypeScript Implementation

interface SupportRequest {
  type: "technical" | "billing" | "general";
  message: string;
  severity: "low" | "medium" | "high";
}
 
abstract class SupportHandler {
  private next: SupportHandler | null = null;
 
  setNext(handler: SupportHandler): SupportHandler {
    this.next = handler;
    return handler; // Enables chaining
  }
 
  handle(request: SupportRequest): string {
    if (this.next) {
      return this.next.handle(request);
    }
    return "No handler could process this request.";
  }
}
 
class TechnicalSupport extends SupportHandler {
  handle(request: SupportRequest): string {
    if (request.type === "technical" && request.severity !== "high") {
      return `[Tech Support] Resolved: ${request.message}`;
    }
    return super.handle(request);
  }
}
 
class BillingSupport extends SupportHandler {
  handle(request: SupportRequest): string {
    if (request.type === "billing") {
      return `[Billing] Resolved: ${request.message}`;
    }
    return super.handle(request);
  }
}
 
class ManagerSupport extends SupportHandler {
  handle(request: SupportRequest): string {
    if (request.severity === "high") {
      return `[Manager] Escalated and resolved: ${request.message}`;
    }
    return super.handle(request);
  }
}
 
// Build the chain
const tech = new TechnicalSupport();
const billing = new BillingSupport();
const manager = new ManagerSupport();
tech.setNext(billing).setNext(manager);
 
// Requests flow through the chain
console.log(tech.handle({ type: "technical", message: "App crash", severity: "low" }));
// [Tech Support] Resolved: App crash
 
console.log(tech.handle({ type: "billing", message: "Wrong charge", severity: "medium" }));
// [Billing] Resolved: Wrong charge
 
console.log(tech.handle({ type: "technical", message: "Data loss", severity: "high" }));
// [Manager] Escalated and resolved: Data loss

Real-World Example: Express Middleware

If you've used Express.js, you've used Chain of Responsibility:

app.use(authMiddleware);     // Handler 1: check auth
app.use(rateLimitMiddleware); // Handler 2: rate limit
app.use(loggingMiddleware);   // Handler 3: log request
app.get("/api/users", handler); // Final handler

Each middleware either handles the request or calls next().

Command

The Problem

You need to encapsulate a request as an object, allowing you to parameterize clients with operations, queue requests, log them, or support undo/redo.

When this comes up: Undo/redo systems, task queues, macro recording, transaction logging, remote execution.

The Analogy

A restaurant order ticket. The waiter writes the order (creates the command), hands it to the kitchen (invoker), and the chef executes it. The ticket can be queued, logged, or canceled.

TypeScript Implementation

// Command interface
interface Command {
  execute(): void;
  undo(): void;
  describe(): string;
}
 
// Receiver
class TextEditor {
  private content = "";
 
  insert(text: string, position: number) {
    this.content = this.content.slice(0, position) + text + this.content.slice(position);
  }
 
  delete(position: number, length: number): string {
    const deleted = this.content.slice(position, position + length);
    this.content = this.content.slice(0, position) + this.content.slice(position + length);
    return deleted;
  }
 
  getContent() { return this.content; }
}
 
// Concrete commands
class InsertCommand implements Command {
  constructor(
    private editor: TextEditor,
    private text: string,
    private position: number,
  ) {}
 
  execute() { this.editor.insert(this.text, this.position); }
  undo() { this.editor.delete(this.position, this.text.length); }
  describe() { return `Insert "${this.text}" at ${this.position}`; }
}
 
class DeleteCommand implements Command {
  private deletedText = "";
 
  constructor(
    private editor: TextEditor,
    private position: number,
    private length: number,
  ) {}
 
  execute() { this.deletedText = this.editor.delete(this.position, this.length); }
  undo() { this.editor.insert(this.deletedText, this.position); }
  describe() { return `Delete ${this.length} chars at ${this.position}`; }
}
 
// Invoker — manages command history
class CommandHistory {
  private history: Command[] = [];
  private undone: Command[] = [];
 
  execute(command: Command) {
    command.execute();
    this.history.push(command);
    this.undone = []; // Clear redo stack
  }
 
  undo() {
    const command = this.history.pop();
    if (command) {
      command.undo();
      this.undone.push(command);
      console.log(`Undo: ${command.describe()}`);
    }
  }
 
  redo() {
    const command = this.undone.pop();
    if (command) {
      command.execute();
      this.history.push(command);
      console.log(`Redo: ${command.describe()}`);
    }
  }
}
 
// Usage
const editor = new TextEditor();
const history = new CommandHistory();
 
history.execute(new InsertCommand(editor, "Hello", 0));
history.execute(new InsertCommand(editor, " World", 5));
console.log(editor.getContent()); // "Hello World"
 
history.undo(); // Undo: Insert " World" at 5
console.log(editor.getContent()); // "Hello"
 
history.redo(); // Redo: Insert " World" at 5
console.log(editor.getContent()); // "Hello World"

When to Use It

  • You need undo/redo functionality.
  • You need to queue, schedule, or log operations.
  • You want to decouple the sender of a request from the object that handles it.

Iterator

The Problem

You need to traverse elements of a collection without exposing its underlying structure (array, tree, graph, linked list).

When this comes up: Any time you need to loop over a custom data structure. In TypeScript, this is built into the language via Symbol.iterator.

TypeScript Implementation

class DateRange implements Iterable<Date> {
  constructor(
    private start: Date,
    private end: Date,
  ) {}
 
  [Symbol.iterator](): Iterator<Date> {
    let current = new Date(this.start);
    const end = this.end;
 
    return {
      next(): IteratorResult<Date> {
        if (current <= end) {
          const value = new Date(current);
          current.setDate(current.getDate() + 1);
          return { value, done: false };
        }
        return { value: undefined, done: true };
      },
    };
  }
}
 
// Usage — works with for...of, spread, destructuring
const week = new DateRange(
  new Date("2026-03-20"),
  new Date("2026-03-26"),
);
 
for (const day of week) {
  console.log(day.toLocaleDateString());
}
 
// Also works with spread
const days = [...week];
console.log(`${days.length} days`); // 7 days

Modern Note

In TypeScript/JavaScript, Iterator is a language-level pattern. Any object with [Symbol.iterator]() works with for...of, spread operators, Array.from(), and destructuring. You rarely need to implement the classic GoF version.

Mediator

The Problem

You have many objects that communicate with each other, creating a tangled web of dependencies. You want to centralize the communication through a single coordinator.

When this comes up: Chat rooms, air traffic control, form validation (fields depend on each other), UI component coordination.

The Analogy

An air traffic control tower. Planes don't communicate with each other directly — they all talk to the tower, and the tower coordinates everything. Removing one plane doesn't affect the others.

TypeScript Implementation

interface ChatMediator {
  sendMessage(message: string, sender: ChatUser): void;
  addUser(user: ChatUser): void;
}
 
class ChatUser {
  constructor(
    public name: string,
    private mediator: ChatMediator,
  ) {
    mediator.addUser(this);
  }
 
  send(message: string) {
    console.log(`${this.name} sends: ${message}`);
    this.mediator.sendMessage(message, this);
  }
 
  receive(message: string, from: string) {
    console.log(`${this.name} receives from ${from}: ${message}`);
  }
}
 
class ChatRoom implements ChatMediator {
  private users: ChatUser[] = [];
 
  addUser(user: ChatUser) {
    this.users.push(user);
  }
 
  sendMessage(message: string, sender: ChatUser) {
    this.users
      .filter(user => user !== sender)
      .forEach(user => user.receive(message, sender.name));
  }
}
 
// Usage
const room = new ChatRoom();
const alice = new ChatUser("Alice", room);
const bob = new ChatUser("Bob", room);
const charlie = new ChatUser("Charlie", room);
 
alice.send("Hey everyone!");
// Alice sends: Hey everyone!
// Bob receives from Alice: Hey everyone!
// Charlie receives from Alice: Hey everyone!

Mediator vs. Observer

  • Mediator: Centralized, bidirectional. The mediator knows about all objects and orchestrates them.
  • Observer: Decentralized, one-directional. The subject doesn't know what observers do with the notification.

Memento

The Problem

You need to save and restore an object's previous state without exposing its internal structure.

When this comes up: Undo/redo, game save points, transaction rollback, form state recovery.

The Analogy

The save point in a video game. You capture the exact state of the game at a moment in time. If you die, you can reload that exact state.

TypeScript Implementation

// Memento — stores the state
class EditorMemento {
  constructor(
    private readonly content: string,
    private readonly cursorPosition: number,
    private readonly selectionLength: number,
    public readonly timestamp: Date = new Date(),
  ) {}
 
  getContent() { return this.content; }
  getCursorPosition() { return this.cursorPosition; }
  getSelectionLength() { return this.selectionLength; }
}
 
// Originator — creates and restores from mementos
class DocumentEditor {
  private content = "";
  private cursorPosition = 0;
  private selectionLength = 0;
 
  type(text: string) {
    this.content =
      this.content.slice(0, this.cursorPosition) +
      text +
      this.content.slice(this.cursorPosition + this.selectionLength);
    this.cursorPosition += text.length;
    this.selectionLength = 0;
  }
 
  save(): EditorMemento {
    return new EditorMemento(this.content, this.cursorPosition, this.selectionLength);
  }
 
  restore(memento: EditorMemento) {
    this.content = memento.getContent();
    this.cursorPosition = memento.getCursorPosition();
    this.selectionLength = memento.getSelectionLength();
  }
 
  getState() {
    return { content: this.content, cursor: this.cursorPosition };
  }
}
 
// Caretaker — manages the memento stack
class UndoManager {
  private snapshots: EditorMemento[] = [];
 
  backup(editor: DocumentEditor) {
    this.snapshots.push(editor.save());
  }
 
  undo(editor: DocumentEditor) {
    const memento = this.snapshots.pop();
    if (memento) {
      editor.restore(memento);
    }
  }
}
 
// Usage
const doc = new DocumentEditor();
const undoManager = new UndoManager();
 
undoManager.backup(doc);
doc.type("Hello");
 
undoManager.backup(doc);
doc.type(" World");
 
console.log(doc.getState()); // { content: "Hello World", cursor: 11 }
 
undoManager.undo(doc);
console.log(doc.getState()); // { content: "Hello", cursor: 5 }
 
undoManager.undo(doc);
console.log(doc.getState()); // { content: "", cursor: 0 }

Memento vs. Command (for undo)

  • Command: Each command knows how to undo itself. Lighter weight but requires every command to be reversible.
  • Memento: Stores full state snapshots. Simpler but uses more memory. Better when reversing an operation is complex.

Observer

The Problem

You need a way for one object to notify multiple dependent objects when its state changes, without coupling them together.

When this comes up: Event systems, reactive UI updates, pub/sub messaging, data binding, notifications.

The Analogy

A YouTube subscription. You subscribe to a channel. When they upload a new video, every subscriber gets notified. The channel doesn't know or care what you do with the notification.

TypeScript Implementation

type EventHandler<T> = (data: T) => void;
 
class EventEmitter<Events extends Record<string, unknown>> {
  private listeners = new Map<string, Set<EventHandler<unknown>>>();
 
  on<K extends keyof Events & string>(
    event: K,
    handler: EventHandler<Events[K]>,
  ): () => void {
    if (!this.listeners.has(event)) {
      this.listeners.set(event, new Set());
    }
    this.listeners.get(event)!.add(handler as EventHandler<unknown>);
 
    // Return unsubscribe function
    return () => this.listeners.get(event)?.delete(handler as EventHandler<unknown>);
  }
 
  emit<K extends keyof Events & string>(event: K, data: Events[K]) {
    this.listeners.get(event)?.forEach(handler => handler(data));
  }
}
 
// Define events with type safety
interface ShopEvents {
  "product:added": { id: string; name: string; price: number };
  "product:sold": { id: string; quantity: number };
  "inventory:low": { id: string; remaining: number };
}
 
const shop = new EventEmitter<ShopEvents>();
 
// Subscribe — each observer is independent
const unsubInventory = shop.on("product:sold", ({ id, quantity }) => {
  console.log(`[Inventory] Update stock: ${id} -${quantity}`);
});
 
shop.on("product:sold", ({ id }) => {
  console.log(`[Analytics] Record sale: ${id}`);
});
 
shop.on("inventory:low", ({ id, remaining }) => {
  console.log(`[Alert] Low stock: ${id} (${remaining} left)`);
});
 
// Emit — all subscribers get notified
shop.emit("product:sold", { id: "WIDGET-42", quantity: 1 });
// [Inventory] Update stock: WIDGET-42 -1
// [Analytics] Record sale: WIDGET-42
 
// Unsubscribe when done
unsubInventory();

When to Use It

  • Changes in one object must trigger updates in others.
  • You don't know in advance how many objects will need to react.
  • You want loose coupling between the publisher and subscribers.

State

The Problem

An object must change its behavior when its internal state changes. It should appear as if the object changed its class.

When this comes up: Workflows with distinct phases, connection states (connecting → connected → disconnected), UI elements with modes, game character states.

The Analogy

A vending machine. It behaves differently depending on its state: no coins (waits for money), has coins (lets you select), dispensing (delivers item), sold out (rejects everything).

TypeScript Implementation

interface OrderState {
  name: string;
  pay(order: Order): void;
  ship(order: Order): void;
  deliver(order: Order): void;
  cancel(order: Order): void;
}
 
class PendingState implements OrderState {
  name = "Pending";
  pay(order: Order) {
    console.log("Payment received!");
    order.setState(new PaidState());
  }
  ship() { console.log("Cannot ship — not paid yet."); }
  deliver() { console.log("Cannot deliver — not shipped yet."); }
  cancel(order: Order) {
    console.log("Order cancelled.");
    order.setState(new CancelledState());
  }
}
 
class PaidState implements OrderState {
  name = "Paid";
  pay() { console.log("Already paid."); }
  ship(order: Order) {
    console.log("Order shipped!");
    order.setState(new ShippedState());
  }
  deliver() { console.log("Cannot deliver — not shipped yet."); }
  cancel(order: Order) {
    console.log("Refunding and cancelling.");
    order.setState(new CancelledState());
  }
}
 
class ShippedState implements OrderState {
  name = "Shipped";
  pay() { console.log("Already paid."); }
  ship() { console.log("Already shipped."); }
  deliver(order: Order) {
    console.log("Order delivered!");
    order.setState(new DeliveredState());
  }
  cancel() { console.log("Cannot cancel — already shipped."); }
}
 
class DeliveredState implements OrderState {
  name = "Delivered";
  pay() { console.log("Already paid."); }
  ship() { console.log("Already delivered."); }
  deliver() { console.log("Already delivered."); }
  cancel() { console.log("Cannot cancel — already delivered."); }
}
 
class CancelledState implements OrderState {
  name = "Cancelled";
  pay() { console.log("Order is cancelled."); }
  ship() { console.log("Order is cancelled."); }
  deliver() { console.log("Order is cancelled."); }
  cancel() { console.log("Already cancelled."); }
}
 
class Order {
  private state: OrderState = new PendingState();
 
  setState(state: OrderState) {
    console.log(`  State: ${this.state.name} → ${state.name}`);
    this.state = state;
  }
 
  pay() { this.state.pay(this); }
  ship() { this.state.ship(this); }
  deliver() { this.state.deliver(this); }
  cancel() { this.state.cancel(this); }
}
 
// Usage
const order = new Order();
order.ship();    // Cannot ship — not paid yet.
order.pay();     // Payment received! State: Pending → Paid
order.ship();    // Order shipped! State: Paid → Shipped
order.cancel();  // Cannot cancel — already shipped.
order.deliver(); // Order delivered! State: Shipped → Delivered

State vs. Strategy

They look identical structurally, but:

  • Strategy: The client chooses the algorithm. The strategies usually don't know about each other.
  • State: The states transition between each other. The object's behavior changes automatically as state changes.

Strategy

The Problem

You have a family of algorithms that do the same thing in different ways, and you want to swap them at runtime.

When this comes up: Sorting algorithms, payment processing, validation rules, compression, serialization formats.

The Analogy

GPS navigation. You pick "fastest route," "shortest route," or "avoid highways." Same destination, different algorithm for getting there. You can change the strategy mid-trip.

TypeScript Implementation

interface PricingStrategy {
  calculate(basePrice: number, quantity: number): number;
  describe(): string;
}
 
class RegularPricing implements PricingStrategy {
  calculate(basePrice: number, quantity: number) {
    return basePrice * quantity;
  }
  describe() { return "Regular pricing"; }
}
 
class BulkPricing implements PricingStrategy {
  calculate(basePrice: number, quantity: number) {
    const discount = quantity >= 100 ? 0.25 : quantity >= 50 ? 0.15 : quantity >= 10 ? 0.1 : 0;
    return basePrice * quantity * (1 - discount);
  }
  describe() { return "Bulk pricing (10+ = 10%, 50+ = 15%, 100+ = 25%)"; }
}
 
class SeasonalPricing implements PricingStrategy {
  constructor(private discountPercent: number) {}
 
  calculate(basePrice: number, quantity: number) {
    return basePrice * quantity * (1 - this.discountPercent / 100);
  }
  describe() { return `Seasonal sale (${this.discountPercent}% off)`; }
}
 
class ShoppingCart {
  private items: { name: string; price: number; quantity: number }[] = [];
  private strategy: PricingStrategy = new RegularPricing();
 
  setStrategy(strategy: PricingStrategy) {
    this.strategy = strategy;
    console.log(`Pricing: ${strategy.describe()}`);
  }
 
  addItem(name: string, price: number, quantity: number) {
    this.items.push({ name, price, quantity });
  }
 
  getTotal(): number {
    return this.items.reduce(
      (total, item) => total + this.strategy.calculate(item.price, item.quantity),
      0,
    );
  }
}
 
// Usage — swap algorithms at runtime
const cart = new ShoppingCart();
cart.addItem("Widget", 10, 50);
 
cart.setStrategy(new RegularPricing());
console.log(`Total: $${cart.getTotal()}`); // $500
 
cart.setStrategy(new BulkPricing());
console.log(`Total: $${cart.getTotal()}`); // $425 (15% bulk discount)
 
cart.setStrategy(new SeasonalPricing(20));
console.log(`Total: $${cart.getTotal()}`); // $400 (20% seasonal)

Modern Alternative: Functions as Strategies

In TypeScript, a strategy is often just a function:

type PriceFn = (base: number, qty: number) => number;
 
const regular: PriceFn = (base, qty) => base * qty;
const bulk: PriceFn = (base, qty) => base * qty * (qty >= 10 ? 0.9 : 1);
 
function calculateTotal(items: Item[], priceFn: PriceFn): number {
  return items.reduce((sum, item) => sum + priceFn(item.price, item.quantity), 0);
}

Use classes when strategies carry state or need multiple methods. Use functions when it's a single algorithm.

Template Method

The Problem

You have an algorithm with a fixed structure, but some steps should be customizable by subclasses.

When this comes up: Frameworks with lifecycle hooks, data processing pipelines, report generators, test fixtures.

The Analogy

A recipe template: "Prep → Cook → Plate" is fixed. But each dish has different prep, cooking, and plating steps. The template defines the order; the subclass defines the specifics.

TypeScript Implementation

abstract class DataPipeline {
  // Template method — defines the fixed algorithm
  process(): void {
    const data = this.extract();
    const validated = this.validate(data);
    const transformed = this.transform(validated);
    this.load(transformed);
    this.notify();
  }
 
  // Steps to be customized by subclasses
  abstract extract(): unknown[];
  abstract transform(data: unknown[]): unknown[];
  abstract load(data: unknown[]): void;
 
  // Steps with default implementation (optional override)
  validate(data: unknown[]): unknown[] {
    return data.filter(item => item !== null && item !== undefined);
  }
 
  notify() {
    console.log("Pipeline complete.");
  }
}
 
class CsvToDbPipeline extends DataPipeline {
  extract(): Record<string, string>[] {
    console.log("Reading CSV file...");
    return [
      { name: "Alice", age: "30" },
      { name: "Bob", age: "25" },
    ];
  }
 
  transform(data: Record<string, string>[]) {
    console.log("Converting types...");
    return data.map(row => ({ ...row, age: parseInt(row.age) }));
  }
 
  load(data: unknown[]) {
    console.log(`Inserting ${data.length} rows into database.`);
  }
}
 
class ApiToS3Pipeline extends DataPipeline {
  extract() {
    console.log("Fetching from API...");
    return [{ id: 1, value: 100 }, { id: 2, value: 200 }];
  }
 
  transform(data: { id: number; value: number }[]) {
    console.log("Aggregating...");
    const total = data.reduce((sum, d) => sum + d.value, 0);
    return [{ total, count: data.length, average: total / data.length }];
  }
 
  load(data: unknown[]) {
    console.log(`Uploading ${JSON.stringify(data)} to S3.`);
  }
}
 
// Usage
new CsvToDbPipeline().process();
new ApiToS3Pipeline().process();

Template Method vs. Strategy

  • Template Method uses inheritance — override steps in a subclass.
  • Strategy uses composition — inject the algorithm from outside.

Template Method is appropriate when the algorithm structure is fixed and only specific steps vary.

Visitor

The Problem

You need to add new operations to a group of classes without modifying them. The classes are stable, but the operations change frequently.

When this comes up: AST processing (linters, formatters, compilers), document export (HTML, PDF, Markdown), tax/pricing calculations across different product types.

The Analogy

A tax inspector visiting different types of businesses. The inspector (visitor) has a different audit procedure for each business type. Adding a new type of audit doesn't require changing the businesses.

TypeScript Implementation

// Element hierarchy — stable, rarely changes
interface DocumentNode {
  accept(visitor: DocumentVisitor): void;
}
 
class Heading implements DocumentNode {
  constructor(public level: number, public text: string) {}
  accept(visitor: DocumentVisitor) { visitor.visitHeading(this); }
}
 
class Paragraph implements DocumentNode {
  constructor(public text: string) {}
  accept(visitor: DocumentVisitor) { visitor.visitParagraph(this); }
}
 
class CodeBlock implements DocumentNode {
  constructor(public code: string, public language: string) {}
  accept(visitor: DocumentVisitor) { visitor.visitCodeBlock(this); }
}
 
// Visitor interface — new operations added here
interface DocumentVisitor {
  visitHeading(node: Heading): void;
  visitParagraph(node: Paragraph): void;
  visitCodeBlock(node: CodeBlock): void;
}
 
// Concrete visitors — each is a new operation
class HtmlExporter implements DocumentVisitor {
  private output = "";
 
  visitHeading(node: Heading) {
    this.output += `<h${node.level}>${node.text}</h${node.level}>\n`;
  }
  visitParagraph(node: Paragraph) {
    this.output += `<p>${node.text}</p>\n`;
  }
  visitCodeBlock(node: CodeBlock) {
    this.output += `<pre><code class="${node.language}">${node.code}</code></pre>\n`;
  }
 
  getResult() { return this.output; }
}
 
class WordCounter implements DocumentVisitor {
  public count = 0;
 
  visitHeading(node: Heading) { this.count += node.text.split(/\s+/).length; }
  visitParagraph(node: Paragraph) { this.count += node.text.split(/\s+/).length; }
  visitCodeBlock() { /* Don't count code */ }
}
 
// Usage
const document: DocumentNode[] = [
  new Heading(1, "Design Patterns"),
  new Paragraph("Patterns help solve recurring problems in software design."),
  new CodeBlock('console.log("hello")', "typescript"),
  new Heading(2, "Why Patterns Matter"),
  new Paragraph("They provide a shared vocabulary for developers."),
];
 
const htmlExporter = new HtmlExporter();
document.forEach(node => node.accept(htmlExporter));
console.log(htmlExporter.getResult());
 
const counter = new WordCounter();
document.forEach(node => node.accept(counter));
console.log(`Word count: ${counter.count}`); // 15

When to Use It

  • You have a stable class hierarchy and frequently need to add new operations.
  • You want to keep related operations together instead of spreading them across classes.

When to Avoid It

  • If the class hierarchy changes frequently (adding a new element type means updating every visitor).

Interpreter

The Problem

You need to evaluate sentences in a simple language or notation, defining a grammar and building an interpreter for it.

When this comes up: Query languages, template engines, mathematical expressions, rule engines, configuration DSLs.

TypeScript Implementation

interface Expression {
  interpret(context: Record<string, number>): number;
  toString(): string;
}
 
class NumberLiteral implements Expression {
  constructor(private value: number) {}
  interpret() { return this.value; }
  toString() { return String(this.value); }
}
 
class Variable implements Expression {
  constructor(private name: string) {}
  interpret(context: Record<string, number>) {
    if (!(this.name in context)) throw new Error(`Undefined: ${this.name}`);
    return context[this.name];
  }
  toString() { return this.name; }
}
 
class Add implements Expression {
  constructor(private left: Expression, private right: Expression) {}
  interpret(context: Record<string, number>) {
    return this.left.interpret(context) + this.right.interpret(context);
  }
  toString() { return `(${this.left} + ${this.right})`; }
}
 
class Multiply implements Expression {
  constructor(private left: Expression, private right: Expression) {}
  interpret(context: Record<string, number>) {
    return this.left.interpret(context) * this.right.interpret(context);
  }
  toString() { return `(${this.left} * ${this.right})`; }
}
 
class GreaterThan implements Expression {
  constructor(private left: Expression, private right: Expression) {}
  interpret(context: Record<string, number>) {
    return this.left.interpret(context) > this.right.interpret(context) ? 1 : 0;
  }
  toString() { return `(${this.left} > ${this.right})`; }
}
 
// Usage: "total = price * quantity + tax"
const expr = new Add(
  new Multiply(new Variable("price"), new Variable("quantity")),
  new Variable("tax"),
);
 
console.log(`Expression: ${expr}`);
// Expression: ((price * quantity) + tax)
 
console.log(expr.interpret({ price: 10, quantity: 5, tax: 8 }));
// 58

When to Use It

  • You have a simple, well-defined grammar.
  • Efficiency is not the primary concern (interpreters are typically slower than compiled solutions).
  • The grammar doesn't change frequently.

For complex grammars, consider a proper parser generator instead.

Choosing Between Behavioral Patterns

SituationPattern
"Request should be handled by one of several handlers"Chain of Responsibility
"I need undo/redo or queued operations"Command
"I need to traverse a custom collection"Iterator
"Too many objects talking to each other"Mediator
"I need to save/restore state snapshots"Memento
"One change should notify many dependents"Observer
"Object behavior depends on its current mode"State
"I need interchangeable algorithms"Strategy
"Algorithm structure is fixed, steps vary"Template Method
"I need new operations on a stable class hierarchy"Visitor
"I need to evaluate a simple language"Interpreter

Common Confusions

  • Strategy vs. State: Strategy = client picks the algorithm. State = object transitions automatically.
  • Command vs. Memento: Command remembers how to undo one action. Memento snapshots entire state.
  • Observer vs. Mediator: Observer = decentralized pub/sub. Mediator = centralized coordinator.
  • Template Method vs. Strategy: Template Method = inheritance. Strategy = composition.
  • Chain of Responsibility vs. Decorator: Both chain objects. CoR can stop the chain; Decorator always delegates.

What's Next

You've now covered all 23 Gang of Four design patterns. Review and revisit:

The best way to internalize these patterns is to recognize them in code you already work with. Express middleware is Chain of Responsibility. React's useState is a form of Memento. Event listeners are Observer. You've been using patterns all along — now you have names for them.

Comments

No comments yet. Be the first to comment!