Skip to main content

Behavioral Patterns in TypeScript

How do objects behave? At runtime, their life is about instantiating, calling, and being part of processes bigger than themselves. It is a complex and critical matter involving our programs' flow control and data manipulation. Behavioral patterns guide us on how instances communicate with others.

Well-designed applications end up with dozens of classes. Some are like legacy-sealed stones, while others seem living creatures evolving. Sometimes we must deal with complex subsystems or add complexity to a simple buddy.

Foto de <a href="https://unsplash.com/@alinnnaaaa?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText">Alina Grubnyak</a> en <a href="https://unsplash.com/es/s/fotos/network?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText">Unsplash</a>


This article will show how to solve these situations with four behavioral patterns with examples in TypeScript. Let's dive in!

🎒 Prerequisites

To complete this tutorial, you will need the following:

  • A local development environment for TypeScript

  • Basic knowledge of Object-Oriented Programming

🎖️ Strategy

Decouples third-party libraries (or legacy code) from the application

The problem

class Logger {
  log(entry: LogEntry): string {
    const message = this.getMessage(entry);
    // ! 😱 new log levels require code changes
    switch (entry.level) {
      case "info":
        console.log(message);
        break;
      case "debug":
        console.debug(message);
        break;
      case "warn":
        console.warn(message);
        break;
      case "error":
        console.error(message);
        break;
      default:
        console.error(message);
    }
    return message;
  }
  private getMessage(entry: LogEntry) {
    // ! 😱 repeated dirty code
    switch (entry.level) {
      case "info":
        return `💁🏼‍♂️: ${entry.message}`;
      case "debug":
        return `🐾: ${entry.message}`;
      case "warn":
        return `⚠️: ${entry.message}`;
      case "error":
        return `💣: ${entry.message} at ${new Date().toISOString()}}`;
      default:
        return `Unknown log level: ${entry.level}`;
    }
  }
}

class App {
  private logger = new Logger();

  public run() {
    this.logger.log({ level: "info", message: "App started!" });
    this.logger.log({ level: "debug", message: "I was here" });
    this.logger.log({ level: "warn", message: "Heads up" });
    this.logger.log({ level: "error", message: "Fatal exception" });
  }
}

Sometimes there are several ways to execute a business process. You must code all those situations, and your program will choose one at runtime. That decision should be encapsulated and never repeated in the code.

The solution with the Strategy pattern

// *  😏 an interface is a contract
interface LogStrategy {
  log(entry: LogEntry): string;
}

// *  😏 different strategies implement the contract

export class InfoLogStrategy implements LogStrategy {
  log(entry: LogEntry) {
    const message = `💁🏼‍♂️: ${entry.message}`;
    console.log(message);
    return message;
  }
}

export class DebugLogStrategy implements LogStrategy {
  log(entry: LogEntry) {
    const message = `🐾: ${entry.message}`;
    console.debug(message);
    return message;
  }
}

export class WarnLogStrategy implements LogStrategy {
  log(entry: LogEntry) {
    const message = `⚠️: ${entry.message}`;
    console.warn(message);
    return message;
  }
}

export class ErrorLogStrategy implements LogStrategy {
  log(entry: LogEntry) {
    const message = `💣: ${entry.message} at ${new Date().toISOString()}}`;
    console.error(message);
    return message;
  }
}

export class Logger implements LogStrategy {
  // *  😏 a map of strategies
  static strategies = new Map<LogLevel, LogStrategy>([
    ["info", new InfoLogStrategy()],
    ["warn", new WarnLogStrategy()],
    ["error", new ErrorLogStrategy()],
  ]);

  log(entry: LogEntry) {
    const strategy = Logger.strategies.get(entry.level);
    if (strategy) {
      return strategy.log(entry);
    } else {
      const message = `Unknown log level: ${entry.level}`;
      console.warn(message);
      return message;
    }
  }
}

class App {
  private logger = new Logger();

  public run() {
    this.logger.log({ level: "info", message: "App started!" });
    // *  😏 change the strategy map at runtime
    Logger.strategies.set("debug", new DebugLogStrategy());
    this.logger.log({ level: "debug", message: "I was here" });
    this.logger.log({ level: "warn", message: "Heads up" });
    this.logger.log({ level: "error", message: "Fatal exception" });
  }
}

To do so, you can use the strategy pattern that requires creating an interface defining the contract of the process. Your code will depend on that abstraction. Then, you can create several implementations of that interface. Finally, you should use an intelligent factory (the strategy) and choose the concrete one at run time.

Strategies can be added or removed at runtime without modifying the code that uses them.

The strategy pattern respects the SOLID principles, Open/Closed by using interfaces, Interface Segregation depending on abstractions, and Dependency Inversion by choosing the implementations outside its consumers.

👁️ Observer

Decouples event emitters from event processors notifying changes to subscribers

The problem

// ! ❌ Bad example not using an observable
class Logger {
  log(data: object): void {
    console.log(data);
  }
}

// ! 😱 Agency depends on Logger
class Agency {
  private bookings: object[] = [];
  constructor(private logger: Logger) {}

  addBooking(booking: object) {
    this.bookings.push(booking);
    // ! 😱 what if we want to send payment or messages or anything else?
    this.logger.log({ event: "booking-created: ", data: booking });
  }
}

// ! 😱 App is aware of Agency and Logger
export class App {
  main() {
    const logger = new Logger();
    const agency = new Agency(logger);
    agency.addBooking({ trip: "Paris", price: 100 });
  }
}

Wherever you have objects, you have some communication between them. The simple way is by calling a method of the other object, called direct communication. The problem with this is that the objects are tightly coupled.

Things get worse when the caller needs not one but many objects to be notified of a change, the so-called broadcast communication.

The solution with the Observer pattern

// * ✅ Observer solution

// * 😏 the observer contract is a function used as a listener or callback
type Observer = (data: object) => void;

// * 😏 the observable contract is a set of methods to subscribe, unsubscribe and publish events
interface Observable {
  subscribe(eventName: string, observer: Observer): void;
  unsubscribe(eventName: string, observer: Observer): void;
  publish(eventName: string, eventArgs: object): void;
}

// * 😏 the event bus implements the observable contract
export class EventBus implements Observable {
  private subscriptions: Map<string, Observer[]> = new Map();

  subscribe(eventName: string, listener: Observer): void {
    let handlers = this.subscriptions.get(eventName);
    if (!handlers) {
      handlers = [];
      this.subscriptions.set(eventName, handlers);
    }
    handlers.push(listener);
  }

  unsubscribe(eventName: string, listener: Observer): void {
    const handlers = this.subscriptions.get(eventName);
    if (handlers) {
      const index = handlers.indexOf(listener);
      handlers.splice(index, 1);
    }
  }

  publish(eventName: string, eventArgs: object): void {
    const handlers = this.subscriptions.get(eventName);
    if (handlers) {
      handlers.forEach((handler) => handler(eventArgs));
    }
  }
}

// * 😏 the logger is an observer
interface LoggerObserver {
  log: Observer;
}
class Logger implements LoggerObserver {
  log(data: object): void {
    console.log(data);
  }
}

// * 😏 the agency is an observable
class Agency extends EventBus implements Observable {
  private bookings: object[] = [];

  addBooking(booking: object) {
    this.bookings.push(booking);
    this.publish("booking-created", booking);
  }
}

// * 😏 the application can subscribe the logger to agency events
export class App {
  main() {
    const agency = new Agency();
    const logger = new Logger();
    // * 😏 agency and logger are unrelated
    agency.subscribe("booking-created", logger.log);
    agency.addBooking({ trip: "Paris", price: 100 });
  }
}

In the Observer pattern, communication is one-way. The objects that send notifications are called emitters or publishers. The objects that receive notifications are called observers or processors. The observers register with the emitters to be notified of messages (the arguments).

Between the emitter and the observer, there is an intermediate responsible for notifying the observers, keeping track of them, and notifying them when a message is received.

We can call it a subject when notifies changes in specific data or an event bus (or hub, aggregator, dispatcher) when notifies events which are pairs of name and data similar to method calls.

🪖 Command

Takes the definition of actions from methods to classes increasing its versatility

The problem

// ! ❌ Bad example not using a command

export class LightBulb {
  private isOn = false;
  private brightness = 0;

  // 😏 a business class not aware of the command pattern
  // nothing to see here

  turnOn(brightness = 10): void {
    this.isOn = true;
    this.brightness = brightness;
    console.log("Light turned on, brightness", this.brightness);
  }

  turnOff(): void {
    this.isOn = false;
    this.brightness = 0;
    console.log("Light turned off, brightness", this.brightness);
  }
}

export class RemoteControl {
  // ! 😱 tight coupling with the light bulb
  constructor(private lightBulb: LightBulb) {}
  pressOnButton(brightness: number): void {
    this.lightBulb.turnOn(brightness);
  }
  pressOffButton(): void {
    this.lightBulb.turnOff();
  }
}

// Usage
const lightBulb = new LightBulb();
const remoteControl = new RemoteControl(lightBulb);
remoteControl.pressOnButton(20); // Light turned on, brightness 20
remoteControl.pressOffButton(); // Light turned off, brightness 0

Any request between objects (direct method callings) can be encapsulated as an object with a method to execute the command. This is the root of the Command pattern. You can enhance the functionality by adding parameters or methods to support the defer, undo, and redo executions.

The solution with the Command pattern

// * ✅ Command solution

// * 😏 Command interface
export interface Command {
  execute(args?: unknown): void;
  undo(): void;
}

// * 😏 Concrete command class 1
export class LightOnCommand implements Command {
  constructor(private lightBulb: LightBulb) {}

  execute(args?: number): void {
    this.lightBulb.turnOn(args);
  }

  undo(): void {
    this.lightBulb.turnOff();
  }
}

// * 😏 Concrete command class 2
export class LightOffCommand implements Command {
  constructor(private lightBulb: LightBulb) {}

  execute(): void {
    this.lightBulb.turnOff();
  }

  undo(): void {
    this.lightBulb.turnOn();
  }
}

// * 😏 Invoker class
export class RemoteControl {
  private onCommand: Command;
  private offCommand: Command;

  // ToDo: ad history of commands
  // ToDo: add serialization/deserialization for later or remote use

  setOnCommand(command: Command): void {
    this.onCommand = command;
  }

  setOffCommand(command: Command): void {
    this.offCommand = command;
  }

  pressOnButton(brightness: number): void {
    this.onCommand.execute(brightness);
  }

  pressOffButton(): void {
    this.offCommand.execute();
  }
}

// * 😏 Receiver class
export class LightBulb {
  private isOn = false;
  private brightness = 0;

  // * 😏 a business class not aware of the command pattern

  turnOn(brightness = 10): void {
    this.isOn = true;
    this.brightness = brightness;
    console.log("Light turned on, brightness", this.brightness);
  }

  turnOff(): void {
    this.isOn = false;
    this.brightness = 0;
    console.log("Light turned off, brightness", this.brightness);
  }
}

// * 😏Usage
const lightBulb = new LightBulb();
const remoteControl = new RemoteControl();
const lightOnCommand = new LightOnCommand(lightBulb);
const lightOffCommand = new LightOffCommand(lightBulb);
remoteControl.setOnCommand(lightOnCommand);
remoteControl.setOffCommand(lightOffCommand);
remoteControl.pressOnButton(6); // prints "Light turned on, brightness" 6
remoteControl.pressOffButton(); // prints "Light turned off, brightness" 0

📚 Template

Ensure common behavior and allow custom implementations

The problem

// ! ❌ Bad example not using a a template

export class EnrollActivity {
  public execute(destination: string): string {
    // ! 😱 repeated steps
    let businessResult = "";
    try {
      console.log("ℹ️  transaction started");
      const paymentResult = "💸  Paying Activity to " + destination;
      console.log("ℹ️  transaction processed");
      businessResult = "✍🏼 Booking Activity " + paymentResult;
      console.log("ℹ️  action done");
      console.warn("📧 Activity booked " + businessResult);
      console.log("ℹ️  notification sent");
    } catch (error) {
      console.log("ℹ️ 😵‍💫 error: " + error);
    }
    return businessResult;
  }
}

// ToDo: cancel enrollment

// ToDo: confirm activity

export class CancelActivity {
  public execute(destination: string): string {
    // ! 😱 repeated steps
    let businessResult = "";
    try {
      console.log("ℹ️  transaction started");
      const paymentResult = "🤑  Refunding Activity to " + destination;
      console.log("ℹ️  transaction processed");
      businessResult = "😭  Cancelling Activity " + paymentResult;
      console.log("ℹ️  action done");
      console.warn("✅ Done " + businessResult);
      console.log("ℹ️  notification sent");
    } catch (error) {
      console.log("ℹ️ 😵‍💫 error: " + error);
    }
    return businessResult;
  }
}

export class Client {
  private enrolling = new EnrollActivity();
  private cancel = new CancelActivity();
  public run(): void {
    this.enrolling.execute("Snorkeling on the Red Sea");
    this.cancel.execute("Snorkeling on the Red Sea");
  }
}

const client = new Client();
client.run();

So you have an algorithm common to several processes, usually involving generic aspects like logging, error handling, etc. The Template Method pattern allows you to define the expected behavior in a base class and the specific conduct in subclasses.

The solution with the Template pattern

// * ✅ Command solution

export interface BusinessTemplateInterface {
  execute(payload: string): string;
}

export abstract class BusinessTemplate implements BusinessTemplateInterface {
  public execute(payload: string): string {
    try {
      // * 😏 hard coded instrumentation steps
      console.log("ℹ️  transaction started");
      const paymentResult = this.processTransaction(payload);
      console.log("ℹ️  transaction processed");
      const businessResult = this.doBusinessAction(paymentResult);
      console.log("ℹ️  action done");
      this.sendNotification(businessResult);
      console.log("ℹ️  notification sent");
      return businessResult;
    } catch (error) {
      // * 😏 hard coded common step
      console.log("ℹ️ 😵‍💫 error: " + error);
      return "";
    }
  }
  // * 😏 mandatory steps
  protected abstract processTransaction(payload: string): string;
  protected abstract doBusinessAction(payload: string): string;
  // * 😏 optional step with default implementation if not overridden
  protected sendNotification(payload = ""): void {
    console.warn("✅ Done " + payload);
  }
}

// * 😏 custom implementation steps while enrollment or cancellation

export class EnrollActivity extends BusinessTemplate {
  protected processTransaction(destination: string): string {
    return "💸  Paying Activity to " + destination;
  }
  protected doBusinessAction(payment: string): string {
    return "✍🏼 Booking Activity " + payment;
  }
  protected override sendNotification(booking: string): void {
    console.warn("📧 Activity booked " + booking);
  }
}

export class CancelActivity extends BusinessTemplate {
  protected processTransaction(destination: string): string {
    return "🤑  Refunding Activity " + destination;
  }
  protected override doBusinessAction(refund: string): string {
    return "😭  Cancelling Activity " + refund;
  }
}

export class Client {
  // * 😏 you can depend on abstraction not implementation
  private enrolling: BusinessTemplateInterface = new EnrollActivity();
  private cancel: BusinessTemplate = new CancelActivity();
  public run(): void {
    this.enrolling.execute("Snorkeling on the Red Sea");
    this.cancel.execute("Snorkeling on the Red Sea");
  }
}

const client = new Client();
client.run();

This pattern leverages the inheritance mechanism to define the common behavior in a base class and the specific behavior in subclasses. The base class defines the algorithm, and the subclasses implement the specific behavior while respecting the Liskov Substitution Principle.

Frameworks use this pattern to call hooks at specific points of the object`s life cycle.

🏦 Memento

Stores and restores the state of an object without exposing its internal structure

The problem.

// ! ❌ Bad example not using a memento
export class Activity {
  private title: string;
  private attendees: string[] = [];
  private places: number;
  // Suppose more state is added to the Activity class(money, date, etc.)

  constructor(title: string, places: number) {
    this.title = title;
    this.places = places;
  }

  get availablePlaces(): number {
    return this.places - this.attendees.length;
  }
  enroll(name: string): void {
    if (this.attendees.length >= this.places) {
      throw new Error("No more places available on " + this.title);
    }
    this.attendees.push(name);
  }
  cancel(): void {
    // ! 😱 cancel logic is needed here
    if (this.attendees.length === 0) {
      return;
    }
    this.attendees.pop();
  }
}

The Memento pattern is like a state manager that allows you to store in a repository the state of an object for restoring it later. This is useful when you need to undo or redo an operation.

The solution with the Memento pattern.

The trick is to do it without exposing the object's internal structure. This allows us to work with private attributes and restore them in a particular order (like a builder).

🌅 Conclusion

When you must communicate objects at runtime to achieve a goal, you will repeatedly find yourself in certain situations. There are proven solutions to those problems, and they are called behavioral patterns. In this article, I showed you their implementation in TypeScript. As a cheat sheet, I remind you of the patterns and their use cases.

  • Strategy: To choose an implementation at run time and use it.

  • Observer: To notify changes to its subscribers.

  • Command: To encapsulate actions and execute them at a later time.

  • Template: To define standard behavior and allow custom implementations.

  • Memento: To store and restore the state of an object.

Other behavioral patterns you can find are:

  • State: To change the behavior of an object based on its internal state.
  • Mediator: To reduce coupling between classes that communicate with each other.
  • Chain of Responsibility: List of classes that handle a process sequentially.
  • Iterator: To traverse a collection without exposing its internal structure.
  • Interpreter: To create a high-level business language.
  • Visitor: To add new behaviors to a class without changing it.

I hope you enjoyed it and learned something new. If you have any questions or suggestions, please leave a comment below.

learn, code, enjoy, repeat

Alberto Basalo

Popular posts from this blog

Fine-tune ESLint rules to write better TypeScript

Writing clean code is a lot easier with the right tools well configured. ESLint is a static analyzer for JavaScript programs. So what does that mean and what can it do for my TypeScript code? First things first, by static it means that you don't need to run or even compile a program to parse it. Analyzing means that it checks your code searching for flaws or bad metrics that can cause problems in the long run. The ESLint is a linter that runs before the compiler. Witch runs before your automated tests and the end-user. Is your first line of defense and should be as integrated with your editor as possible. For the details about integrating ESLint and VSCode, you can read my article to configure basic extensions But ESLint would not be so popular if it was not extensible and configurable . And there is the real power and complexity. In this article, I'll walk you through

10 commandments to naming and writing clean code with TypeScript

A code writer is like any other writer; writes to be read and understood . To do so it must obey certain laws. They are so important that must be followed religiously. Being a good writer is an art. But you can be a better programmer by knowing and applying a set of standards. In this guide, I will show you TypeScript samples for the  10 rules of clean naming . When you're finished, you'll be able to write heavenly code.Let there be code naming conventions 1️⃣ Be Descriptive Add value without being repetitive. Context is key. Prefer clarity over saving space. We are not in the ‘90s’ anymore. // ❌ const width = 5 ; class Image { imageWidth = width ; } // ✅ const imageWidth = 5 ; class Image { width = imageWidth; } 2️⃣ Be Meaningful Use the same word for the same concept. Create a dictionary for business and infrastructure common words. // ❌ getClient () {} readProvider () {} postCustomer () {} // ✅ getClient ()

How to configure VSCode to code better TypeScript

Writing a more readable code is your first goal as a programmer. This initial Visual Studio Code setup makes your job easier. Any good worker must know and customize his tools for the job at hand. With minor tweaks, VSCode is the perfect tool to write TypeScript . There are plenty of guides to configure VSCode . Most of them include a lot of extensions. But almost none starts from the very beginning, with no extension at all. I am an enthusiast of writing clean code , so I do my best to promote and facilitate this goal, and I am sure that this can help you to write better code. In this post, you will learn how to adapt VS Code to write better TypeScript code, even without any extension. And above all, you will get tips to adopt good habits and detect programming vices. 🎒 Prerequisites To complete this tutorial, you will need: A local copy of Visual Studi