Visitor pattern: Design Patterns lessons from production
The Visitor pattern solves a deceptively common problem: you have a tree or graph structure with multiple node types, and you need to perform many different operations on it without scattering logic across your domain objects. When you find yourself saying "I need to add validation, then rendering, then export functionality to this AST," you've found the Visitor pattern's sweet spot. Let's look at how teams in production use this pattern, where it shines, and when simpler alternatives are worth considering instead.
The use case — Walking an AST, a document tree, or a rules tree
Imagine you're building a spreadsheet formula parser or a business rules engine. You parse a string like "IF(SUM(A1:A10) > 100, "high", "low") into an abstract syntax tree (AST). That tree has function call nodes, reference nodes, literal nodes, and comparison nodes. Now you need to:
- Validate that all references point to real cells
- Render it as human-readable text for display
- Optimize it by folding constants
- Export it to JSON for serialization
- Execute it
If you put all this logic into the node classes themselves, each node class becomes huge and violates the single responsibility principle. The Visitor pattern lets you extract each operation into its own visitor class.
We've seen this pattern in production with document processors (walking XML/HTML trees), approval workflows (traversing state machine diagrams), and pricing engines (evaluating rule trees). The problem they all share: the structure is stable, but the operations are many and changing.
Double dispatch — Each node calls the right visitor method
The magic of the Visitor pattern is double dispatch. Instead of the visitor asking "what type are you?" and doing a type check, each node knows to call the right method on the visitor. Here's a simplified formula evaluation system:
// Our stable tree structure
interface FormulaNode {
accept(visitor: FormulaVisitor): any;
}
class LiteralNode implements FormulaNode {
constructor(readonly value: number) {}
accept(visitor: FormulaVisitor) {
return visitor.visitLiteral(this);
}
}
class BinaryOpNode implements FormulaNode {
constructor(readonly left: FormulaNode, readonly op: string, readonly right: FormulaNode) {}
accept(visitor: FormulaVisitor) {
return visitor.visitBinaryOp(this);
}
}
class FunctionCallNode implements FormulaNode {
constructor(readonly name: string, readonly args: FormulaNode[]) {}
accept(visitor: FormulaVisitor) {
return visitor.visitFunctionCall(this);
}
}
// The visitor interface
interface FormulaVisitor {
visitLiteral(node: LiteralNode): any;
visitBinaryOp(node: BinaryOpNode): any;
visitFunctionCall(node: FunctionCallNode): any;
}
When you call node.accept(visitor), the node calls the specific visitor method for its type. This is dispatch number one (the runtime finds the right accept method based on the node's actual type). Then, within accept, the node calls the specific visitor method for its own type—dispatch number two.
The result: each visitor implementation handles each node type separately, without type checks or casting. The structure remains untouched. Each new visitor is a new file with a single concern.
Many operations — Validation, rendering and export as separate visitors
Let's implement three different visitors for our formula tree. First, a validator that checks whether referenced cells exist:
class ValidationVisitor implements FormulaVisitor {
constructor(private validCells: Set) {}
visitLiteral(node: LiteralNode) {
return { valid: true, errors: [] };
}
visitBinaryOp(node: BinaryOpNode) {
const leftResult = node.left.accept(this);
const rightResult = node.right.accept(this);
const errors = [...leftResult.errors, ...rightResult.errors];
return {
valid: errors.length === 0,
errors
};
}
visitFunctionCall(node: FunctionCallNode) {
// Validate that SUM, AVG, etc. exist and have right arg count
const knownFunctions = ['SUM', 'AVG', 'COUNT', 'MAX', 'MIN'];
if (!knownFunctions.includes(node.name)) {
return { valid: false, errors: [`Unknown function: ${node.name}`] };
}
const argErrors = node.args.flatMap(arg => arg.accept(this).errors);
return {
valid: argErrors.length === 0,
errors: argErrors
};
}
}
// Usage
const ast = new BinaryOpNode(
new FunctionCallNode('SUM', [new LiteralNode(10)]),
'>',
new LiteralNode(5)
);
const validator = new ValidationVisitor(new Set(['A1', 'A2']));
const validationResult = ast.accept(validator);
console.log(validationResult);
Now a rendering visitor that produces human-readable output:
class RenderingVisitor implements FormulaVisitor {
visitLiteral(node: LiteralNode): string {
return String(node.value);
}
visitBinaryOp(node: BinaryOpNode): string {
const left = node.left.accept(this);
const right = node.right.accept(this);
return `(${left} ${node.op} ${right})`;
}
visitFunctionCall(node: FunctionCallNode): string {
const args = node.args.map(arg => arg.accept(this)).join(', ');
return `${node.name}(${args})`;
}
}
// Usage
const renderer = new RenderingVisitor();
console.log(ast.accept(renderer));
// Output: (SUM(10) > 5)
And an export visitor for serialization:
class ExportVisitor implements FormulaVisitor {
visitLiteral(node: LiteralNode): object {
return { type: 'literal', value: node.value };
}
visitBinaryOp(node: BinaryOpNode): object {
return {
type: 'binaryOp',
op: node.op,
left: node.left.accept(this),
right: node.right.accept(this)
};
}
visitFunctionCall(node: FunctionCallNode): object {
return {
type: 'functionCall',
name: node.name,
args: node.args.map(arg => arg.accept(this))
};
}
}
// Usage
const exporter = new ExportVisitor();
const json = JSON.stringify(ast.accept(exporter), null, 2);
console.log(json);
Three separate concerns, three separate classes. To add a new operation—say, an optimizer that simplifies constant expressions—you write a new visitor. The node classes never change. This is exactly what makes the pattern valuable in production systems where business requirements evolve faster than your core data structures.
Modern alternative — Pattern matching over discriminated unions often reads better
The classic Visitor pattern works, but modern languages offer alternatives that can be clearer. Using TypeScript's discriminated unions with pattern matching (or a series of type guards and conditional logic), you can often write more readable code:
// Discriminated union—more modern, flat structure
type FormulaNode =
| { type: 'literal'; value: number }
| { type: 'binaryOp'; op: string; left: FormulaNode; right: FormulaNode }
| { type: 'functionCall'; name: string; args: FormulaNode[] };
// No visitor interface needed. Just functions.
function validate(node: FormulaNode, validCells: Set): { valid: boolean; errors: string[] } {
switch (node.type) {
case 'literal':
return { valid: true, errors: [] };
case 'binaryOp': {
const leftResult = validate(node.left, validCells);
const rightResult = validate(node.right, validCells);
return {
valid: leftResult.valid && rightResult.valid,
errors: [...leftResult.errors, ...rightResult.errors]
};
}
case 'functionCall': {
const knownFunctions = new Set(['SUM', 'AVG', 'COUNT']);
if (!knownFunctions.has(node.name)) {
return { valid: false, errors: [`Unknown function: ${node.name}`] };
}
const argErrors = node.args.flatMap(arg => validate(arg, validCells).errors);
return { valid: argErrors.length === 0, errors: argErrors };
}
}
}
function render(node: FormulaNode): string {
switch (node.type) {
case 'literal':
return String(node.value);
case 'binaryOp':
return `(${render(node.left)} ${node.op} ${render(node.right)})`;
case 'functionCall': {
const args = node.args.map(render).join(', ');
return `${node.name}(${args})`;
}
}
}
// Usage
const ast: FormulaNode = {
type: 'binaryOp',
op: '>',
left: { type: 'functionCall', name: 'SUM', args: [{ type: 'literal', value: 10 }] },
right: { type: 'literal', value: 5 }
};
console.log(validate(ast, new Set()));
console.log(render(ast));
This approach works well when:
- You control the data structure and don't need to hide it behind classes
- Operations are relatively stable (few new operations being added)
- Your team is already comfortable with discriminated unions and pattern matching
It's simpler to understand and requires less boilerplate. However, the classical Visitor pattern remains better when your structure is truly stable but operations are constantly evolving, or when you need to decouple your domain model from the operations that consume it.
Common mistakes
- State management across visits. Visitors should be stateless or pass state explicitly. Storing mutable state on the visitor between node visits is a recipe for bugs, especially in concurrent scenarios.
- Breaking encapsulation. Avoid exposing internal node details just to make the visitor simpler. Keep your node API clean; the visitor should work with public methods, not reaching into private fields.
- Overengineering for "flexibility." If you only have one or two operations and they're unlikely to change, the Visitor pattern introduces needless complexity. Use it when you genuinely have multiple, independent operations on a stable structure.
- Forgetting to handle new node types. When you add a new node type to your tree, every visitor implementation must add a handler for it. Use TypeScript's strict mode and
exhaustivenesschecks to catch these at compile time. - Mixing traversal logic with business logic. Keep visitors focused on one concern. If you find yourself writing complex pre/post-order traversal logic and business logic in the same visitor, consider breaking it into separate visitors or adding helper methods.
Key takeaways
- Use the Visitor pattern when you have a stable tree/graph structure and multiple, independent operations to perform on it.
- Double dispatch—each node calls the right visitor method by type—keeps your node classes clean and operation logic separate.
- Each new operation becomes a new visitor class. This scales well as requirements grow without modifying existing code.
- Modern pattern matching with discriminated unions often provides a simpler, more readable alternative for smaller or more flexible data models.
- Watch for state management issues, encapsulation leaks, and overengineering when simpler approaches would suffice.
- In production, use this pattern when ASTs, workflows, or rule engines need multiple view-specific transformations without coupling business logic to the structure itself.






Comments
Loading comments…