Skip to content
digital garden
Back to Blog

Structural Design Patterns — From Concept to TypeScript

14 min read
design-patternstypescriptsoftware-architectureoop

Structural Design Patterns — From Concept to TypeScript

This is the third article in the Design Patterns series. If you haven't read the overview yet, start there to build your mental map of all 23 patterns.

Structural patterns answer: how do I compose objects into larger structures while keeping those structures flexible? They're about wiring things together — making incompatible things compatible, simplifying complex interfaces, and building tree-like hierarchies.

PatternProblem It Solves
Adapter"I have two incompatible interfaces that need to work together"
Bridge"I need to vary abstraction and implementation independently"
Composite"I need to treat single objects and groups of objects the same way"
Decorator"I need to add behavior to objects at runtime without modifying their class"
Facade"I need a simple interface to a complex subsystem"
Flyweight"I have too many similar objects eating up memory"
Proxy"I need to control access to an object"

Adapter

The Problem

You have an existing class with a useful interface, and another class that expects a different interface. You can't change either class, but you need them to work together.

When this comes up: Integrating third-party libraries, migrating to a new API while supporting the old one, wrapping legacy code.

The Analogy

A power plug adapter when traveling. Your laptop plug is US-style, the wall outlet is EU-style. The adapter sits between them, translating one shape to another without modifying either.

TypeScript Implementation

// Existing analytics service you can't modify (third-party)
class LegacyAnalytics {
  trackEvent(category: string, action: string, label: string) {
    console.log(`[Legacy] ${category}:${action} — ${label}`);
  }
}
 
// Interface your app expects
interface AnalyticsProvider {
  track(event: { name: string; properties: Record<string, string> }): void;
}
 
// Adapter bridges the gap
class LegacyAnalyticsAdapter implements AnalyticsProvider {
  constructor(private legacy: LegacyAnalytics) {}
 
  track(event: { name: string; properties: Record<string, string> }) {
    this.legacy.trackEvent(
      event.properties.category ?? "general",
      event.name,
      JSON.stringify(event.properties),
    );
  }
}
 
// Your app code — doesn't know about the legacy API
function recordPurchase(analytics: AnalyticsProvider) {
  analytics.track({
    name: "purchase",
    properties: { category: "ecommerce", item: "widget", price: "29.99" },
  });
}
 
// Wiring
const legacy = new LegacyAnalytics();
const analytics = new LegacyAnalyticsAdapter(legacy);
recordPurchase(analytics);

When to Use It

  • You need to use a class that doesn't match the interface you need.
  • You want to create a reusable class that cooperates with classes that don't necessarily have compatible interfaces.
  • You're integrating third-party code that you can't modify.

Bridge

The Problem

You have an abstraction that could vary along two dimensions. Without Bridge, you'd need a class for every combination: WindowsButton, MacButton, WindowsCheckbox, MacCheckbox... the number of classes explodes.

When this comes up: Cross-platform UI, rendering engines (SVG vs. Canvas), notification systems (email/SMS × urgent/normal).

The Analogy

A TV remote (abstraction) and a TV (implementation). Any remote can control any TV — you can upgrade the remote without changing the TV, or buy a new TV without getting a new remote.

TypeScript Implementation

// Implementation interface
interface Renderer {
  renderShape(shape: string, x: number, y: number): void;
  renderText(text: string, x: number, y: number): void;
}
 
// Concrete implementations
class SvgRenderer implements Renderer {
  renderShape(shape: string, x: number, y: number) {
    console.log(`<svg><${shape} cx="${x}" cy="${y}" /></svg>`);
  }
  renderText(text: string, x: number, y: number) {
    console.log(`<svg><text x="${x}" y="${y}">${text}</text></svg>`);
  }
}
 
class CanvasRenderer implements Renderer {
  renderShape(shape: string, x: number, y: number) {
    console.log(`ctx.draw${shape}(${x}, ${y})`);
  }
  renderText(text: string, x: number, y: number) {
    console.log(`ctx.fillText("${text}", ${x}, ${y})`);
  }
}
 
// Abstraction
abstract class UIComponent {
  constructor(protected renderer: Renderer) {}
  abstract draw(): void;
}
 
// Refined abstractions
class ChartComponent extends UIComponent {
  draw() {
    this.renderer.renderShape("circle", 100, 100);
    this.renderer.renderShape("rect", 200, 50);
    this.renderer.renderText("Sales Data", 150, 20);
  }
}
 
class FormComponent extends UIComponent {
  draw() {
    this.renderer.renderShape("rect", 0, 0);
    this.renderer.renderText("Submit", 50, 30);
  }
}
 
// Usage — mix and match freely
const svgChart = new ChartComponent(new SvgRenderer());
const canvasChart = new ChartComponent(new CanvasRenderer());
svgChart.draw();    // SVG output
canvasChart.draw(); // Canvas output

When to Use It

  • You want to avoid a class explosion from combining two independent dimensions of variation.
  • You need to switch implementations at runtime.
  • Changes in the implementation should not affect the client code.

Bridge vs. Adapter

  • Adapter is applied after design — it makes existing incompatible classes work together.
  • Bridge is designed upfront — it separates abstraction and implementation so they can evolve independently.

Composite

The Problem

You need to represent part-whole hierarchies — tree structures where individual objects and groups of objects should be treated uniformly.

When this comes up: File systems (files and folders), UI component trees, organization charts, menus with submenus.

The Analogy

A file system. Both a file and a folder are "items." You can move, copy, or delete either one. A folder contains other items — which can be files or more folders, recursively.

TypeScript Implementation

// Component interface — both leaves and composites implement this
interface PricedItem {
  getName(): string;
  getPrice(): number;
  print(indent?: string): void;
}
 
// Leaf
class Product implements PricedItem {
  constructor(private name: string, private price: number) {}
 
  getName() { return this.name; }
  getPrice() { return this.price; }
  print(indent = "") {
    console.log(`${indent}${this.name}: $${this.price}`);
  }
}
 
// Composite
class ProductBundle implements PricedItem {
  private items: PricedItem[] = [];
 
  constructor(private name: string, private discount: number = 0) {}
 
  add(item: PricedItem) { this.items.push(item); }
  remove(item: PricedItem) {
    this.items = this.items.filter(i => i !== item);
  }
 
  getName() { return this.name; }
 
  getPrice(): number {
    const total = this.items.reduce((sum, item) => sum + item.getPrice(), 0);
    return total * (1 - this.discount);
  }
 
  print(indent = "") {
    console.log(`${indent}📦 ${this.name} (${this.discount * 100}% off):`);
    this.items.forEach(item => item.print(indent + "  "));
    console.log(`${indent}  Total: $${this.getPrice().toFixed(2)}`);
  }
}
 
// Usage — bundles within bundles
const keyboard = new Product("Mechanical Keyboard", 79.99);
const mouse = new Product("Gaming Mouse", 49.99);
const mousepad = new Product("Mousepad", 19.99);
 
const peripherals = new ProductBundle("Peripherals Bundle", 0.1);
peripherals.add(keyboard);
peripherals.add(mouse);
peripherals.add(mousepad);
 
const monitor = new Product("4K Monitor", 399.99);
 
const workstation = new ProductBundle("Workstation Bundle", 0.05);
workstation.add(peripherals); // Bundle within a bundle
workstation.add(monitor);
 
workstation.print();
// 📦 Workstation Bundle (5% off):
//   📦 Peripherals Bundle (10% off):
//     Mechanical Keyboard: $79.99
//     Gaming Mouse: $49.99
//     Mousepad: $19.99
//     Total: $134.97
//   4K Monitor: $399.99
//   Total: $508.21

When to Use It

  • You need to represent tree-like hierarchies.
  • You want clients to treat individual objects and compositions uniformly.
  • You want recursive structures where a container can hold other containers.

Decorator

The Problem

You need to add responsibilities to objects dynamically, without modifying their class or creating a subclass for every possible combination.

When this comes up: I/O streams (buffered, encrypted, compressed), middleware pipelines, UI component enhancements, logging/caching wrappers.

The Analogy

Coffee ordering. You start with plain coffee, then wrap it with milk, then sugar, then whipped cream. Each addition wraps the previous order, adding cost and description. You can combine toppings in any order.

TypeScript Implementation

interface Logger {
  log(message: string): void;
}
 
// Base component
class ConsoleLogger implements Logger {
  log(message: string) {
    console.log(message);
  }
}
 
// Base decorator
abstract class LoggerDecorator implements Logger {
  constructor(protected wrapped: Logger) {}
  abstract log(message: string): void;
}
 
// Concrete decorators — each adds one responsibility
class TimestampLogger extends LoggerDecorator {
  log(message: string) {
    const timestamp = new Date().toISOString();
    this.wrapped.log(`[${timestamp}] ${message}`);
  }
}
 
class UpperCaseLogger extends LoggerDecorator {
  log(message: string) {
    this.wrapped.log(message.toUpperCase());
  }
}
 
class JsonLogger extends LoggerDecorator {
  log(message: string) {
    this.wrapped.log(JSON.stringify({ message, level: "info" }));
  }
}
 
// Usage — stack decorators in any combination
let logger: Logger = new ConsoleLogger();
logger = new TimestampLogger(logger);
logger = new UpperCaseLogger(logger);
 
logger.log("user logged in");
// [2026-03-20T10:30:00.000Z] USER LOGGED IN
 
// Different combination
let jsonLogger: Logger = new ConsoleLogger();
jsonLogger = new JsonLogger(jsonLogger);
jsonLogger = new TimestampLogger(jsonLogger);
 
jsonLogger.log("payment processed");
// [2026-03-20T10:30:00.000Z] {"message":"payment processed","level":"info"}

When to Use It

  • You need to add behavior to objects without changing their class.
  • You can't use inheritance (too many combinations, or classes are final).
  • You want to combine behaviors dynamically at runtime.

Decorator vs. Inheritance

With 3 optional behaviors, inheritance requires 8 subclasses (2³). Decorators need just 3 classes, composable in any combination. The trade-off: debugging a deeply wrapped object can be harder.

Facade

The Problem

You have a complex subsystem with many classes, and clients need a simple, unified interface to perform common tasks.

When this comes up: Complex library APIs, multi-step workflows (place order, charge payment, send confirmation, update inventory), system initialization.

The Analogy

A hotel concierge. Instead of calling the restaurant directly, booking a taxi yourself, and purchasing theater tickets separately, you tell the concierge what you want and they handle everything.

TypeScript Implementation

// Complex subsystem classes
class InventoryService {
  check(productId: string): boolean {
    console.log(`Checking inventory for ${productId}`);
    return true;
  }
  reserve(productId: string) {
    console.log(`Reserved ${productId}`);
  }
}
 
class PaymentService {
  charge(amount: number, card: string): string {
    console.log(`Charged $${amount} to ${card}`);
    return "txn_" + Math.random().toString(36).substring(7);
  }
  refund(transactionId: string) {
    console.log(`Refunded ${transactionId}`);
  }
}
 
class ShippingService {
  calculateCost(address: string): number {
    return address.includes("express") ? 15.99 : 5.99;
  }
  createLabel(address: string): string {
    console.log(`Shipping label created for ${address}`);
    return "SHIP-" + Math.random().toString(36).substring(7);
  }
}
 
class EmailService {
  sendConfirmation(email: string, orderId: string) {
    console.log(`Confirmation sent to ${email} for order ${orderId}`);
  }
}
 
// Facade — one simple method for a complex workflow
class OrderFacade {
  private inventory = new InventoryService();
  private payment = new PaymentService();
  private shipping = new ShippingService();
  private email = new EmailService();
 
  placeOrder(order: {
    productId: string;
    price: number;
    card: string;
    address: string;
    email: string;
  }): { orderId: string; trackingId: string } {
    // Step 1: Check inventory
    if (!this.inventory.check(order.productId)) {
      throw new Error("Out of stock");
    }
    this.inventory.reserve(order.productId);
 
    // Step 2: Calculate total and charge
    const shippingCost = this.shipping.calculateCost(order.address);
    const total = order.price + shippingCost;
    const txnId = this.payment.charge(total, order.card);
 
    // Step 3: Create shipping label
    const trackingId = this.shipping.createLabel(order.address);
 
    // Step 4: Send confirmation
    const orderId = "ORD-" + Date.now();
    this.email.sendConfirmation(order.email, orderId);
 
    return { orderId, trackingId };
  }
}
 
// Client code — blissfully simple
const shop = new OrderFacade();
const result = shop.placeOrder({
  productId: "WIDGET-42",
  price: 29.99,
  card: "****1234",
  address: "123 Main St",
  email: "alice@example.com",
});

When to Use It

  • You need a simplified interface to a complex subsystem.
  • You want to layer your subsystems and define entry points for each layer.
  • You want to reduce coupling between client code and subsystem internals.

Important Note

A Facade doesn't hide the subsystem — clients can still use subsystem classes directly when they need fine-grained control. The Facade is an additional interface, not a replacement.

Flyweight

The Problem

You need a huge number of similar objects, and creating a unique instance for each one would consume too much memory.

When this comes up: Text rendering (each character), game worlds (trees, particles, bullets), spreadsheet cells, map markers.

The Analogy

A font renderer. The shape of the letter "e" is stored once in memory. When the renderer needs to display 10,000 "e" characters, it reuses that one shape — only the position, size, and color differ.

TypeScript Implementation

// Flyweight — stores shared (intrinsic) state
class TreeType {
  constructor(
    public readonly name: string,
    public readonly color: string,
    public readonly texture: string, // Imagine this is a large image
  ) {}
 
  draw(x: number, y: number) {
    console.log(`Drawing ${this.name} at (${x},${y}) — ${this.color}`);
  }
}
 
// Flyweight factory — ensures sharing
class TreeTypeFactory {
  private types = new Map<string, TreeType>();
 
  getType(name: string, color: string, texture: string): TreeType {
    const key = `${name}-${color}-${texture}`;
    if (!this.types.has(key)) {
      this.types.set(key, new TreeType(name, color, texture));
      console.log(`Created new TreeType: ${key}`);
    }
    return this.types.get(key)!;
  }
 
  get count() { return this.types.size; }
}
 
// Context — stores unique (extrinsic) state
class Tree {
  constructor(
    private x: number,
    private y: number,
    private type: TreeType, // Shared reference
  ) {}
 
  draw() {
    this.type.draw(this.x, this.y);
  }
}
 
// Usage — 10,000 trees but only a few TreeType objects
const factory = new TreeTypeFactory();
const forest: Tree[] = [];
 
for (let i = 0; i < 10000; i++) {
  // Only 3 unique types, shared across 10,000 trees
  const types = [
    { name: "Oak", color: "green", texture: "oak.png" },
    { name: "Pine", color: "darkgreen", texture: "pine.png" },
    { name: "Birch", color: "lightgreen", texture: "birch.png" },
  ];
  const t = types[i % 3];
  const type = factory.getType(t.name, t.color, t.texture);
 
  forest.push(new Tree(Math.random() * 1000, Math.random() * 1000, type));
}
 
console.log(`Trees: ${forest.length}, Unique types: ${factory.count}`);
// Trees: 10000, Unique types: 3

When to Use It

  • Your application creates a very large number of similar objects.
  • Objects contain duplicate state that can be extracted and shared.
  • Memory is a concern and many objects can share common data.

The Key Distinction

  • Intrinsic state: Shared, immutable, stored in the flyweight (name, color, texture).
  • Extrinsic state: Unique per instance, stored externally (x, y coordinates).

Proxy

The Problem

You need to control access to an object — adding lazy initialization, access control, logging, caching, or remote access without changing the object's interface.

When this comes up: Lazy loading heavy resources, access control, request caching, logging/monitoring, remote service calls.

The Analogy

A credit card is a proxy for your bank account. It has the same interface (you can "pay" with both), but the credit card adds access control (PIN), logging (transaction history), and remote access (the bank doesn't need to be physically present).

TypeScript Implementation

interface ImageLoader {
  display(): void;
  getUrl(): string;
}
 
// Real subject — expensive to create
class HighResImage implements ImageLoader {
  private data: string;
 
  constructor(private url: string) {
    // Simulates expensive loading
    console.log(`Loading high-res image from ${url}...`);
    this.data = `[${url} image data — 50MB]`;
  }
 
  display() {
    console.log(`Displaying: ${this.data}`);
  }
 
  getUrl() { return this.url; }
}
 
// Proxy — controls access to the real subject
class LazyImageProxy implements ImageLoader {
  private realImage: HighResImage | null = null;
 
  constructor(private url: string) {
    // No loading here — that's the whole point
    console.log(`Created proxy for ${url} (not loaded yet)`);
  }
 
  display() {
    // Load only when actually needed
    if (!this.realImage) {
      this.realImage = new HighResImage(this.url);
    }
    this.realImage.display();
  }
 
  getUrl() { return this.url; }
}
 
// Usage
const images: ImageLoader[] = [
  new LazyImageProxy("/photos/vacation-1.jpg"),
  new LazyImageProxy("/photos/vacation-2.jpg"),
  new LazyImageProxy("/photos/vacation-3.jpg"),
];
// Output: 3x "Created proxy for ... (not loaded yet)"
// No actual loading happened yet!
 
// User scrolls to first image
images[0].display();
// Now loads: "Loading high-res image from /photos/vacation-1.jpg..."
// Then displays: "Displaying: [/photos/vacation-1.jpg image data — 50MB]"
 
// Display again — no reload
images[0].display();
// Just displays — already loaded

Proxy Variants

VariantWhat It DoesExample
Virtual ProxyLazy initializationLoad heavy resource only when needed
Protection ProxyAccess controlCheck permissions before delegating
Caching ProxyCache resultsReturn cached API response if fresh
Logging ProxyAudit trailLog every method call before delegating

Proxy vs. Decorator

They look identical structurally — both wrap an object and implement its interface. The difference is intent:

  • Decorator adds new behavior (more functionality).
  • Proxy controls access (same functionality, but guarded).

Choosing Between Structural Patterns

SituationPattern
"This API doesn't match the interface I need"Adapter
"I have two dimensions of variation"Bridge
"I need to handle trees/hierarchies uniformly"Composite
"I need to add optional behavior at runtime"Decorator
"This subsystem is too complex to use directly"Facade
"I have thousands of similar objects eating memory"Flyweight
"I need to control access, add caching, or lazy-load"Proxy

Patterns That Look Similar

  • Adapter vs. Bridge: Adapter fixes existing incompatibility; Bridge prevents it by design.
  • Adapter vs. Facade: Adapter wraps one object; Facade wraps an entire subsystem.
  • Decorator vs. Proxy: Decorator adds behavior; Proxy controls access.
  • Composite vs. Decorator: Both use recursive composition. Composite lets you treat groups as individuals; Decorator adds responsibilities.

What's Next

Continue the series:

Comments

No comments yet. Be the first to comment!