Right, let’s talk about the patterns that make your objects behave themselves. Or, more accurately, that let you dictate how they behave without rewriting them every five minutes. We’re diving into Behavioral Patterns: the Observer, the Strategy, and the Command. These are less about object creation and more about object communication and responsibility. Think of them as the diplomats and special forces of your codebase.

The Observer Pattern: Stop Polling, Start Listening

Ever found yourself writing a setInterval function to constantly check if some data has changed? You’re not just impatient; you’re also wasting CPU cycles. The Observer pattern is the civilized solution. It defines a one-to-many dependency between objects so that when one object (the “subject”) changes state, all its dependents (“observers”) are notified and updated automatically. It’s the software equivalent of signing up for a newsletter instead of refreshing the news website every ten seconds.

Here’s the classic setup. Notice how we use interfaces. This is crucial. It lets any class that implements IObserver listen to any class that implements ISubject. It’s all about contracts, not concrete types.

// The contracts first. This is the protocol.
interface IObserver {
  update(message: string): void;
}

interface ISubject {
  registerObserver(observer: IObserver): void;
  removeObserver(observer: IObserver): void;
  notifyObservers(): void;
}

// The Subject (the one who holds the state and has the news)
class ConcreteSubject implements ISubject {
  private observers: IObserver[] = [];
  private state: number = 0;

  registerObserver(observer: IObserver): void {
    this.observers.push(observer);
  }

  removeObserver(observer: IObserver): void {
    const index = this.observers.indexOf(observer);
    if (index > -1) {
      this.observers.splice(index, 1);
    }
  }

  notifyObservers(): void {
    for (const observer of this.observers) {
      observer.update(`The state is now: ${this.state}`);
    }
  }

  // Some business logic that changes the important state
  setState(newState: number): void {
    this.state = newState;
    this.notifyObservers(); // This is the key line!
  }
}

// An Observer (a interested party)
class ConcreteObserver implements IObserver {
  private name: string;

  constructor(name: string) {
    this.name = name;
  }

  update(message: string): void {
    console.log(`Observer ${this.name} got a message: ${message}`);
  }
}

// Let's see it in action
const subject = new ConcreteSubject();
const observer1 = new ConcreteObserver("A");
const observer2 = new ConcreteObserver("B");

subject.registerObserver(observer1);
subject.registerObserver(observer2);

subject.setState(42);
// Observer A got a message: The state is now: 42
// Observer B got a message: The state is now: 42

Pitfall Alert: The most common mistake is creating memory leaks by forgetting to removeObserver. If an observer is meant to be short-lived but gets registered to a long-lived subject, it can’t be garbage collected. Always provide a cleanup mechanism. In front-end frameworks like React, you’d do this in a useEffect cleanup function.

The Strategy Pattern: Swapping Algorithms Like Trading Cards

The Strategy pattern is arguably the most useful one on this list. It defines a family of algorithms, encapsulates each one, and makes them interchangeable. It lets the algorithm vary independently from the clients that use it. Translation: it lets you stop using if/else or switch statements to choose between behaviors. You just delegate the entire job to a dedicated object.

Imagine you have different ways of compressing a file. Instead of:

if (format === 'ZIP') { compressWithZip() }
else if (format === 'RAR') { compressWithRar() }
// ... and it gets worse with every new format

You create a CompressionStrategy interface and let the caller inject the specific strategy they want.

// The strategy interface
interface CompressionStrategy {
  compress(data: string): string;
}

// Concrete strategies
class ZipStrategy implements CompressionStrategy {
  compress(data: string): string {
    // ... fake ZIP logic
    return `ZIP compressed: ${data.slice(0, 5)}...`;
  }
}

class RarStrategy implements CompressionStrategy {
  compress(data: string): string {
    // ... fake RAR logic
    return `RAR compressed: ${data.slice(0, 3)}...`;
  }
}

// The context class that uses a strategy
class Compressor {
  private strategy: CompressionStrategy;

  constructor(strategy: CompressionStrategy) {
    this.strategy = strategy;
  }

  setStrategy(strategy: CompressionStrategy) {
    this.strategy = strategy; // You can change it at runtime!
  }

  executeCompression(data: string): string {
    return this.strategy.compress(data);
  }
}

// Usage
const data = "This is a long string of data to be compressed.";

const zipCompressor = new Compressor(new ZipStrategy());
console.log(zipCompressor.executeCompression(data)); // ZIP compressed: This ...

const rarCompressor = new Compressor(new RarStrategy());
console.log(rarCompressor.executeCompression(data)); // RAR compressed: Thi ...

The beauty here is the Open/Closed Principle in action. You can add a new SevenZipStrategy without ever touching the Compressor class. You’re not modifying code; you’re extending it.

The Command Pattern: Encapsulating Requests as Objects

This one is a bit more abstract but incredibly powerful. The Command pattern encapsulates a request as an object, thereby letting you parameterize clients with different requests, queue or log requests, and support undoable operations. It’s the patron saint of “I’ll do that later.”

At its heart, it’s about giving you more control over when and how something gets executed. The key is that the object that issues the request (the Invoker) knows nothing about the object that will perform the action (the Receiver). It just knows how to call execute() on the Command object.

// The core Command interface
interface Command {
  execute(): void;
  undo?(): void; // Optional, for undo functionality
}

// The Receiver - the object that knows how to do the actual work
class Light {
  on(): void {
    console.log("Light is on");
  }
  off(): void {
    console.log("Light is off");
  }
}

// Concrete Command classes
class LightOnCommand implements Command {
  private light: Light;

  constructor(light: Light) {
    this.light = light;
  }

  execute(): void {
    this.light.on();
  }

  undo(): void {
    this.light.off();
  }
}

class LightOffCommand implements Command {
  private light: Light;

  constructor(light: Light) {
    this.light = light;
  }

  execute(): void {
    this.light.off();
  }

  undo(): void {
    this.light.on();
  }
}

// The Invoker - doesn't know what the command does, just how to trigger it.
class RemoteControl {
  private commandHistory: Command[] = [];

  pressButton(command: Command): void {
    command.execute();
    this.commandHistory.push(command); // For undo history
  }

  undoLastCommand(): void {
    const lastCommand = this.commandHistory.pop();
    if (lastCommand && lastCommand.undo) {
      lastCommand.undo();
    }
  }
}

// Usage
const light = new Light();
const lightOn = new LightOnCommand(light);
const lightOff = new LightOffCommand(light);

const remote = new RemoteControl();

remote.pressButton(lightOn); // "Light is on"
remote.pressButton(lightOff); // "Light is off"
remote.undoLastCommand(); // "Light is on" (undoes the last command)

This pattern is the foundation for things like job queues, macro recording, and transactional systems. The downside? You can end up with a ton of little command classes for every single action, which feels like overkill for a simple button click. Use it when you need the advanced features—queuing, logging, undo—not for every single operation.