Beyond Basics: Level Up Your TypeScript Skills with Visitor Design Pattern

Visitor design pattern is a powerful object-oriented behavioral design pattern that allows you to separate algorithms from the objects they operate on.

In this pattern, we can define a set of operations to be performed on the elements of an object structure without changing the classes on which they operate. In this blog post, we'll discuss using the visitor pattern in TypeScript.

Implementing the visitor pattern

First, let's look at an example of how the visitor pattern can be used. Imagine that you are developing an e-commerce platform that needs to calculate the total cost of a customer's shopping cart, which may contain different types of items such as books and electronics. Each type of item may have its price calculation logic based on different factors. In our case, the price will differ between weekdays and weekends.

Let's start with a simple ShoppingCart implementation.

class ShoppingCart {
  items: Item[] = [];

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

  getTotalPrice(visitor: ShoppingCartVisitor) {
    let total = 0;
    for (let item of this.items) {
      total += item.accept(visitor);
    }
    return total;
  }
}

We can add a new item to the shopping cart via addItem method, but more importantly, when calculating the cart's total price, we can pass a visitor object to getTotalPrice.

The different visitors can contain different price calculation algorithms, thus decoupling them from the objects they operate on. Let's look at how to achieve it by first defining an abstract class for shopping items and then two concrete shopping items - Book and Electronic.

abstract class Item {
  abstract accept(visitor: ShoppingCartVisitor): number;
}

class Book extends Item {
  price: number;

  constructor(price: number) {
    super();
    this.price = price;
  }

  accept(visitor: ShoppingCartVisitor) {
    return visitor.visitBook(this);
  }
}

class Electronic extends Item {
  price: number;
  weight: number;

  constructor(price: number, weight: number) {
    super();
    this.price = price;
    this.weight = weight;
  }

  accept(visitor: ShoppingCartVisitor) {
    return visitor.visitElectronic(this);
  }
}

The abstract class defines a single abstract method accept that each concrete shopping item that inherits this class has to implement. This enables each class to accept a visitor, thus allowing different algorithms to operate on this class.

Now we need to define an interface for shopping card visitors so each has the same code shape.

interface ShoppingCartVisitor {
  visitBook(book: Item): number;
  visitElectronic(electronic: Item): number;
}

As the first implementation of the visitor interface, we will choose the WeekdayVisitor. It does not apply any discounts and simply returns the original prices of items.

class WeekdayVisitor implements ShoppingCartVisitor {
  visitBook(book: Book) {
    return book.price;
  }

  visitElectronic(electronic: Electronic) {
    return electronic.price;
  }
}

As for the WeekendVisitor, let's add a different algorithm for price calculation. We will apply a 10% discount to books and a discount calculated from their weight for electronics.

class WeekendVisitor implements ShoppingCartVisitor {
  visitBook(book: Book) {
    // 10% discount on books during weekends
    return book.price * 0.9;
  }

  visitElectronic(electronic: Electronic) {
    // Discount based on weight of a electronic
    return electronic.price - electronic.weight * 0.1;
  }
}

And now, we can get the total price of the shopping cart based on different days of the week by applying a visitor of choice.

const cart = new ShoppingCart();

cart.addItem(new Book(5));
cart.addItem(new Book(10));
cart.addItem(new Electronic(100, 20));

// Weekdays
const weekdayVisitor = new WeekdayVisitor();
const weekdaysTotalPrice = cart.getTotalPrice(weekdayVisitor);
console.log(weekdaysTotalPrice); // output: 115

// Weekends
const weekendVisitor = new WeekendVisitor();
const weekendTotalPrice = cart.getTotalPrice(weekendVisitor);
console.log(weekendTotalPrice); // output: 111.5

As you can see, this pattern allows us to apply different algorithms based on some criteria, in our case, the day of the week, to the same data set. You can explore the complete example in this CodeSandbox.

Beyond Basics: Abstract syntax trees

One practical use of the visitor pattern in programming is working with abstract syntax trees (ASTs). ASTs represent the structure of code or an expression in a way that programs can easily manipulate and analyze. The visitor pattern is a natural fit for working with ASTs because it allows you to traverse the AST and perform operations on its nodes without modifying them.

In the following example, we'll define two types of nodes in the AST, BinaryOperationNode and NumberNode, and an interface for visiting these nodes named AstVisitor.

interface Node {
  accept(visitor: AstVisitor): number;
}

class BinaryOperationNode implements Node {
  constructor(private left: Node, private operator: string, private right: Node) {}

  accept(visitor: AstVisitor): number {
    return visitor.visitBinaryOperation(this);
  }

  getLeft(): Node {
    return this.left;
  }

  getOperator(): string {
    return this.operator;
  }

  getRight(): Node {
    return this.right;
  }
}

class NumberNode implements Node {
  constructor(private value: number) {}

  accept(visitor: AstVisitor): number {
    return visitor.visitNumber(this);
  }

  getValue(): number {
    return this.value;
  }
}

Now it's time to define a concrete visitor implementation called ExpressionEvaluator, which evaluates the expression represented by the AST. Notice how the visitBinaryOperation method first evaluates the left and right subtrees. This tree traversal method is called Post-order traversal, or LRN as in Left-Right-Node, and is one of the Depth-first search tree traversal methods.

interface AstVisitor {
  visitBinaryOperation(node: Node): number;
  visitNumber(node: Node): number;
}

class ExpressionEvaluator implements AstVisitor {
  visitBinaryOperation(node: BinaryOperationNode): number {
    const left = node.getLeft().accept(this);
    const right = node.getRight().accept(this);

    switch (node.getOperator()) {
      case '+':
        return left + right;
      case '-':
        return left - right;
      case '*':
        return left * right;
      case '/':
        return left / right;
      default:
        throw new Error(`Unknown operator ${node.getOperator()}`);
    }
  }

  visitNumber(node: NumberNode): number {
    return node.getValue();
  }
}

Finally, we create an AST representing the arithmetic expression 2 + (3 * 4) and traverse it using the visitor to evaluate the expression.

const expressionAst = new BinaryOperationNode(
  new NumberNode(2),
  '+',
  new BinaryOperationNode(
    new NumberNode(3),
    '*',
    new NumberNode(4)
  )
);

const expressionEvaluator = new ExpressionEvaluator();

const result = expressionAst.accept(expressionEvaluator);

console.log(result); // output: 14

See the complete example in this CodeSandbox.

This way, we can have many different algorithms traverse the AST. Imagine algorithms that analyze the AST for optimization purposes, logical errors, performing transpilation to other structures, etc. All these algorithms can operate on top of the same AST.

Benefits

Let's summarize the three main benefits of the visitor pattern:

  1. Separation of Concerns: The visitor pattern separates algorithms from the objects on which they operate. This separation of concerns makes it easy to add new operations or algorithms without modifying the classes of the objects on which they operate.

  2. Open-Closed Principle: The visitor pattern follows the open-closed principle, meaning classes should be open for extension but closed for modification. By using the visitor pattern, you can add new operations or algorithms to an existing object structure without modifying the classes of the objects, thus making your code extensible and flexible.

  3. Maintainability: The visitor pattern makes it easy to maintain code by separating the operations or algorithms from the objects on which they operate. This separation makes understanding and modifying the code more straightforward, as each class has a single responsibility, leading to better code organization.

Drawbacks

Like any programming pattern, the visitor pattern also has its drawbacks. It is essential to know if applying the visitor pattern to your codebase allows you to leverage the benefits it brings or because of the nature of your code and objects on which the visitor would operate, the pattern would have more downside than upside. Some of the drawbacks of the visitor pattern are:

  1. Complexity: The visitor pattern can add complexity to your code, especially when it involves multiple visitors and visitable classes. This can make your code harder to read and maintain.

  2. Coupling: The visitor pattern can create a tight coupling between the visitors and the visitable classes. Suppose you need to modify the structure of the visitable classes. In that case, you may also need to modify the visitors, which can lead to a ripple effect of changes throughout your codebase.

  3. Inflexibility: The visitor pattern can make your code less flexible because it requires the visitable classes to define a fixed set of methods for visitors to use. This can make it harder to add new functionality to your code in the future.

In summary, the visitor pattern provides several benefits when used in programming, including separation of concerns, adherence to the open-closed principle, and better code organization. In TypeScript, it's easy to implement the visitor pattern by defining a visitor interface, the visitor, the element interface, and the elements. By using the visitor pattern, you can create more flexible and maintainable code that's easier to extend and modify over time.