Pattern: Frozen-class AST + visitor

May 10, 2026 · View on GitHub

Status: Stable Maintainer: architect

Intent

Migration ops are a tree of kinds: CreateTableCall, AddColumnCall, DropIndexCall, and a Postgres-only CreateExtensionCall. Several walks consume that tree — the renderer that prints TypeScript, the runner that applies ops, the differ that compares two op sets. When a new kind lands (say a Postgres CreateMaterializedViewCall), every walk needs to know about it; quietly forgetting one is a bug that won't surface until production.

The pattern: every kind is a small concrete class extending an abstract base; the base declares an accept(visitor) method; consumers dispatch through the visitor instead of through switch (node.kind). Adding a new kind is a compile error in every walk that hasn't handled it. Each instance is Object.freeze'd in its constructor so the tree is immutable once built.

When to use

  • The tree has more than two kinds and consumers need exhaustive kind-narrow dispatch.
  • The tree round-trips through JSON (pairs naturally with JSON-canonical / class-in-memory round-trip).
  • Targets need to extend the framework's set of kinds with target-only kinds (pairs with Three-layer polymorphic IR).
  • Multiple distinct walks exist (planning, lowering, rendering, diffing) and each wants a checked exhaustiveness signal when a new kind lands.

When NOT to use

  • Stateful services (registries, runtimes, adapters, drivers) — use Interface + factory function. Services have lifecycle and behaviour, not polymorphic data. The catalogue's deliberate split: services hide their classes; AST nodes are their classes.
  • Single-instance value objects that nobody dispatches over polymorphically — a plain interface plus a frozen literal is enough.
  • Trees that never need polymorphic dispatch — if the only consumer is a single switch, a discriminated union of plain objects is cheaper to read.
  • Hot-path data structures where allocation dominates — class instances per node have measurable overhead vs. plain objects; profile before adopting in tight loops.

Structure

abstract class FooAstNode {                      // package-private base
  abstract readonly kind: string;                 // literal discriminator
  abstract accept<R>(visitor: FooVisitor<R>): R;  // exhaustive dispatch
  abstract rewrite(rewriter: FooRewriter): FooAst; // optional: transform
  protected freeze(): void { Object.freeze(this); }
}

export class FooLiteral extends FooAstNode {     // concrete class per kind
  readonly kind = 'literal' as const;
  readonly value: string;
  constructor(value: string) {
    super();
    this.value = value;
    this.freeze();                                // frozen at construction
  }
  accept<R>(v: FooVisitor<R>): R { return v.literal(this); }
  rewrite(_: FooRewriter): FooAst { return this; }
}

export interface FooVisitor<R> {                  // exhaustive contract
  literal(node: FooLiteral): R;
  binary(node: FooBinary): R;
  // ...one method per kind
}

The base is package-private; consumers see only the framework-level interface (e.g. OpFactoryCall) and the discriminated union of concrete classes (e.g. PostgresOpFactoryCall). The kind field lets a non-visitor consumer narrow ad hoc; the visitor lets the type system prove exhaustiveness across kinds.

Reference implementations

ImplementationPathDemonstrates
Postgres migration ops IRpackages/3-targets/3-targets/postgres/src/core/migrations/op-factory-call.tsAbstract PostgresOpFactoryCallNode base + one concrete *Call class per pure factory; freeze() in constructor; polymorphic toOp() and inherited renderTypeScript() hooks.
Mongo migration ops IRpackages/3-mongo-target/1-mongo-target/src/core/op-factory-call.tsSame shape on the Mongo target; demonstrates the pattern's portability across SQL- and document-shaped targets.
Mongo schema IRpackages/2-mongo-family/3-tooling/mongo-schema-ir/src/schema-node.ts (with siblings schema-ir.ts, schema-collection.ts, schema-index.ts, schema-validator.ts)Visitor extracted into a dedicated visitor.tsMongoSchemaVisitor<R> with one method per node kind.
Mongo filter expressionspackages/2-mongo-family/4-query/query-ast/src/filter-expressions.tsBoth the visitor (MongoFilterVisitor<R>) and the rewriter (MongoFilterRewriter) variants on the same hierarchy; brand-tagged via non-enumerable Object.defineProperty.
Mongo aggregation expressions, stages, wire commandsaggregation-expressions.ts, stages.ts, wire-commands.tsThe pattern scales across the full Mongo query stack, not just one IR layer.

Cautions / common mistakes

  • Forgetting freeze() in the constructor. A class that allows post-construction mutation breaks the round-trip invariant and the visitor's "data is final after accept" contract.
  • Storing non-JSON-clean fields (Map, Set, Date, methods on properties) on a class that round-trips through JSON. The catalogue's JSON-canonical / class-in-memory round-trip entry spells out the constraint; this pattern alone does not.
  • Skipping the visitor and dispatching with switch (node.kind) everywhere. That works until a new kind lands and the compiler cannot tell you which switches need a new arm. The visitor interface is the cheap way to make exhaustiveness a build error.
  • Exporting the abstract base class. Consumers should see the framework-level interface and the discriminated union of concrete classes; the abstract base is an implementation detail.