Skip to content

refactor(zod): render generated export blocks from a single emitter #3810

Description

@the-ult

What's wrong

Nothing owns the shape of a generated Zod export block — the export const, its companion
types, and its Item companion. That shape lives in eleven template literals spread over two
packages, and the two sets have drifted apart. Per-operation schemas get branding and an Item
split but never companion types; reusable component schemas get companion types and a recursive
pin but never branding or an Item split. Both behaviours are fine on their own. What isn't
fine is that neither is written down anywhere except as repeated string concatenation, so
changing export shape means editing eleven places and reconciling two conventions by hand.

You can see the split in the output: tests/__snapshots__/zod/petstore/endpoints.ts has 52
export const and 0 export type, while component-schema output ships both type aliases for
every schema. Hono's *.zod.ts files inherit the per-operation form, since they route through
the same builder.

It also makes the tests awkward. packages/zod/src/zod.test.ts is 12,335 lines with 79
assertions against generateZod(...).implementation, 45 of which fabricate input with
} as unknown as Parameters<typeof generateZod>[0]. Those tests want to check what one export
block looks like, but the smallest thing to assert against is the whole client builder.

Where

Verified against master (70fc4fad4).

packages/zod/src/index.ts — 8 literals, 10 export const statements:

  • <Op>Params — 3370
  • <Op>QueryParams — 3376
  • <Op>Header — 3382
  • <Op>Body ± Item — 3389-3391
  • <Op>Response, no content — 3431
  • <Op>Response ± Item — 3438-3440

Helpers they lean on: brand (3288), zodArrayWithBounds (3295), allocateExportName (3333).

packages/orval/src/write-zod-specs.ts — 3 literals:

  • schema-file entry — 241-244
  • reusable recursive — 365-367 (inside renderReusableSchemaEntry, 295)
  • reusable acyclic — 374-376

Callers of generateZod outside @orval/zod: packages/hono/src/index.ts:35, 909, 962.

Proposed change

Add packages/zod/src/export-emitter.ts, sibling to compatible-v4.ts, and route all eleven
sites through it. No new dependency edges: orval and @orval/hono already depend on
@orval/zod. @orval/effect is out of scope — it only depends on @orval/core.

Make it a pure renderer: descriptor in, string out. No ContextSpec, no OpenAPI, so it can be
tested with plain objects. Sketch, not a settled API:

export interface ZodExportBlock {
  name: string;                  // already allocated by the caller
  expression: string;            // already-rendered zod expression
  variant: ZodVariantOption;     // 'classic' | 'mini'
  isZodV4: boolean;
  brand?: boolean;
  companionTypes?: boolean;
  arrayItem?: { rules?: { min?: number; max?: number } };
  recursivePin?: { tsBody: string };
}

export const renderZodExport = (block: ZodExportBlock): string => { /* … */ };

It owns: companion types and the input/Output pairing; brand placement (on the wrapper,
never on Item); the Item split, including the Mini-vs-classic bounds syntax now in
zodArrayWithBounds; the recursive pin.

It does not own: name allocation (allocateExportName needs ref context and stays put); the
recursive TS body (renderReusableSchemaEntry keeps resolveValue, sub-models, and extra
imports, and passes tsBody in — pulling that in would need fixtures and lose the point of the
module); hoisted consts (…Default, …RegExp0), which keep crossing as pre-rendered strings.

Why @orval/zod and not @orval/core

#3650 adds a similar emitter and puts it in core. Suggested rule that fits both: a shared
emitter lives in the lowest package all its callers already depend on. For #3650 that's core
(angular, fetch, and query don't depend on @orval/zod); for this one it's @orval/zod (zod,
orval, and hono all do). Putting it in core would push Zod-specific syntax — brand form,
zod.input/zod.output, the Mini functional API — below the package that owns it, with no gain
in reach. Easy to move if maintainers prefer one location.

Acceptance

  • pnpm test:snapshots shows zero diff across every tracked snapshot file. This change is
    behaviour-preserving, and that is the whole review.
  • All eleven export templates in packages/zod/src/index.ts and
    packages/orval/src/write-zod-specs.ts render through renderZodExport; no export const
    string concatenation is left in either file for these blocks.
  • New packages/zod/src/export-emitter.test.ts with plain-object unit tests, modelled on
    compatible-v4.test.ts, covering: companion types on/off, brand on/off, Item split in both
    classic and Mini with and without min/max bounds, and the recursive pin.
  • The recursive pin from fix(zod): emit recursive reusable schemas with full TypeScript types #3467 is preserved byte for byte.

Out of scope: moving the 79 existing result.implementation assertions onto the new interface.
It wouldn't change the zero-diff proof and would roughly triple the diff on the PR that most
needs careful reading. Better as a follow-up, tests only.

Related

Metadata

Metadata

Assignees

Labels

zodZod schema client related issue

Type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions