← Design patterns
Behavior Operations over a known vocabulary

Visitor

One structure, another operation.

Your editor stores text, images, and groups. First it needs a readable export. Then it needs a list of image assets. Then someone asks for a review of image descriptions.

The document still has the same three shapes. What keeps growing is the list of things you want to do with them. Let’s give each of those jobs a home of its own.

TypeScriptGoSame document and results · across the comparison

01 / The idea

Where should the next operation live?

Giving each node an export method is a reasonable start. A text node returns its text, an image contributes its description, and a group combines its children. The operation is easy to find beside the data it uses.

Then the inventory arrives, and the description review after it. If each one adds a method to every node, each workflow ends up spread across the document model. You want to change how export works without opening the files that define what a document is.

Visitor puts an operation in a separate object with a method for each supported node type. A node accepts that object and routes the call to its corresponding method. The operation can then keep its rules and accumulated result together.

So the visitor knows the document’s vocabulary, and each node knows which visitor method is its own. That makes a new operation cheap: you write one more visitor. A new kind of node is the expensive change, because every visitor needs a method for it.

Document nodes

Describe the content.

Text, Image, and Group hold the article’s structure and route a visit to the matching method.

One visitor

Own one operation.

Export, inventory, or review supplies its own rules, traversal decisions, and accumulated output.

The caller

Choose and run the work.

Create a fresh operation, pass it to the root, and consume the result after the run finishes.

02 / See the shape

The node chooses a handler. The handler owns the policy.

Follow an Image node: accept receives the operation, then calls its image method with the node. Text export reads the description; inventory reads the source; review checks the relationship between description and decorative intent.

Group.accept only dispatches to the group method. Each visitor decides whether to walk the children. Text export stops at a draft group. Inventory includes unpublished assets, and review includes unpublished images. Children are visited in their stored order.

The basic view shows this dispatch contract. The practical view adds all three operations and their traces. The call site runs them over the same article; complete files include everything needed to reproduce the output.

Three node variants and three required visitor methods. Accept selects the matching method; it does not walk the children. The implementations use native dispatch mechanisms.

TypeScriptReading
document.ts
export interface Visitor {
	visitText(node: TextNode): void;
	visitImage(node: ImageNode): void;
	visitGroup(node: GroupNode): void;
}
export interface DocumentNode {
	readonly id: string;
	accept(visitor: Visitor): void;
}
export class TextNode implements DocumentNode {
	readonly id: string;
	readonly text: string;
	constructor(id: string, text: string) {
		this.id = id;
		this.text = text;
	}
	accept(visitor: Visitor) {
		visitor.visitText(this);
	}
}
export class ImageNode implements DocumentNode {
	readonly id: string;
	readonly src: string;
	readonly alt: string;
	readonly decorative: boolean;
	constructor(id: string, src: string, alt: string, decorative: boolean) {
		this.id = id;
		this.src = src;
		this.alt = alt;
		this.decorative = decorative;
	}
	accept(visitor: Visitor) {
		visitor.visitImage(this);
	}
}
export class GroupNode implements DocumentNode {
	readonly id: string;
	readonly draft: boolean;
	readonly children: readonly DocumentNode[];
	constructor(id: string, draft: boolean, children: readonly DocumentNode[]) {
		this.id = id;
		this.draft = draft;
		this.children = [...children];
	}
	// Dispatch only. This method does not walk the children.
	accept(visitor: Visitor) {
		visitor.visitGroup(this);
	}
}
GoAlongside
document.go
type Visitor interface {
	VisitText(*TextNode)
	VisitImage(*ImageNode)
	VisitGroup(*GroupNode)
}
type DocumentNode interface{ Accept(Visitor) }
type TextNode struct{ ID, Text string }

func (n *TextNode) Accept(v Visitor) { v.VisitText(n) }

type ImageNode struct {
	ID, Src, Alt string
	Decorative   bool
}

func (n *ImageNode) Accept(v Visitor) { v.VisitImage(n) }

type GroupNode struct {
	ID       string
	Draft    bool
	Children []DocumentNode
}

// Dispatch only. The visitor decides whether to walk children.
func (n *GroupNode) Accept(v Visitor) { v.VisitGroup(n) }
Reading the TypeScriptAn interface for operations, a narrow accept method for nodes

Each class’s accept method names the corresponding visitor method. That method receives the concrete node type, so visitImage can read src and alt without inspecting a tag. The first call is chosen by the node’s runtime type and the second by the visitor’s; this is the classic double-dispatch arrangement.

A switch over a discriminated union would also work; section 06 compares it. The example uses accept so that an operation is one object implementing Visitor. When the interface gains a method, TypeScript reports each visitor class that lacks it.

Readonly fields are a compile-time rule, not a deep runtime freeze. Group copies the child array’s membership, but it retains the child objects. The supplied visitors only read the tree; they keep mutable output arrays in their own instances.

Reading the GoInterfaces provide dispatch without a base class

DocumentNode requires Accept. TextNode, ImageNode, and GroupNode each implement it with a pointer receiver. Their method bodies select VisitText, VisitImage, or VisitGroup on the Visitor interface.

The visitor’s pointer retains its output while child calls run. Embedded Report fields keep results together; the asset visitor also owns a map for membership checks. Output order comes from the appended slice, not map iteration. Go does not make the node pointers read-only: these visitors follow that contract by convention.

03 / Follow the walk

The same root can produce a different walk.

Run text export with the notes kept as draft. Then switch to inventory. Predict whether draft-image is visited, even though its source has already appeared on hero. A callback can happen without adding an output entry.

Try an empty hero description, or mark it decorative while keeping a description. Compare text export with image review: omitting text and reporting a description issue are different policies.

One document, three operations

Keep the document in view. Choose an operation and predict whether draft-image will receive a callback, then inspect the recorded visit sequence.

Text and nonempty informative image descriptions become lines. Draft groups stop traversal; decorative images contribute no line.

Document tree

9 nodes

The structure stays the same while the operation changes.

  1. pagegroup—

    Published group

  2. headingtext—

    Visitor in practice

  3. heroimage—

    /cover.png · informative

  4. bodygroup—

    Published group

  5. introtext—

    One document, several operations.

  6. dividerimage—

    /divider.svg · decorative

  7. draftgroup—

    Draft group

  8. draft-notetext—

    Unpublished notes

  9. draft-imageimage—

    /cover.png · informative

Export readable text

Ready to run

Run the operation to see its output and follow the callbacks. Changing a document input clears the previous result.

The lab runs the displayed TypeScript synchronously; the replay controls step through callbacks recorded after the run finishes. Go is checked separately. Image sources are plain labels.

04 / Try a decision

A new operation and a new node are different changes.

A word-count visitor can implement the existing three methods. The node definitions need no word-count method. Setup still needs to expose or call that new operation.

Video changes the vocabulary itself. Every operation now needs a policy for something it did not previously understand. This is the other half of the design, and a useful test of whether it fits the changes you expect.

The editor adds a Video node.

The three visitors currently handle Text, Image, and Group. What should happen before the new node is accepted as part of the document vocabulary?

Reasoning and a stopping pointAlso available without JavaScript

Adding an operation usually means adding one visitor implementation. Adding a node type changes the vocabulary understood by every operation. In this design, add the handler to the contract, route Video to it, and make each operation’s policy explicit—even if that policy is deliberately “ignore this variant.”

TypeScript and Go check required methods after the visitor interface changes: TypeScript reports each visitor class that lacks the new method, and Go reports each place a visitor is passed as a Visitor. They cannot infer that an unrelated new class or struct must receive a new handler. A closed set of variants can require a new branch when a variant is added. None of those checks proves that a caption policy is correct.

If your node types change every week while the operations are stable, methods on nodes or a simpler set of functions may fit better. A visitor is a decision about which kind of change you want to concentrate.

Name one operation you could add without changing the nodes. Then name every decision that adding Video would require.

This reflection is not saved or automatically assessed.

05 / Give it a real job

Run a fresh operation over a stable document.

An export button, build task, or review command can pass the current document to runOperation. It creates a fresh visitor and returns that run’s output and callback trace. Reusing a TextExport instance would append another run’s lines to the previous ones; the helper deliberately avoids that lifetime.

The supplied operations read the tree without changing it, synchronously, over trusted finite input. An image source is only a string: inventory neither normalizes URLs nor checks files.

Review has two authored rules: flag an empty description on an informative image, and flag a nonempty one on an image marked decorative. Whitespace is preserved. Passing them shows that those two conditions hold, not that the descriptions are good or the document accessible.

Production changeDecision to make
Add another report over the same nodesAdd an operation implementation and wire its caller. Give each existing node kind an explicit policy.
Add a Video nodeExtend the node vocabulary, dispatch, and visitor contract. Review every operation’s behavior for Video.
Process documents from outside the applicationValidate structure, permitted values, size, and nesting before traversal.
Write files or fetch data during a visitDefine error propagation, cancellation, partial results, and side effects.
Who owns recursion—and what happens to shared nodes?Dispatch and traversal are separate contracts

In this sample, group visitor methods own recursion. Adding another child loop inside Group.accept would visit descendants again. A separate walker can be useful when operations share one traversal policy, but its contract must explain how a handler continues, skips, or replaces that walk.

The recursive calls use the runtime call stack, so arbitrarily deep input can exhaust it. TypeScript and Go can also be given shared node references or cycles. These operations count each occurrence along a path and do not detect cycles. An owned tree representation may not preserve shared node identity in the same way.

Asset deduplication is a separate policy about source strings. Two image nodes can both receive callbacks while contributing one asset.

When a visitor fails partwayExceptions, partial output, and side effects

An unexpected exception or panic stops the run without a successful report or any rollback. The supplied operations only read, so the input survives; a new visitor with side effects would leave those effects in place.

If a report can fail for expected reasons, choose a result or error contract and decide whether partial output is meaningful. For a transformation, returning a new document is a different responsibility from collecting this report.

06 / Make the call

A visitor is one way to keep operations separate.

A TypeScript discriminated union can also support ordinary functions that switch on each variant. Each function remains a separate operation. For a small vocabulary and a few functions, that can be the clearest arrangement.

The visitor contract becomes useful when operations need a shared extension interface, reusable traversal hooks, or state that lasts across a walk. A match-based implementation can select the method, while the visitor contract describes what an operation must provide.

Required visitor methods expose missing implementations after the contract is updated. Default handlers can make partial visitors convenient while weakening that coverage signal.

Keep behavior on nodes when it belongs to their responsibilities and adding node kinds is the common change. Keep direct functions when they express the job clearly. Reach for Visitor when keeping operations together earns the extra dispatch contract.

07 / Already in your toolbox

Read the callback contract before writing the handler.

Go’s ast.Visitor works with ast.Walk. Visit returns the visitor to use for children; returning nil skips them. Walk also sends a nil-node callback after visiting children. That library owns the walk, unlike our group methods.

Build UIs?Every lint rule you run visits your source one node type at a time, and one day your team will need a rule of its own.

Where it already is in your components

The lint rules you run on every save are written this way. An ESLint rule’s create() “Returns an object with methods that ESLint calls to "visit" nodes while traversing the abstract syntax tree”. Each key is a node type or a selector, and a key ending in :exit runs on the way back up, after the node’s children. Like Go’s ast.Walk, ESLint owns the walk, and a rule lists only the node types it cares about, where our visitors must handle all three.

Babel traverse works the same way for code transformations: the caller provides handlers for particular node types and the library supplies the traversal, though its paths and mutation facilities go well beyond this document visitor.

When you have to own it

Now your team wants a rule the plugins do not ship, such as flagging an image component used without a description. You write the create() object: one method for the node type that matters, one decision about what to report, and nothing about how the tree is walked. That is this lesson’s split, with the linter as the owner of traversal and your rule as one operation over a vocabulary you did not define.

>

08 / Take the idea with you

Which kind of change should have one home?

Explain the design without saying “Visitor”: these nodes describe the document, this operation knows what each kind means, and this part owns the walk. Then name one new operation that stays together—and one new node type that would make you inspect every operation.

Choose Visitor when a known vocabulary keeps gaining independent operations, and you are willing to revisit those operations when the vocabulary changes.

Connections to follow nextRelated lessons

Composite supplies a common contract for individual elements and groups. Visitor supplies operations over those shapes. They can appear together, but a visitor can also work over a flat collection of variants. Interpreter is the contrast: each expression interprets itself, so evaluation lives in the node types. Here the node types only route a call, and each operation lives outside them.

Discriminated unions make the alternatives explicit and support matching. Strategy supplies an interchangeable policy; Visitor additionally gives an operation a method for each supported shape. Mediator owns collaboration rules between participants; it does not inherently define a walk over node variants.