Visitor Pattern: Operations on Object Structures

You have a document model with paragraphs, images, and tables. Now you need to export it to HTML. Then PDF. Then calculate word counts. Then extract all image references. Each new requirement means...

Key Insights

  • The Visitor pattern separates algorithms from object structures through double dispatch, enabling you to add new operations without modifying existing element classes
  • Use Visitor when your object structure is stable but you need to frequently add new operations—if you’re adding new element types often, choose a different pattern
  • Modern language features like sealed classes and pattern matching can replace traditional Visitor implementations with more concise, type-safe code

The Problem of Adding Operations

You have a document model with paragraphs, images, and tables. Now you need to export it to HTML. Then PDF. Then calculate word counts. Then extract all image references. Each new requirement means touching every element class, violating the Open-Closed Principle and scattering related logic across your codebase.

The Visitor pattern solves this by inverting the relationship between operations and objects. Instead of adding methods to each element, you create separate visitor classes that encapsulate each operation. Elements accept visitors and delegate the operation back to them—a technique called double dispatch.

This pattern appears throughout software engineering: compilers traversing ASTs, linters checking code, serializers converting objects to different formats. Understanding when and how to apply it will sharpen your design instincts.

Core Concepts and Structure

The Visitor pattern involves five participants working together:

Visitor Interface: Declares a visit method for each concrete element type in your structure. This is where you define the operation’s signature.

ConcreteVisitor: Implements the visitor interface with specific behavior for each element type. Each visitor represents one complete operation across all elements.

Element Interface: Declares an accept method that takes a visitor. This is the entry point for the double dispatch mechanism.

ConcreteElement: Implements accept by calling the appropriate visit method on the visitor, passing itself as an argument.

Object Structure: The composite or collection that holds elements and provides a way to iterate over them.

The magic happens through double dispatch. In single dispatch languages like Java or TypeScript, method calls are resolved based on the receiver’s runtime type. Double dispatch achieves resolution based on two types: the element and the visitor.

// The Element calls visitor.visitParagraph(this)
// First dispatch: resolved by element's type (Paragraph)
// Second dispatch: resolved by visitor's type (HtmlExportVisitor)

Here’s the basic structure:

// Visitor interface - one method per element type
interface DocumentVisitor<T> {
  visitParagraph(paragraph: Paragraph): T;
  visitImage(image: Image): T;
  visitTable(table: Table): T;
}

// Element interface
interface DocumentElement {
  accept<T>(visitor: DocumentVisitor<T>): T;
}

Implementation Walkthrough

Let’s build a document processing system. We’ll start with our element hierarchy:

class Paragraph implements DocumentElement {
  constructor(public readonly text: string) {}

  accept<T>(visitor: DocumentVisitor<T>): T {
    return visitor.visitParagraph(this);
  }
}

class Image implements DocumentElement {
  constructor(
    public readonly src: string,
    public readonly alt: string
  ) {}

  accept<T>(visitor: DocumentVisitor<T>): T {
    return visitor.visitImage(this);
  }
}

class Table implements DocumentElement {
  constructor(
    public readonly headers: string[],
    public readonly rows: string[][]
  ) {}

  accept<T>(visitor: DocumentVisitor<T>): T {
    return visitor.visitTable(this);
  }
}

Now we can add operations without touching these classes. Here’s an HTML export visitor:

class HtmlExportVisitor implements DocumentVisitor<string> {
  visitParagraph(paragraph: Paragraph): string {
    return `<p>${this.escapeHtml(paragraph.text)}</p>`;
  }

  visitImage(image: Image): string {
    return `<img src="${image.src}" alt="${image.alt}" />`;
  }

  visitTable(table: Table): string {
    const headerRow = table.headers
      .map(h => `<th>${this.escapeHtml(h)}</th>`)
      .join('');
    
    const bodyRows = table.rows
      .map(row => 
        `<tr>${row.map(cell => `<td>${this.escapeHtml(cell)}</td>`).join('')}</tr>`
      )
      .join('\n');

    return `<table>
      <thead><tr>${headerRow}</tr></thead>
      <tbody>${bodyRows}</tbody>
    </table>`;
  }

  private escapeHtml(text: string): string {
    return text.replace(/[&<>"']/g, char => ({
      '&': '&amp;', '<': '&lt;', '>': '&gt;',
      '"': '&quot;', "'": '&#39;'
    }[char] || char));
  }
}

Adding word count requires no changes to elements:

class WordCountVisitor implements DocumentVisitor<number> {
  visitParagraph(paragraph: Paragraph): number {
    return paragraph.text.split(/\s+/).filter(w => w.length > 0).length;
  }

  visitImage(image: Image): number {
    // Images contribute their alt text word count
    return image.alt.split(/\s+/).filter(w => w.length > 0).length;
  }

  visitTable(table: Table): number {
    const headerWords = table.headers.join(' ').split(/\s+/).length;
    const cellWords = table.rows
      .flat()
      .join(' ')
      .split(/\s+/)
      .filter(w => w.length > 0).length;
    return headerWords + cellWords;
  }
}

Processing a document becomes straightforward:

class Document {
  constructor(private elements: DocumentElement[]) {}

  export(visitor: DocumentVisitor<string>): string {
    return this.elements.map(el => el.accept(visitor)).join('\n');
  }

  countWords(): number {
    const counter = new WordCountVisitor();
    return this.elements.reduce(
      (sum, el) => sum + el.accept(counter), 
      0
    );
  }
}

// Usage
const doc = new Document([
  new Paragraph('Welcome to our documentation.'),
  new Image('/logo.png', 'Company Logo'),
  new Table(['Feature', 'Status'], [['Auth', 'Complete'], ['API', 'In Progress']])
]);

console.log(doc.export(new HtmlExportVisitor()));
console.log(`Word count: ${doc.countWords()}`);

Real-World Use Cases

AST Traversal is the canonical visitor application. Compilers, linters, and code formatters all process abstract syntax trees with visitors:

// Expression AST for a simple calculator
interface Expression {
  accept<T>(visitor: ExpressionVisitor<T>): T;
}

interface ExpressionVisitor<T> {
  visitNumber(expr: NumberExpr): T;
  visitBinaryOp(expr: BinaryOpExpr): T;
}

class NumberExpr implements Expression {
  constructor(public readonly value: number) {}
  accept<T>(visitor: ExpressionVisitor<T>): T {
    return visitor.visitNumber(this);
  }
}

class BinaryOpExpr implements Expression {
  constructor(
    public readonly left: Expression,
    public readonly operator: '+' | '-' | '*' | '/',
    public readonly right: Expression
  ) {}
  accept<T>(visitor: ExpressionVisitor<T>): T {
    return visitor.visitBinaryOp(this);
  }
}

// Evaluator visitor
class EvaluatorVisitor implements ExpressionVisitor<number> {
  visitNumber(expr: NumberExpr): number {
    return expr.value;
  }

  visitBinaryOp(expr: BinaryOpExpr): number {
    const left = expr.left.accept(this);
    const right = expr.right.accept(this);
    switch (expr.operator) {
      case '+': return left + right;
      case '-': return left - right;
      case '*': return left * right;
      case '/': return left / right;
    }
  }
}

// Pretty printer visitor
class PrettyPrintVisitor implements ExpressionVisitor<string> {
  visitNumber(expr: NumberExpr): string {
    return expr.value.toString();
  }

  visitBinaryOp(expr: BinaryOpExpr): string {
    return `(${expr.left.accept(this)} ${expr.operator} ${expr.right.accept(this)})`;
  }
}

// Parse "3 + 5 * 2" and evaluate
const ast = new BinaryOpExpr(
  new NumberExpr(3),
  '+',
  new BinaryOpExpr(new NumberExpr(5), '*', new NumberExpr(2))
);

console.log(ast.accept(new PrettyPrintVisitor())); // (3 + (5 * 2))
console.log(ast.accept(new EvaluatorVisitor()));    // 13

Other common applications include file system traversal (calculating sizes, finding duplicates, applying permissions), shopping cart calculations (taxes, discounts, shipping), and report generation across heterogeneous data sources.

Trade-offs and When to Use

Benefits:

  • Adding new operations is trivial—just create a new visitor class
  • Related behavior is centralized in one class rather than scattered across elements
  • Visitors can accumulate state as they traverse the structure

Drawbacks:

  • Adding new element types requires updating every visitor interface and implementation
  • Visitors access element internals, potentially breaking encapsulation
  • The double dispatch mechanism adds indirection and complexity

Use Visitor when:

  • Your object structure is stable but operations change frequently
  • You have many distinct operations that don’t belong on the elements themselves
  • You need to perform operations across a heterogeneous collection

Avoid Visitor when:

  • Element types change frequently
  • Operations are simple and few
  • You’re working in a language with better alternatives (see next section)

Variations and Modern Alternatives

The Acyclic Visitor variation uses separate interfaces for each element type, avoiding the need to update a monolithic visitor interface when adding elements. It trades compile-time safety for flexibility.

Modern languages offer cleaner alternatives. Kotlin’s sealed classes with when expressions provide exhaustive pattern matching:

sealed class DocumentElement

data class Paragraph(val text: String) : DocumentElement()
data class Image(val src: String, val alt: String) : DocumentElement()
data class Table(val headers: List<String>, val rows: List<List<String>>) : DocumentElement()

// No visitor needed - pattern matching handles it
fun toHtml(element: DocumentElement): String = when (element) {
    is Paragraph -> "<p>${element.text}</p>"
    is Image -> """<img src="${element.src}" alt="${element.alt}" />"""
    is Table -> buildTable(element)
}

// Compiler enforces exhaustiveness - add a new subclass and you must handle it
fun wordCount(element: DocumentElement): Int = when (element) {
    is Paragraph -> element.text.split("\\s+".toRegex()).size
    is Image -> element.alt.split("\\s+".toRegex()).size
    is Table -> element.headers.size + element.rows.flatten().sumOf { 
        it.split("\\s+".toRegex()).size 
    }
}

This approach gives you the same exhaustiveness guarantees without the ceremony of visitor interfaces and accept methods. Java 17+ offers similar capabilities with sealed classes and pattern matching in switch expressions.

Summary and Best Practices

The Visitor pattern remains valuable when you need extensible operations over stable structures. Follow these guidelines:

  1. Keep visitors focused. One visitor, one operation. Don’t create god-visitors that do everything.

  2. Use generics for return types. The DocumentVisitor<T> pattern lets different visitors return different types cleanly.

  3. Consider the stability axis. If elements change more than operations, Visitor is the wrong choice. Use polymorphism instead.

  4. Evaluate modern alternatives first. If your language supports sealed types and pattern matching, you likely don’t need the full Visitor ceremony.

  5. Document the traversal order. When visitors accumulate state, the order elements are visited matters. Make it explicit.

The pattern’s power comes from recognizing when your design axis favors new operations over new types. Get that judgment right, and Visitor transforms a maintenance nightmare into an extensible, well-organized codebase.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.