ast-visitor-pattern

Use the frozen-class/visitor pattern for discriminated unions that have multiple dispatch sites. Use when creating a new set of variants (commands, IR nodes,…

npx skills add https://github.com/prisma/prisma-next --skill ast-visitor-pattern

AST Class/Visitor Pattern

When a discriminated union has 3+ variants and 2+ dispatch sites (renderers, serializers, classifiers, etc.), replace plain union + switch with frozen subclasses and a visitor interface. This makes adding a new variant a compiler error at every consumer, instead of a silent omission.

Structure

Four pieces, always in the same file:

// 1. Abstract base (not exported — consumers use the union type)
abstract class FooNode {
  abstract readonly kind: string;
  abstract accept<R>(visitor: FooVisitor<R>): R;
  protected freeze(): void { Object.freeze(this); }
}

// 2. Visitor interface
export interface FooVisitor<R> {
  bar(node: BarNode): R;
  baz(node: BazNode): R;
}

// 3. Concrete subclasses — readonly fields, freeze() in constructor
export class BarNode extends FooNode {
  readonly kind = 'bar' as const;
  readonly value: string;
  constructor(value: string) {
    super();
    this.value = value;
    this.freeze();
  }
  accept<R>(visitor: FooVisitor<R>): R { return visitor.bar(this); }
}

export class BazNode extends FooNode {
  readonly kind = 'baz' as const;
  readonly count: number;
  constructor(count: number) {
    super();
    this.count = count;
    this.freeze();
  }
  accept<R>(visitor: FooVisitor<R>): R { return visitor.baz(this); }
}

// 4. Union type
export type Foo = BarNode | BazNode;

Consuming

Define a visitor object (or class) per concern:

const renderVisitor: FooVisitor<string> = {
  bar(node) { return node.value; },
  baz(node) { return String(node.count); },
};

function render(node: Foo): string {
  return node.accept(renderVisitor);
}

Always construct instances, never frozen object literals

This holds everywhere a node is built — tests and production construction surfaces (contract-free factories, builders). A factory must return new BarNode(...), never Object.freeze({ kind: 'bar', value: 'x' }). A frozen plain object has no prototype, so instanceof fails, accept() is missing, and a downstream shallow-copy ({ ...node }) silently strips the type back to an anonymous bag; constructor-time invariants are skipped too.

// ✅
const call = new BarNode('x');
export function bar(value: string): BarNode { return new BarNode(value); }

// ❌
const call: Foo = { kind: 'bar', value: 'x' };
export function bar(value: string): Foo { return Object.freeze({ kind: 'bar', value }); }

When NOT to use

  • Single dispatch site → plain union + switch is simpler
  • Fewer than 3 variants with no expected growth → not worth the boilerplate

Codebase examples

  • MongoAstNode / MongoDdlCommandVisitorpackages/2-mongo-family/4-query/query-ast/src/ddl-commands.ts
  • OpFactoryCall / OpFactoryCallVisitorpackages/3-mongo-target/1-mongo-target/src/core/op-factory-call.ts