Behavioral Pattern (GoF)

Visitor

Separates an algorithm from the objects it operates on, allowing new operations to be added to a class hierarchy without modifying it — via double dispatch, each element "accepts" the visitor and invokes the correct method for its own type.

Intent

Represent an operation to be performed on the elements of an object hierarchy. Visitor lets you define a new operation without changing the classes of the elements it operates on. Each concrete class in the hierarchy implements an accept(visitor) method that calls the visitor's method specific to that type — the so-called double dispatch.

Cataloged by the GoF (1994) as a behavioral pattern, Visitor is the right pattern when an object hierarchy is stable but you need to add new operations frequently. It's widely used in compilers (an AST with type-checking visitors, code generation, and optimization), static analysis tools, tree serialization, and structured document processing.

Problem

Imagine an abstract syntax tree (AST) with three node types: NumberNode, Sum, and Multiplication. You need to add two independent operations: printing the expression as a string and computing its numeric result. The direct approach puts the operations inside the classes:

// Naive approach — DON'T do this:
class Sum {
  constructor(readonly left: IExpression, readonly right: IExpression) {}

  print(): string {
    // Printing logic coupled to the AST node.
    return `(${this.left.print()} + ${this.right.print()})`;
  }

  evaluate(): number {
    // Evaluation logic also coupled.
    return this.left.evaluate() + this.right.evaluate();
  }
  // Adding a third operation (e.g.: serialize, optimize, compile)
  // requires opening and modifying EVERY class in the hierarchy.
}

Every new operation requires opening and modifying every class in the hierarchy — NumberNode, Sum, Multiplication — all at once. This violates the Open/Closed Principle: domain classes should be closed for modification. In real compilers, the AST can have dozens of node types and dozens of passes (optimizations, type checks, bytecode generation) — mixing everything into the node classes makes the code unintelligible.

Visitor solves this by extracting each operation into its own class (the ConcreteVisitor). The hierarchy's classes stay stable; new behaviors are added without touching them.

Solution

Visitor organizes the code into four participants:

  1. Element (interface): every class in the hierarchy declares accept(visitor: IVisitor): void. The implementation is always the same: visitor.visitConcreteType(this). This is the double dispatch — the correct visitor method is chosen at runtime based on the element's concrete type.
  2. ConcreteElement: the concrete classes of the hierarchy (NumberNode, Sum, Multiplication). Each one implements accept() by calling the corresponding visit method on the visitor. They expose their internal data (via getters or readonly fields) so visitors can access it.
  3. Visitor (interface): declares a visit method for each ConcreteElement in the hierarchy. E.g.: visitNumber(node: NumberNode): void, visitSum(node: Sum): void. When a new node type is added to the hierarchy, every visitor needs to be updated — the inverse cost compared to the naive approach.
  4. ConcreteVisitor: implements the specific operation for each element type. Each visitor is a cohesive class with a single responsibility: printing, evaluating, serializing, optimizing. It can accumulate internal state during the visit (e.g.: a stack for evaluation, a list of parts for printing).

The central mechanism is double dispatch: the language resolves the correct method in two steps — first it dispatches to the correct element's accept() (chosen via the hierarchy's polymorphism); inside accept(), the element calls visitor.visitType(this), resolving the visitor's method by the concrete type of this. Without this second dispatch, the visitor would need instanceof/type-switch to detect the type — breaking polymorphism.

Structure

        «interface»
        IExpression
  ┌──────────────────────────────────┐
  │ + accept(v: IVisitor): void      │
  └──────────────────────────────────┘
               ▲
   ┌───────────┼──────────────────┐
   │           │                  │
NumberNode    Sum            Multiplication
(Terminal) (NonTerminal)     (NonTerminal)
  │             │                  │
  │accept:      │accept:           │accept:
  │v.visitNum.. │v.visitSum(this)  │v.visitMult..(this)
  ▼             ▼                  ▼
        «interface»
         IVisitor
  ┌──────────────────────────────────────────────┐
  │ + visitNumber(node: NumberNode): void        │
  │ + visitSum(node: Sum): void                  │
  │ + visitMultiplication(node: Multiplication): │
  │   void                                       │
  └──────────────────────────────────────────────┘
               ▲
   ┌───────────┴──────────────────┐
   │                              │
PrintVisitor              EvaluateVisitor
  │ parts: string[]         stack: number[]
  │ visitNumber → push      visitNumber → push
  │ visitSum → parentheses  visitSum → pop+pop+sum
  │ result(): string        result(): number
  └──────────────────────────────┘


Double dispatch — step by step:

  tree.accept(evaluator)
    │
    ├─ tree is Multiplication → Multiplication.accept(evaluator)
    │    └─ evaluator.visitMultiplication(this)
    │         ├─ this.left.accept(evaluator)      // Sum
    │         │    └─ evaluator.visitSum(this)
    │         │         ├─ this.left.accept(evaluator)  // NumberNode(2)
    │         │         │    └─ evaluator.visitNumber(this) → stack:[2]
    │         │         └─ this.right.accept(evaluator) // NumberNode(3)
    │         │              └─ evaluator.visitNumber(this) → stack:[2,3]
    │         │         → pop 3, pop 2, push 5      stack:[5]
    │         └─ this.right.accept(evaluator)     // NumberNode(4)
    │              └─ evaluator.visitNumber(this) → stack:[5,4]
    │         → pop 4, pop 5, push 20              stack:[20]
    result(): 20

Code examples

Example 1 — Expression AST with PrintVisitor and EvaluateVisitor

A complete implementation with the expression hierarchy (NumberNode, Sum, Multiplication), the IVisitor interface, and two concrete visitors. Note how every class in the hierarchy implements accept() with exactly one line — all the operation's logic lives in the visitor, without touching the AST nodes.

// ── Visitor interface ─────────────────────────────────────────
// Declared BEFORE the elements because they reference IVisitor.
// In TS, forward references work fine via interfaces — no ordering issue.
interface IVisitor {
  visitNumber(node: NumberNode): void;
  visitSum(node: Sum): void;
  visitMultiplication(node: Multiplication): void;
}

// ── Element interface ─────────────────────────────────────────
interface IExpression {
  accept(visitor: IVisitor): void;
}

// ── ConcreteElements (AST nodes) ──────────────────────────────
// Named NumberNode (instead of just "Number") to avoid colliding with the built-in Number.
class NumberNode implements IExpression {
  constructor(readonly value: number) {}

  // Double dispatch: calls the visitor's correct method for this type.
  accept(visitor: IVisitor): void {
    visitor.visitNumber(this);
  }
}

class Sum implements IExpression {
  constructor(
    readonly left: IExpression,
    readonly right: IExpression
  ) {}

  accept(visitor: IVisitor): void {
    visitor.visitSum(this);
  }
}

class Multiplication implements IExpression {
  constructor(
    readonly left: IExpression,
    readonly right: IExpression
  ) {}

  accept(visitor: IVisitor): void {
    visitor.visitMultiplication(this);
  }
}

// ── ConcreteVisitor 1: Print ──────────────────────────────────
// Builds the textual representation of the expression with explicit parentheses.
class PrintVisitor implements IVisitor {
  private parts: string[] = [];

  visitNumber(node: NumberNode): void {
    this.parts.push(String(node.value));
  }

  visitSum(node: Sum): void {
    this.parts.push('(');
    node.left.accept(this);    // visits the left child
    this.parts.push(' + ');
    node.right.accept(this);   // visits the right child
    this.parts.push(')');
  }

  visitMultiplication(node: Multiplication): void {
    this.parts.push('(');
    node.left.accept(this);
    this.parts.push(' * ');
    node.right.accept(this);
    this.parts.push(')');
  }

  result(): string {
    return this.parts.join('');
  }
}

// ── ConcreteVisitor 2: Evaluate ───────────────────────────────
// Evaluates the expression using a stack — with no changes to the nodes.
class EvaluateVisitor implements IVisitor {
  private stack: number[] = [];

  visitNumber(node: NumberNode): void {
    this.stack.push(node.value);
  }

  visitSum(node: Sum): void {
    node.left.accept(this);
    node.right.accept(this);
    const b = this.stack.pop()!;
    const a = this.stack.pop()!;
    this.stack.push(a + b);
  }

  visitMultiplication(node: Multiplication): void {
    node.left.accept(this);
    node.right.accept(this);
    const b = this.stack.pop()!;
    const a = this.stack.pop()!;
    this.stack.push(a * b);
  }

  result(): number {
    return this.stack[0] ?? 0;
  }
}

// ── Usage ────────────────────────────────────────────────────
// AST representing: (2 + 3) * 4
const tree: IExpression = new Multiplication(
  new Sum(new NumberNode(2), new NumberNode(3)),
  new NumberNode(4)
);

// Operation 1: print (without touching the AST nodes)
const printer = new PrintVisitor();
tree.accept(printer);
console.log(printer.result());   // ((2 + 3) * 4)

// Operation 2: evaluate (without touching the AST nodes)
const evaluator = new EvaluateVisitor();
tree.accept(evaluator);
console.log(evaluator.result());   // 20

// The AST can be visited multiple times by different visitors.
// No node class was modified to support these operations.

Example 2 — Adding new operations without touching the hierarchy

Visitor's main benefit: adding operations is open for extension. Reusing the same AST from Example 1, we implement two additional visitors — CountNodesVisitor (counts how many nodes the AST has) and SerializeJsonVisitor (serializes the tree to JSON) — without changing a single line of NumberNode, Sum, or Multiplication.

// Reuses IVisitor, IExpression, NumberNode, Sum and Multiplication from Example 1.

// ── ConcreteVisitor 3: Count nodes ───────────────────────────
// Counts the total number of AST nodes — leaves (NumberNode) and internal (Sum, Multiplication).
class CountNodesVisitor implements IVisitor {
  private count = 0;

  visitNumber(_node: NumberNode): void {
    this.count++;
  }

  visitSum(node: Sum): void {
    this.count++;
    node.left.accept(this);
    node.right.accept(this);
  }

  visitMultiplication(node: Multiplication): void {
    this.count++;
    node.left.accept(this);
    node.right.accept(this);
  }

  result(): number {
    return this.count;
  }
}

// ── ConcreteVisitor 4: Serialize to JSON ──────────────────────
// Serializes the AST to a JSON string without using JSON.stringify at the root.
// Uses recursion's implicit stack: each visit builds and stores
// the sub-node's JSON fragment before composing the parent node.
class SerializeJsonVisitor implements IVisitor {
  private json = '';

  visitNumber(node: NumberNode): void {
    this.json = `{"type":"Number","value":${node.value}}`;
  }

  visitSum(node: Sum): void {
    node.left.accept(this);
    const left = this.json;
    node.right.accept(this);
    const right = this.json;
    this.json = `{"type":"Sum","left":${left},"right":${right}}`;
  }

  visitMultiplication(node: Multiplication): void {
    node.left.accept(this);
    const left = this.json;
    node.right.accept(this);
    const right = this.json;
    this.json = `{"type":"Mult","left":${left},"right":${right}}`;
  }

  result(): string {
    return this.json;
  }
}

// ── Usage ────────────────────────────────────────────────────
// Same AST: (2 + 3) * 4
const tree: IExpression = new Multiplication(
  new Sum(new NumberNode(2), new NumberNode(3)),
  new NumberNode(4)
);

// Visitor 3: count
const counter = new CountNodesVisitor();
tree.accept(counter);
console.log(counter.result());
// 5  (Multiplication + Sum + NumberNode(2) + NumberNode(3) + NumberNode(4))

// Visitor 4: serialize
const serializer = new SerializeJsonVisitor();
tree.accept(serializer);
console.log(serializer.result());
// {"type":"Mult",
//   "left":{"type":"Sum",
//     "left":{"type":"Number","value":2},
//     "right":{"type":"Number","value":3}},
//   "right":{"type":"Number","value":4}}

// Zero lines changed in NumberNode, Sum and Multiplication.

When to use

  • When you need to add frequent operations to a stable hierarchy: if the hierarchy (the node types) rarely changes but new operations (visitors) are added often, Visitor keeps the operations' code cohesive and the domain classes untouched.
  • In compilers and analysis tools: the AST has stable node types (expressions, statements, literals), but the passes (type-checking, inlining, code generation, flow analysis) are added incrementally. Each pass is a Visitor.
  • For operations that access data from multiple unrelated types: a serialization or auditing visitor can access internal fields of completely different types and produce a unified result — without those types needing to share behavior.
  • To separate concerns in complex hierarchies: when mixing multiple operations into the classes makes the code hard to understand, Visitor concentrates each operation into a single cohesive class.

When to avoid

  • When the hierarchy changes frequently: adding a new element type (e.g.: a new AST node) requires updating the IVisitor interface and every existing visitor. If the hierarchy is volatile, Visitor generates more work than it saves — the Open/Closed axis is inverted for elements.
  • When the classes need to protect their internals: Visitor needs to access the elements' internal data (readonly fields or getters). If encapsulation is critical and the data shouldn't be exposed, the pattern is unsuitable — it essentially forces every element to be a DTO for the visitors.
  • For small hierarchies and few operations: with two element types and one operation, creating a visitor interface, two accept() methods, and a concrete class is overengineering. A direct method on the class solves it with less code.

Pros and cons

Pros

  • Open for new operations: adding a visitor doesn't touch the hierarchy's classes.
  • Concentrates each operation's logic in a single cohesive class — easy to locate, test, and maintain.
  • Allows accumulating state during the visit (counting, a stack, an output buffer) without polluting the elements.
  • Can traverse composite hierarchies (Composite) in an ordered, recursive way, applying complex transformations.
  • Multiple visitation: the same structure can be visited by different visitors independently.

Cons

  • Closed for new elements: adding a type to the hierarchy requires updating every visitor — the inverse cost compared to the naive approach.
  • Partially breaks encapsulation: elements need to expose their internal data so visitors can access it.
  • Double dispatch is non-intuitive to those unfamiliar with the pattern — the execution flow is less obvious than a direct call.
  • Increases the number of classes: each operation becomes a class; large hierarchies with many operations generate many visitors.

Common pitfalls

1. Obscured double dispatch — using instanceof instead of accept()

The most common temptation when learning Visitor: implementing the visitor with an if/instanceof block instead of using accept(). This defeats the pattern's purpose and creates explicit coupling to the type:

// WRONG — visitor with instanceof (not a real Visitor):
class WrongEvaluator {
  evaluate(node: IExpression): number {
    if (node instanceof NumberNode)        return node.value;
    if (node instanceof Sum)            return this.evaluate(node.left) + this.evaluate(node.right);
    if (node instanceof Multiplication) return this.evaluate(node.left) * this.evaluate(node.right);
    throw new Error('Unknown type');
    // Problem: adding a new node type doesn't cause a compile error —
    // the code simply throws at runtime, with no static warning.
  }
}

// CORRECT — the compiler guarantees every type is covered:
// If Division is added to IVisitor, every ConcreteVisitor that
// doesn't implement visitDivision() will fail to compile.

With real double dispatch, the compiler forces every visitor to implement the method for each new type — the IVisitor interface's contract acts as a static coverage checklist.

2. Unstable hierarchy — Open/Closed inverted for elements

Visitor flips the Open/Closed axis: it's open for new operations (visitors) but closed for new types in the hierarchy. When a project is still in the modeling phase and the element hierarchy is still changing, every new type requires updating every existing visitor — the opposite of what the pattern promises. Apply Visitor only when the element hierarchy is reasonably stable.

3. A visitor with mutable state shared across visits

When a visitor accumulates internal state (stack, parts, count), it can only be used once per visit. Reusing the same instance on two different trees produces incorrect results because the first visit's state contaminates the second.

Rule of thumb: create a new visitor instance for each visit. If a visitor needs to be reusable, implement a reset() method that zeroes the internal state — and document that requirement explicitly.

4. Visitor vs Strategy — the intent distinction

The most frequent confusion between these two patterns. Strategy encapsulates an interchangeable algorithm per object: the context has one active strategy that can be swapped, but each object has its own. Visitor encapsulates an operation that traverses an entire hierarchy: a single visitor walks every element, calling the correct method on each type — the algorithm is distributed across the visit* methods, not across isolated objects. Use Strategy when a single object needs interchangeable behavior; use Visitor when an operation needs to traverse and process multiple types in a hierarchy.

Related patterns

Visitor works alongside patterns that organize hierarchies and traversals:

Composite is Visitor's natural partner: Composite organizes objects into tree structures; Visitor traverses that tree applying an operation to each node. The accept() method on a composite node typically delegates to the children, which in turn delegate to their own children — Composite's recursion and Visitor's double dispatch combine naturally. Iterator is a simpler alternative when the operation is just traversing and handing elements to the caller, without processing per type; when each element type needs to be processed differently, Visitor is more appropriate. Command encapsulates a discrete action on a specific receiver; Visitor encapsulates an operation that's distributed across an entire hierarchy — they're complementary patterns when a system has both discrete actions (Command) and analysis or transformation passes over data structures (Visitor).