Beyond Basics: Transform Your Typescript Codebase with Command Design Pattern

The design pattern Command is a behavioral design pattern that encapsulates requests as objects. The primary purpose of the pattern is to separate a request from its execution, thus allowing a more flexible and modular design. This pattern is especially useful in applications that need:

  • UI to interact with complex business logic

  • Undo/redo functionality

  • Transactional behavior with rollback

In this blog post, we will explore how to use the Command pattern in Typescript and its benefits and drawbacks.

Implementing the Command pattern

Let's start with a simple example to illustrate the Command pattern. Suppose we have a simple calculator application that supports adding and subtracting numbers. We can use the Command pattern to encapsulate these operations as commands.

First, we need to define the Command interface:

interface Command {   
  execute(): void;
  undo(): void;
}

This interface defines two methods: execute() and undo(). As their names suggest, the execute() method is used to execute the command, while the undo() method is used to undo the command.

Next, we can define the AddCommand and SubtractCommand classes that implement the interface:

class AddCommand implements Command {
  constructor(
    private calculator: Calculator,
    private value: number
  ) {}

  execute(): void {
    this.calculator.add(this.value);
  }

  undo(): void {
    this.calculator.subtract(this.value);
  }
}

class SubtractCommand implements Command {
  constructor(
    private calculator: Calculator, 
    private value: number
  ) {}

  execute(): void {
    this.calculator.subtract(this.value);
  }

  undo(): void {
    this.calculator.add(this.value);
  }
}

As you can see, the execute() method calls the corresponding method on the calculator object, while the undo() method reverses the operation. The important part to notice is that both classes encapsulate behavior relevant to their operation. Nothing less, nothing more.

Finally, we can define the Calculator class:

class Calculator {
  private value = 0;

  add(value: number): void {
    this.value += value;
  }

  subtract(value: number): void {
    this.value -= value;
  }
}

The Calculator class represents the receiver in the Command pattern and provides the add and subtract operations.

Now we can use our implementation of the Command pattern to add and subtract numbers:

const calculator = new Calculator();

const addCommand = new AddCommand(calculator, 5);
const subtractCommand = new SubtractCommand(calculator, 3);

addCommand.execute(); // calculator value is now 5
subtractCommand.execute(); // calculator value is now 2

// undo subtract command
subtractCommand.undo(); // calculator value is now 5

// undo add command
addCommand.undo(); // calculator value is now 0

In this example, we create instances of the AddCommand and SubtractCommand classes and pass them the Calculator object and values to add or subtract. We then execute the commands using the execute() method, which calls the corresponding method on the calculator object. Finally, we undo the commands using the undo() method, which reverses the operation and returns the calculator to its initial state.

You can find the full example in this CodeSandbox.

Of course, this simple implementation has some flaws; for example, you can undo a command without ever calling execute() method. Make sure to handle such cases when using the pattern in your applications.

Beyond Basics

Let's look at a more practical example of the Command pattern in Typescript. That can be the implementation of an e-commerce application. Suppose we have an application that allows users to add, remove, and modify items in their shopping cart. We can use the Command pattern to encapsulate these operations as commands.

First, let's define the Command interface the same as we did before:

interface Command {
  execute(): void;
  undo(): void;
}

Next, we can define the concrete command classes. Let's start with AddToCartCommand and RemoveFromCartCommand:

class AddToCartCommand implements Command {
  constructor(
    private cart: Cart,
    private item: Item
  ) {}

  execute(): void {
    this.cart.add(this.item);
  }

  undo(): void {
    this.cart.remove(this.item);
  }
}

class RemoveFromCartCommand implements Command {
  constructor(
    private cart: Cart, 
    private item: Item
  ) {}

  execute(): void {
    this.cart.remove(this.item);
  }

  undo(): void {
    this.cart.add(this.item);
  }
}

Same as with the Calculator, the straightforward encapsulation of adding and removing items from a cart. Now we can proceed with a more complex modification operation that allows modifying the number of items of the same kind in the cart.

class ModifyCartItemCommand implements Command {
   private previousItemQuantity: number;

  constructor(
    private cart: Cart, 
    private item: Item, 
    private newQuantity: number
  ) {}

  execute(): void {
    this.previousItemQuantity = this.cart.modify(this.item, this.newQuantity);
  }

  undo(): void {
    this.cart.modify(this.item, this.previousItemQuantity);
  }
}

The important part here is that a command, like any other object, can hold a state, in our case previousItemQuantity. If we take an example of a command that implements one step in a transaction, the internal state can hold the state change for that transaction. In case the transaction as a whole fails, the orchestrating logic can revert all the operations within that transaction, keeping the consistency in the system and atomicity of the transaction itself.

Finally, we can define the Item and Cart classes:

class Item {
  constructor(
    public id: number, 
    public name: string, 
    public price: number
  ) {}
}

class Cart {
  private items: Item[] = [];

  add(item: Item): void {
    this.items.push(item);
  }

  remove(item: Item): void {
    this.items = this.items.filter((i) => i.id !== item.id);
  }

  modify(item: Item, newQuantity: number): number {
    const previousQuantity = item.quantity;
    item.quantity = newQuantity;
    return previousQuantity;
  }
}

The Cart itself holds its internal state in an array of Items and individual commands operate on top of Cart's public methods.

Now we can use the Command pattern to add, remove, and modify items in the shopping cart:

const cart = new Cart();
const book = new Item(1, "Book", 10);
const movie = new Item(2, "Movie", 20);

const addBookCommand = new AddToCartCommand(cart, book);
const removeBookCommand = new RemoveFromCartCommand(cart, book);
const modifyBookCommand = new ModifyCartItemCommand(cart, book, 2);
const addMovieCommand = new AddToCartCommand(cart, movie);

addBookCommand.execute(); // cart now contains book
addMovieCommand.execute(); // cart now contains book and movie
removeBookCommand.execute(); // cart now contains only movie
addMovieCommand.undo(); // cart is empty
removeBookCommand.undo(); // cart now again contains book
modifyBookCommand.execute(); // cart now contains 2 books
modifyBookCommand.undo(); // cart now again contains only 1 book

In this example, we create instances of the AddToCartCommand, RemoveFromCartCommand, and ModifyCartItemCommand classes and pass them the cart object and items to add, remove, or modify. We then execute the commands using the execute() and undo() methods, which calls the corresponding methods on the cart object.

You can find the complete example in this CodeSandbox.

Benefits

The Command pattern offers several benefits, including:

  • Encapsulation: The Command pattern encapsulates the request as an object, which allows us to decouple the sender and receiver of the request. This separation of a request from its execution allows for a more flexible and modular design. Adding, removing, or modifying commands without affecting the rest of the code is easy.

  • Undo/redo functionality: By encapsulating requests as objects, the Command pattern makes it easy to implement undo/redo functionality. Each command object can store its state, making it possible to undo or redo a series of commands.

  • Decoupling: The Command pattern decouples the client from the receiver, making it possible to change the receiver without affecting the client. This also makes it easier to test the application since the client and receiver can be tested separately.

Drawbacks

Let's talk about the drawbacks of the Command pattern. It's important to always evaluate your particular use case and decide if this architectural approach makes sense.

  • Complexity: The pattern can add complexity to the system design, especially if many commands need to be implemented. If your application contains many small commands this can introduce code overhead.

  • Memory usage: The pattern can lead to increased memory usage, especially if each command object needs to store a large amount of data. It is essential to decide how many undo/redo operations are allowed so your application only stores the state of commands that still makes sense to undo/redo.

  • Performance: The pattern can impact performance, especially if many commands need to be executed or undone.

The Command pattern is a useful design pattern that provides a flexible way to implement complex operations by breaking them down into smaller, simpler commands. Typescript provides strong typing and other features that make it well-suited for implementing the Command pattern. By its usage, we can improve the decoupling, flexibility, and testability of our code. However, we should also be aware of the potential drawbacks, including increased complexity, performance, and overhead.