Skip to content

feat(zod): add AsSchemas mutation for Zod v4 codegen#85

Draft
Emyrk wants to merge 1 commit into
mainfrom
feat/zod-mutation
Draft

feat(zod): add AsSchemas mutation for Zod v4 codegen#85
Emyrk wants to merge 1 commit into
mainfrom
feat/zod-mutation

Conversation

@Emyrk
Copy link
Copy Markdown
Member

@Emyrk Emyrk commented May 29, 2026

Adds a zod package with one mutation, zod.AsSchemas, that rewrites every Interface and Alias into a Zod v4 schema plus an inferred type alias, and injects import { z } from "zod". Composes with the rest of the config mutations instead of duplicating them as a string-builder.

ts.ApplyMutations(
    config.EnumAsTypes,
    config.SimplifyOmitEmpty,
    zod.AsSchemas,
    config.ExportTypes,
)
out, _ := ts.Serialize()

Replaces the string-builder approach in #82 with an AST mutation. Closes the door on parallel TS output paths: existing mutations like ExportTypes, ReadOnly, TrimEnumPrefix continue to work on the rewritten declarations.

Conversion rules, fixture, test coverage

Pipeline composition

Other mutations that walk Interface or Alias (ReadOnly, TrimEnumPrefix, etc.) must run before AsSchemas because the originals are replaced. ExportTypes runs after because the new VariableStatement and Alias nodes start unmodified; ExportTypes adds export to them.

Conversion rules

  • Interface with no heritage: const FooSchema = z.object({...}); plus type Foo = z.infer<typeof FooSchema>;.
  • Interface with single-base heritage: BaseSchema.extend({...}) instead of z.object. Multiple bases panic since Zod has no multiple inheritance.
  • Alias whose Type is a union of string literals: z.enum([...]) for readability.
  • Alias with any other Type: recursive exprToZod plus the inferred type alias.
  • Field types: z.string, z.number, z.boolean for keywords; z.array for arrays; z.record(K, V) for Record<K, V>; bare references emit the paired <Name>Schema identifier; T | null collapses to .nullable(); single-non-null-member unions unwrap; QuestionToken appends .optional(); inline TypeLiteralNode emits z.object; intersections fold into a left-associative chain of z.intersection.
  • Self-references wrap in z.lazy((): z.ZodType => SelfSchema) so the value-position reference does not fire before the binding exists.
  • Cross-package prefix on bindings.Identifier flows through to the emitted schema and reference, matching the rest of the AST. Pinned by TestPrefixedReference.

Mutation API

// zod.AsSchemas
func AsSchemas(ts *guts.Typescript) {
    ts.AppendImport(/* import { z } from "zod" */)
    // walk every Interface and Alias; replace each with a
    // VariableStatement (the schema) plus an Alias (z.infer<>).
}

No options or filter helpers in this PR. If a future use case needs them, a AsSchemasFiltered variant can be added without changing this surface.

Fixture

testdata/zod/types.go defines a Base struct, an embedding Ticket struct with optional pointers, enums, slices, maps, and a self-reference, plus a CreateTicketRequest request body. testdata/zod/golden.ts is the Zod output. testdata/zod/zod.ts is the regular guts TS output, kept so the existing TestGeneration loop also covers this fixture.

Tests

zod/zod_test.go pins each conversion case in isolation. Each test installs a small *guts.Typescript via SetNode, runs AsSchemas, and asserts substrings of the serialized output. Cases:

  • TestObjectFields — keyword mappings (z.string, z.number, z.boolean) and QuestionToken.
  • TestStringLiteralUnionBecomesEnum — union-of-literals collapses to z.enum, no fallback to z.literal chain.
  • TestNullableFieldT | null collapses to .nullable().
  • TestOptionalNullableFieldQuestionToken plus T | null chains to .nullable().optional().
  • TestArrayFieldz.array(...).
  • TestReferenceField — bare reference resolves to <Name>Schema.
  • TestRecordFieldRecord<K, V> to z.record(K, V).
  • TestInlineObjectLiteral — inline TypeLiteralNode to z.object.
  • TestSelfReferenceLazy — recursive type wraps in z.lazy((): z.ZodType => SelfSchema).
  • TestHeritageExtend — single-base heritage to BaseSchema.extend.
  • TestAppendsZodImport — pins the import { z } from "zod" injection.
  • TestMixedUnionStaysUnion — non-literal, non-T|null union stays as z.union.
  • TestSingleMemberUnionUnwraps — single-member union skips z.union wrapper.
  • TestPrefixedReference — cross-package Identifier.Prefix reaches both the schema declaration and the reference.

zod/zod_e2e_test.go drives the full pipeline (EnumAsTypes, SimplifyOmitEmpty, AsSchemas, ExportTypes) against testdata/zod/types.go and diffs against testdata/zod/golden.ts. Run with -update to regenerate after intentional changes.

Output style

The TS printer is the source of truth for indentation, quote style, and trailing-comma policy. The PR #82 string-builder produced 2-space indents with trailing commas; this output uses the TS printer's defaults (4-space indents, no trailing commas) because we delegate to ts.createPrinter. If consumers care, they can run Biome or Prettier post-codegen.


Generated by Coder Agents on behalf of @Emyrk.

Introduces a zod package whose only exported mutation, zod.AsSchemas,
rewrites every Interface and Alias in a *guts.Typescript into a
Zod v4 schema VariableStatement plus an inferred type alias. The
mutation also injects `import { z } from "zod"` so the generated file
is self-contained.

Composability is the point. AsSchemas slots into the existing pipeline
alongside the config mutations rather than maintaining a parallel
string-builder. The recommended order is:

    ts.ApplyMutations(
        config.EnumAsTypes,
        config.SimplifyOmitEmpty,
        zod.AsSchemas,
        config.ExportTypes,
    )

EnumAsTypes lowers Go enums to unions of literals; SimplifyOmitEmpty
drops the null half of optional fields; AsSchemas rewrites the
interfaces and aliases; ExportTypes adds `export` to the new
declarations. Other mutations that walk Interface or Alias (ReadOnly,
TrimEnumPrefix, etc.) must run before AsSchemas because the originals
are replaced.

Conversion rules implemented:

- Interface with no heritage: `const FooSchema = z.object({...})` plus
  `type Foo = z.infer<typeof FooSchema>`.
- Interface with single-base heritage: `BaseSchema.extend({...})`
  instead of z.object. Multiple bases panic since Zod has no multiple
  inheritance.
- Alias whose Type is a union of string literals: `z.enum([...])`.
- Alias with any other Type: recursive exprToZod plus the inferred
  type alias.
- Field types: z.string, z.number, z.boolean for keywords; z.array
  for arrays; z.record(K, V) for Record<K, V>; bare references emit
  the paired Schema identifier; T | null collapses to .nullable();
  single-non-null-member unions unwrap; QuestionToken appends
  .optional(); inline TypeLiteralNode emits z.object; intersections
  fold into a left-associative chain of z.intersection.
- Self-references are wrapped in z.lazy((): z.ZodType => SelfSchema)
  so the value-position reference does not fire before the binding
  exists.
- Cross-package prefix on bindings.Identifier flows through to the
  emitted schema and reference, matching the rest of the AST.

Testing:

- testdata/zod/types.go and golden.ts give a realistic end-to-end
  fixture covering structs, embedded fields, enums, nullable pointers,
  arrays, maps, and self-references. testdata/zod/zod.ts is the
  regular guts TS output for the TestGeneration loop.
- zod/zod_test.go pins each conversion case in isolation, including
  the QuestionToken plus nullable interaction, the single-member union
  unwrap, the cross-package prefix passthrough, and the heritage
  extend path.
- zod/zod_e2e_test.go drives the full pipeline against the testdata
  and diffs the output against golden.ts. Run with -update to
  regenerate after intentional changes.

Replaces the string-builder approach in #82 with an AST mutation that
composes with the rest of guts.

Generated by Coder Agents on behalf of @Emyrk.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant