Visitor

Advanced · 11 min read · ▶ live playground · ✦ checkpoint

A drawing model starts with Circle and Square. Area, text rendering, JSON export, and accessibility descriptions arrive one after another, and each operation adds another method to every shape class.

The Visitor pattern represents an operation in a separate object that visits each concrete element type. It makes new operations extendable without editing stable element classes, but adding one element type then forces a change to every visitor.

Before: every operation expands the element hierarchy

The shape interface groups all operations, even when area and rendering change for unrelated reasons:

interface Shape {
  area(): number;
  render(): string;
}
 
class Circle implements Shape {
  public constructor(private radius: number) {}
 
  public area(): number {
    return Math.PI * this.radius ** 2;
  }
 
  public render(): string {
    return `Circle(radius=${this.radius})`;
  }
}
 
class Square implements Shape {
  public constructor(private side: number) {}
 
  public area(): number {
    return this.side ** 2;
  }
 
  public render(): string {
    return `Square(side=${this.side})`;
  }
}

Adding JSON export edits Shape, Circle, and Square. A future pricing operation edits them again. The element classes are open for every new operation even when the set of shapes has stopped changing.

This is the Open/Closed Principle viewed along another axis. Polymorphic methods make adding a new shape local, but adding a cross-cutting operation touches every shape. Visitor deliberately reverses that trade.

After: elements accept operation objects

A visitor is an object that implements one operation across every concrete element type. Its interface has a distinct method such as visitCircle() or visitSquare() for each type it can visit.

An element is an object in the visited structure. The accept() operation is the element's stable entry point: it receives a visitor and calls back the visitor method for its own concrete type.

class diagram

Shapes dispatch an area operation to the matching visitor method

Shapes dispatch an area operation to the matching visitor method
  1. 1Visitor declares one method per concrete shape, making its supported element set explicit.
  2. 2Shape uses a Visitor through accept(), the one operation every element keeps.
  3. 3Circle accepts the visitor and calls visitCircle(this).
  4. 4Square follows the same Shape contract but calls visitSquare(this).
  5. 5AreaVisitor implements both type-specific methods as one operation over the shape set.

The Shape-to-Visitor dependency comes from accept(visitor). Each concrete shape supplies the callback that selects its matching visitor method.

That two-step selection is double dispatch, choosing behavior from two runtime types: the concrete element and the concrete visitor. The first call selects Circle.accept() or Square.accept(); the callback then reaches AreaVisitor.visitCircle() or AreaVisitor.visitSquare().

interface ShapeVisitor {
  visitCircle(circle: Circle): void;
  visitSquare(square: Square): void;
}
 
interface Shape {
  accept(visitor: ShapeVisitor): void;
}
 
class Circle implements Shape {
  public constructor(public readonly radius: number) {}
 
  public accept(visitor: ShapeVisitor): void {
    visitor.visitCircle(this);
  }
}
 
class Square implements Shape {
  public constructor(public readonly side: number) {}
 
  public accept(visitor: ShapeVisitor): void {
    visitor.visitSquare(this);
  }
}
 
class AreaVisitor implements ShapeVisitor {
  private total = 0;
 
  public visitCircle(circle: Circle): void {
    this.total += Math.PI * circle.radius ** 2;
  }
 
  public visitSquare(square: Square): void {
    this.total += square.side ** 2;
  }
 
  public result(): number {
    return this.total;
  }
}
 
const shapes: Shape[] = [
  new Circle(2),
  new Square(4),
];
const area = new AreaVisitor();
 
for (const shape of shapes) {
  shape.accept(area);
}
 
console.log(`Total area: ${area.result().toFixed(2)}`);

Total area: 28.57

The loop knows only Shape. Circle.accept() passes a Circle to visitCircle(), so the visitor can read radius without a type check. Square.accept() does the corresponding work for side.

A new SvgVisitor can implement both visit methods without changing either element class. Each operation also keeps its state together: AreaVisitor owns the running total instead of spreading accumulation across shapes or a central type switch.

The trade runs in both directions

With Visitor, adding an operation is local: create another visitor that implements every existing visitX() method. Adding an element type is global: add visitTriangle() to the visitor interface, update every existing visitor, and implement Triangle.accept().

Without Visitor, the pressure reverses. Adding Triangle is local because it implements existing shape operations, while adding a new operation edits all element types. Neither arrangement is universally more open; choose the axis that actually changes in the domain.

An Abstract Syntax Tree (AST) is a tree of nodes representing source-code structure, and it is a common fit. Its node types may stay stable while type checking, formatting, optimization, interpretation, and code generation evolve as separate passes.

When NOT to use it

Visitor is the most over-applied GoF pattern. It is not a default way to calculate across a few TypeScript values.

It pays off only when the set of element types is stable and operations churn. If Triangle, Polygon, and Path arrive regularly, every addition breaks the Visitor contract and forces edits to every visitor. The pattern has traded one difficult axis for the wrong one.

Double dispatch and accept() also add heavy boilerplate. In TypeScript, a discriminated union is a union whose members carry a shared literal tag, allowing a switch to narrow each case. For a small data model, that representation is usually clearer:

type ShapeData =
  | { kind: "circle"; radius: number }
  | { kind: "square"; side: number };
 
function areaOf(shape: ShapeData): number {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2;
    case "square":
      return shape.side ** 2;
  }
}

The switch keeps the closed set visible in one place and TypeScript narrows the fields. Pattern-matching libraries can offer a similar shape. Choose Visitor only when operation objects need their own state or dependencies, the element set is genuinely stable, and repeated operations justify the ceremony.

Checkpoint

Answer all three to mark this lesson complete

Module 7 is complete. Next, Module 8 assembles these patterns into full machine-coding case studies: parking lot, Splitwise, tic-tac-toe, vending machine, and more.

+50 XP on completion