An expression tree: numbers, additions, multiplications. You need to evaluate it. Then print it. Then measure its depth. Then type-check it. Then optimise it.
Each new operation means a new method on every node class. Five operations across three node types is fifteen methods, and the node classes keep changing for reasons that have nothing to do with what a node is.
The pattern
Move the operation into its own object, and let nodes hand themselves to it.
interface Visitor<T> { visitNum(node: Num): T; visitAdd(node: Add): T; visitMul(node: Mul): T;}
abstract class Node { abstract accept<T>(visitor: Visitor<T>): T;}
class Add extends Node { constructor(readonly left: Node, readonly right: Node) { super(); } accept<T>(visitor: Visitor<T>): T { return visitor.visitAdd(this); }}An operation is now one class:
class Evaluate implements Visitor<number> { visitNum(n: Num) { return n.value; } visitAdd(n: Add) { return n.left.accept(this) + n.right.accept(this); } visitMul(n: Mul) { return n.left.accept(this) * n.right.accept(this); }}Three passes over the same tree, none of which touched a node class:
Add ├─ Num 2 └─ Mul ├─ Num 3 └─ Num 4
14
A number. The visitor combines children with the node’s own operator.
The trade, stated plainly
Easy: a new operation. One class, zero edits elsewhere.
Hard: a new node type. visitDivide goes on the Visitor interface, and
every existing visitor must implement it.
In TypeScript, use a discriminated union
If the node types are yours and finite, the language has a better answer:
type Node = | {kind: 'num'; value: number} | {kind: 'add'; left: Node; right: Node} | {kind: 'mul'; left: Node; right: Node};
function evaluate(node: Node): number { switch (node.kind) { case 'num': return node.value; case 'add': return evaluate(node.left) + evaluate(node.right); case 'mul': return evaluate(node.left) * evaluate(node.right); }}An operation is one function. Adding a node type makes the compiler flag every
switch that no longer covers all cases — the same protection the visitor
interface gave, without accept methods.
This is Visitor’s benefit with none of its ceremony, and it is what you should write when the type set is closed and under your control. Reach for the classic version when node types come from elsewhere, or when the language has no sum types.
What it costs
Encapsulation. Visitors need the node’s data, so fields go public. The operation has been separated from the type by exposing the type’s insides.
Traversal is duplicated. Every visitor above recurses into children itself. A shared base visitor helps, but then visitors that need a different order have to fight it.
It is a lot of code. For three node types and two operations, do not do this. The break-even is somewhere around five of each.
Where it already is
Compilers, everywhere — an AST plus one visitor per pass. ESLint rules are visitors over the JavaScript AST. Babel plugins are visitors. XML/DOM traversal APIs. Anywhere the data shape is fixed and the number of things you do to it is not.