StackPractices
advanced By Mathias Paulenko

Visitor Pattern: Extensible Operations in TypeScript

Separate algorithms from the objects they operate on, allowing new operations to be added without modifying existing element classes

Topics: design

The Visitor pattern separates an algorithm from the object structure it operates on. Moving operations into visitor classes lets you pile new behaviors onto a class hierarchy without touching the element classes. That trade pays off when the node types are stable but the operations keep multiplying.

This page applies the pattern in TypeScript over an expression AST, the canonical use case. For the language-agnostic GoF version in Python, Java, and JavaScript, see Visitor Pattern.

When to Use This

  • Many unrelated operations run over the same object structure — evaluation, printing, serialization, linting.
  • The object structure rarely changes but operations change often. For interchangeable algorithms on a single object, see Strategy Pattern.
  • You have to accumulate state as the traversal walks the elements — totals, output buffers, scope stacks. For the tree structure itself, see Composite Pattern.
  • Adding behavior as methods would pollute the element classes with concerns they shouldn’t own.

Problem

Take an AST (Abstract Syntax Tree) for a small language: it needs formatting, linting, optimization, and code generation. Adding each operation as a method on every node class pollutes the hierarchy, and every new operation means editing all of them.

Solution

// visitor/ASTVisitor.ts
interface ASTNode {
  accept(visitor: ASTVisitor): void;
}

interface ASTVisitor {
  visitNumber(node: NumberNode): void;
  visitBinaryOp(node: BinaryOpNode): void;
  visitVariable(node: VariableNode): void;
}

class NumberNode implements ASTNode {
  constructor(public value: number) {}
  accept(visitor: ASTVisitor): void { visitor.visitNumber(this); }
}

class BinaryOpNode implements ASTNode {
  constructor(
    public operator: string,
    public left: ASTNode,
    public right: ASTNode
  ) {}
  accept(visitor: ASTVisitor): void { visitor.visitBinaryOp(this); }
}

class VariableNode implements ASTNode {
  constructor(public name: string) {}
  accept(visitor: ASTVisitor): void { visitor.visitVariable(this); }
}

// Evaluator visitor: accumulates results on an internal stack
class Evaluator implements ASTVisitor {
  private stack: number[] = [];

  constructor(private variables: Record<string, number>) {}

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

  visitVariable(node: VariableNode): void {
    this.stack.push(this.variables[node.name] ?? 0);
  }

  visitBinaryOp(node: BinaryOpNode): void {
    node.left.accept(this);
    node.right.accept(this);

    const right = this.stack.pop()!;
    const left = this.stack.pop()!;

    switch (node.operator) {
      case '+': this.stack.push(left + right); break;
      case '-': this.stack.push(left - right); break;
      case '*': this.stack.push(left * right); break;
      case '/': this.stack.push(left / right); break;
    }
  }

  getResult(): number {
    return this.stack[this.stack.length - 1];
  }
}

// Printer visitor: a new operation with zero changes to the nodes
class Printer implements ASTVisitor {
  private output = '';

  visitNumber(node: NumberNode): void {
    this.output += node.value;
  }

  visitVariable(node: VariableNode): void {
    this.output += node.name;
  }

  visitBinaryOp(node: BinaryOpNode): void {
    this.output += '(';
    node.left.accept(this);
    this.output += ` ${node.operator} `;
    node.right.accept(this);
    this.output += ')';
  }

  getOutput(): string {
    return this.output;
  }
}

// Usage
const ast = new BinaryOpNode(
  '+',
  new NumberNode(2),
  new BinaryOpNode('*', new VariableNode('x'), new NumberNode(3))
);

const evaluator = new Evaluator({ x: 4 });
ast.accept(evaluator);
console.log(evaluator.getResult()); // 14

const printer = new Printer();
ast.accept(printer);
console.log(printer.getOutput()); // (2 + (x * 3))

Variants

Serializer visitor

Same structure, different operation. JSONSerializer turns the tree into a plain object — notice it stores this.result between calls instead of a stack, so it captures the left subtree before the right traversal overwrites it:

// visitor/Serializer.ts
class JSONSerializer implements ASTVisitor {
  private result: unknown = null;

  visitNumber(node: NumberNode): void {
    this.result = { type: 'number', value: node.value };
  }

  visitVariable(node: VariableNode): void {
    this.result = { type: 'variable', name: node.name };
  }

  visitBinaryOp(node: BinaryOpNode): void {
    node.left.accept(this);
    const left = this.result;
    node.right.accept(this);
    const right = this.result;

    this.result = {
      type: 'binaryOp',
      operator: node.operator,
      left,
      right,
    };
  }

  serialize(): string {
    return JSON.stringify(this.result);
  }
}

When a discriminated union is enough

TypeScript gives you a lighter alternative: a union type plus an exhaustive switch. The compiler still guarantees every case is handled, but there’s no accept() boilerplate:

type Expr =
  | { kind: 'number'; value: number }
  | { kind: 'variable'; name: string }
  | { kind: 'binaryOp'; op: string; left: Expr; right: Expr };

function evaluate(node: Expr, vars: Record<string, number>): number {
  switch (node.kind) {
    case 'number': return node.value;
    case 'variable': return vars[node.name] ?? 0;
    case 'binaryOp': {
      const l = evaluate(node.left, vars);
      const r = evaluate(node.right, vars);
      switch (node.op) {
        case '+': return l + r;
        case '-': return l - r;
        case '*': return l * r;
        case '/': return l / r;
        default: throw new Error(`unknown operator ${node.op}`);
      }
    }
  }
}

Pick the union when operations are few and pure. Pick Visitor when each operation is a cohesive object with its own state — a type checker holding a scope stack, a code generator holding an output buffer — or when you want to ship operations as separate, testable units.

How It Works

  1. Visitor declares a visitX method for each element type.
  2. Concrete visitor implements the operation for each element.
  3. Element declares an accept method that takes a visitor.
  4. Object structure hands each element to the visitor via accept.
  5. Double dispatch routes the call to the right method based on both types.

The walkthrough for ast.accept(evaluator):

  • BinaryOpNode.accept calls evaluator.visitBinaryOp(this) — the first dispatch picks the concrete node.
  • visitBinaryOp re-dispatches to node.left and node.right, each of which calls back into the visitor — the second dispatch picks the operation.
  • Results accumulate on the evaluator’s stack, and getResult() reads the root value when the traversal unwinds.
sequenceDiagram diagram: participant C as Client

Production Considerations

  • Adding a node type breaks every visitor — on purpose. In a typed language the compiler won’t let you ship a visitor missing a visitX, which is the safety you bought with the boilerplate.
  • State lives on the visitor, not the nodes. Evaluator carries a stack; JSONSerializer carries this.result. Either reset state between traversals or build a fresh visitor per run, or a second accept() will see leftovers from the first.
  • accept() recursion is as deep as the tree. On very deep ASTs you’ll hit the call stack limit before any logic fails. If that’s your case, drive the traversal with an explicit stack in the object structure instead of recursing inside visitX.
  • Visitors need node internals. Evaluator reads node.value, node.operator, and the children directly. Keep that surface minimal; widening it “for the visitor” couples every node to every operation.

Common Mistakes

  • Forgetting to call accept on child elements, which silently truncates the traversal at the first level.
  • Reading getResult() before the traversal finishes — the stack still holds intermediate values.
  • Reusing a stateful visitor across two trees without resetting it.
  • Adding a node type and relying on a fallback visit — that defeats the exhaustiveness the pattern exists to give you.
  • Using Visitor where a polymorphic method on the elements would do: if there’s exactly one operation, put it on the node.

Troubleshooting

  • Wrong result on nested expressions: you’re probably pushing or popping in the wrong order, or reading the result before the children have been visited. Visit left and right first, then pop.
  • New node type does nothing at runtime: it either misses its accept implementation or the visitor lacks the matching visitX. In TypeScript, an unimplemented interface method fails at compile time — if it reached runtime, something bypassed the types with any.
  • Stack overflow on deep trees: accept recursion matches tree depth. Move the traversal to an explicit stack in the object structure, or raise the limit only as a stopgap.
  • Serialized output has identical left and right subtrees: the serializer overwrote this.result before you captured it. Store the left result before accepting the right child, as JSONSerializer does.
  • A visitor needs state the node doesn’t expose: don’t widen the node’s public surface for one operation. Pass what the visit needs through the visitX signature, or reconsider whether that operation belongs in a visitor at all.

A Larger Example: Visitor for a Compiler AST

A more realistic structure adds strings and function calls. The same two visitors — evaluation and pretty printing — work unchanged in shape:

interface ASTNode {
  accept(visitor: ASTVisitor): void;
}

interface ASTVisitor {
  visitNumber(node: NumberNode): void;
  visitString(node: StringNode): void;
  visitBinaryOp(node: BinaryOpNode): void;
  visitFunctionCall(node: FunctionCallNode): void;
}

class NumberNode implements ASTNode {
  constructor(public value: number) {}
  accept(v: ASTVisitor) { v.visitNumber(this); }
}

class StringNode implements ASTNode {
  constructor(public value: string) {}
  accept(v: ASTVisitor) { v.visitString(this); }
}

class BinaryOpNode implements ASTNode {
  constructor(public op: string, public left: ASTNode, public right: ASTNode) {}
  accept(v: ASTVisitor) { v.visitBinaryOp(this); }
}

class FunctionCallNode implements ASTNode {
  constructor(public name: string, public args: ASTNode[]) {}
  accept(v: ASTVisitor) { v.visitFunctionCall(this); }
}

// Visitor 1: evaluation
class EvaluatorVisitor implements ASTVisitor {
  private stack: unknown[] = [];
  getResult(): unknown { return this.stack[0]; }
  visitNumber(n: NumberNode) { this.stack.push(n.value); }
  visitString(n: StringNode) { this.stack.push(n.value); }
  visitBinaryOp(n: BinaryOpNode) {
    n.left.accept(this); n.right.accept(this);
    const r = this.stack.pop() as number;
    const l = this.stack.pop() as number;
    switch (n.op) {
      case "+": this.stack.push(l + r); break;
      case "-": this.stack.push(l - r); break;
      case "*": this.stack.push(l * r); break;
      case "/": this.stack.push(l / r); break;
    }
  }
  visitFunctionCall(n: FunctionCallNode) {
    const args = n.args.map(a => { a.accept(this); return this.stack.pop(); });
    if (n.name === "sqrt") this.stack.push(Math.sqrt(args[0] as number));
  }
}

// Visitor 2: pretty print
class PrettyPrintVisitor implements ASTVisitor {
  private output = "";
  getOutput(): string { return this.output; }
  visitNumber(n: NumberNode) { this.output += n.value; }
  visitString(n: StringNode) { this.output += `"${n.value}"`; }
  visitBinaryOp(n: BinaryOpNode) {
    this.output += "("; n.left.accept(this);
    this.output += ` ${n.op} `; n.right.accept(this);
    this.output += ")";
  }
  visitFunctionCall(n: FunctionCallNode) {
    this.output += `${n.name}(`;
    n.args.forEach((a, i) => { if (i > 0) this.output += ", "; a.accept(this); });
    this.output += ")";
  }
}

// Usage: AST for (3 + 4) * sqrt(16)
const ast = new BinaryOpNode("*",
  new BinaryOpNode("+", new NumberNode(3), new NumberNode(4)),
  new FunctionCallNode("sqrt", [new NumberNode(16)])
);

const evalVisitor = new EvaluatorVisitor();
ast.accept(evalVisitor);
console.log(evalVisitor.getResult()); // 28

const printer = new PrettyPrintVisitor();
ast.accept(printer);
console.log(printer.getOutput()); // ((3 + 4) * sqrt(16))

Two things to notice here. The EvaluatorVisitor mixes types on one stack — unknown[] — because visitString pushes a string where visitBinaryOp expects numbers; a production evaluator would tag results or keep separate stacks. And visitFunctionCall hardcodes sqrt, which is exactly the rigidity the pattern is meant to avoid: a real implementation keeps a function table the visitor looks up by name.

When Not to Use Visitor

Don’t use Visitor when the object structure changes frequently: each new node type forces an edit in every visitor. Use it when the structure is stable but the algorithms change or multiply. For unstable hierarchies, keep the methods on the classes themselves or reach for the discriminated-union switch shown in Variants — you keep exhaustiveness checking and drop the accept() machinery.

See Also

Frequently Asked Questions

How is this different from Strategy?

Strategy varies an algorithm for a single object. Visitor applies different operations across an entire object structure, dispatching on both the element type and the visitor type.

Can I use Visitor with the Composite pattern?

Yes — it's the standard pairing. Composite owns the tree structure; Visitor owns the operations over it. The AST examples on this page are Composite structures with Visitor traversals.

When should I prefer a discriminated union over Visitor?

When operations are few, stateless, and unlikely to grow. A switch over a union type gives you the same compile-time exhaustiveness with a fraction of the code. Visitor pays off once operations carry their own state or you want each one isolated and testable.

What happens when I add a new node type?

Every concrete visitor needs a new visitX method. TypeScript enforces this at compile time, so nothing fails silently — but the edit cost lands on all visitors at once. That's the deliberate trade the pattern makes.

Should visitors be stateless?

Prefer stateless when the operation allows it. Evaluator needs its stack, so it can't be — the fix is discipline: reset the state before each traversal or instantiate a fresh visitor per tree.

Does Visitor work on recursive structures?

Recursive structures are its home turf. visitBinaryOp calls accept on its children, so the traversal recurses on its own. Watch out for cycles — the pattern assumes a tree, and a cyclic graph will recurse forever unless the visitor tracks visited nodes.