Alright, let’s talk about the structural trio: Adapter, Decorator, and Proxy. These are the patterns you use when you need to change the skin of an object, not its guts. They’re all about composing objects in different ways to change how they interact with the rest of your system, and TypeScript’s type system makes them an absolute joy (or a necessary nightmare) to implement. Let’s get into it.

The Adapter: Your Code’s Universal Translator

Ever tried to plug a British plug into an American socket? You need an adapter. The software equivalent is exactly the same. You have a client that expects a specific interface (AmericanSocket), and you have a useful class that does the job but presents the wrong interface (BritishPlug). The Adapter pattern makes them work together.

The key here is that the Adapter changes the interface of an existing object. The BritishPlug doesn’t know it’s being adapted; it’s just doing its thing. We wrap it in a class that speaks the language our client expects.

Here’s the classic, object-based approach:

// The Target interface that our client code expects
interface AmericanSocket {
  providePower(): number; // returns voltage, e.g., 120
}

// The Adaptee - the useful class with the wrong interface
class BritishPlug {
  public giveElectricity(): number {
    return 240; // Way too much power for our delicate American appliances
  }
}

// The Adapter
class BritishToAmericanAdapter implements AmericanSocket {
  private britishPlug: BritishPlug;

  constructor(plug: BritishPlug) {
    this.britishPlug = plug;
  }

  providePower(): number {
    const britishVoltage = this.britishPlug.giveElectricity();
    // The adapter does the conversion work
    return this.convertVoltage(britishVoltage, 120);
  }

  private convertVoltage(sourceVoltage: number, targetVoltage: number): number {
    console.log(`Converting ${sourceVoltage}V to ${targetVoltage}V...`);
    return targetVoltage;
  }
}

// Client code: blissfully unaware of any British shenanigans
const myAmericanLamp = (socket: AmericanSocket) => {
  const power = socket.providePower();
  console.log(`Lamp is running on ${power} volts. Nice.`);
};

// Making it work
const ukPlug = new BritishPlug();
const adapter = new BritishToAmericanAdapter(ukPlug);
myAmericanLamp(adapter); // Output: Converting 240V to 120V... Lamp is running on 120 volts. Nice.

Why this works: The client is coupled to the AmericanSocket interface, not a concrete class. This means we can slip any object that implements that interface in there, including our adapter. It’s the cornerstone of keeping your code flexible.

Pitfall: Don’t create a “God Adapter” that can adapt anything to anything. That’s just a mess. An adapter should have a single, clear responsibility: making one specific interface work with another.

The Decorator: The Open-Closed Principle’s Best Friend

The Decorator pattern lets you attach new behaviors to objects by placing them inside special wrapper objects. Think of it like a Russian doll, or adding toppings to a pizza. The core object (Pizza) stays the same, but you can wrap it in a CheeseDecorator, then a PepperoniDecorator.

The critical design point is that a decorator implements the same interface as the object it wraps. This way, you can nest them recursively. A decorator isn’t concerned with the core logic, just augmenting it.

// The core Component interface
interface Pizza {
  getDescription(): string;
  getCost(): number;
}

// A Concrete Component
class PlainPizza implements Pizza {
  getDescription(): string {
    return "Plain dough";
  }
  getCost(): number {
    return 4.0;
  }
}

// The Base Decorator class follows the same interface as Pizza.
// This is the key. It holds a reference to a Pizza and delegates
// the core work to it before adding its own behavior.
abstract class PizzaDecorator implements Pizza {
  protected pizza: Pizza;

  constructor(pizza: Pizza) {
    this.pizza = pizza;
  }

  // The decorator delegates the core calls to the wrapped object.
  abstract getDescription(): string;
  abstract getCost(): number;
}

// Concrete Decorators
class CheeseDecorator extends PizzaDecorator {
  getDescription(): string {
    return this.pizza.getDescription() + ", cheese";
  }

  getCost(): number {
    return this.pizza.getCost() + 1.5;
  }
}

class PepperoniDecorator extends PizzaDecorator {
  getDescription(): string {
    return this.pizza.getDescription() + ", pepperoni";
  }

  getCost(): number {
    return this.pizza.getCost() + 2.0;
  }
}

// Let's build a fancy pizza
let myPizza: Pizza = new PlainPizza();
myPizza = new CheeseDecorator(myPizza);
myPizza = new PepperoniDecorator(myPizza); // Decorating the already-decorated pizza

console.log(myPizza.getDescription()); // "Plain dough, cheese, pepperoni"
console.log(myPizza.getCost()); // 7.5

Why this works: It’s a more flexible alternative to subclassing. You can mix and match behaviors at runtime, not just at compile time. It adheres to the Open/Closed Principle—you can extend the system with new decorators without modifying the existing core code.

Pitfall: The order of decoration can matter. Adding cheese then pepperoni is different from pepperoni then cheese, at least in terms of how your description reads. Also, it can lead to a lot of small objects, which might be a concern for performance-critical systems.

The Proxy: The Overprotective Bouncer

A Proxy controls access to another object. It has the same interface as the real object, so the client often doesn’t know it’s talking to a proxy and not the real thing. Why would you do this? Laziness (lazy initialization), security (access control), or logistics (logging, caching).

The proxy stands in for the real object, often creating it only when absolutely necessary.

// The Subject interface, common to both RealSubject and the Proxy
interface ExpensiveDatabase {
  query(key: string): string;
}

// The RealSubject: the real workhorse that does the expensive operation
class RealExpensiveDatabase implements ExpensiveDatabase {
  query(key: string): string {
    console.log(`Performing an extremely expensive database query for ${key}...`);
    // Simulate heavy work
    return `Result for ${key}`;
  }
}

// The Proxy: it stands in for the RealSubject
class ExpensiveDatabaseProxy implements ExpensiveDatabase {
  private realSubject: RealExpensiveDatabase | null = null;
  private cache: Map<string, string> = new Map();

  query(key: string): string {
    // Lazy Initialization: Create the real object only on first use.
    if (!this.realSubject) {
      this.realSubject = new RealExpensiveDatabase();
    }

    // Caching: The proxy adds its own behavior here.
    if (this.cache.has(key)) {
      console.log(`Returning CACHED result for ${key}`);
      return this.cache.get(key)!;
    }

    // If not cached, delegate to the real subject and cache the result.
    const result = this.realSubject.query(key);
    this.cache.set(key, result);
    return result;
  }
}

// Client code works with the interface, unaware of the proxy or real subject.
function clientCode(database: ExpensiveDatabase) {
  // First call hits the real database and caches the result.
  console.log(database.query("user_123"));
  // Second call with the same key returns the cached result.
  console.log(database.query("user_123"));
  // A new key causes another expensive query.
  console.log(database.query("user_456"));
}

const proxy = new ExpensiveDatabaseProxy();
clientCode(proxy);
/* Output:
Performing an extremely expensive database query for user_123...
Result for user_123
Returning CACHED result for user_123
Result for user_123
Performing an extremely expensive database query for user_456...
Result for user_456
*/

Why this works: The client is none the wiser. The proxy encapsulates the logic for access control, caching, or lazy loading, keeping that concern separate from the core business logic of the real subject. This is a fantastic way to implement cross-cutting concerns.

Pitfall: It can introduce unexpected behavior. If the real object has state or side effects, the proxy’s caching might break the client’s expectations. Be transparent about what your proxy is doing, either through documentation or logging. Also, don’t get clever and turn your proxy into a god object; its job is to control access, not become the entire system.