feat(zod): add AsSchemas mutation for Zod v4 codegen#85
Draft
Emyrk wants to merge 1 commit into
Draft
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds a
zodpackage with one mutation,zod.AsSchemas, that rewrites everyInterfaceandAliasinto a Zod v4 schema plus an inferred type alias, and injectsimport { z } from "zod". Composes with the rest of the config mutations instead of duplicating them as a string-builder.Replaces the string-builder approach in #82 with an AST mutation. Closes the door on parallel TS output paths: existing mutations like
ExportTypes,ReadOnly,TrimEnumPrefixcontinue to work on the rewritten declarations.Conversion rules, fixture, test coverage
Pipeline composition
Other mutations that walk
InterfaceorAlias(ReadOnly,TrimEnumPrefix, etc.) must run beforeAsSchemasbecause the originals are replaced.ExportTypesruns after because the newVariableStatementandAliasnodes start unmodified;ExportTypesaddsexportto them.Conversion rules
Interfacewith no heritage:const FooSchema = z.object({...});plustype Foo = z.infer<typeof FooSchema>;.Interfacewith single-base heritage:BaseSchema.extend({...})instead ofz.object. Multiple bases panic since Zod has no multiple inheritance.AliaswhoseTypeis a union of string literals:z.enum([...])for readability.Aliaswith any otherType: recursiveexprToZodplus the inferred type alias.z.string,z.number,z.booleanfor keywords;z.arrayfor arrays;z.record(K, V)forRecord<K, V>; bare references emit the paired<Name>Schemaidentifier;T | nullcollapses to.nullable(); single-non-null-member unions unwrap;QuestionTokenappends.optional(); inlineTypeLiteralNodeemitsz.object; intersections fold into a left-associative chain ofz.intersection.z.lazy((): z.ZodType => SelfSchema)so the value-position reference does not fire before the binding exists.bindings.Identifierflows through to the emitted schema and reference, matching the rest of the AST. Pinned byTestPrefixedReference.Mutation API
No options or filter helpers in this PR. If a future use case needs them, a
AsSchemasFilteredvariant can be added without changing this surface.Fixture
testdata/zod/types.godefines aBasestruct, an embeddingTicketstruct with optional pointers, enums, slices, maps, and a self-reference, plus aCreateTicketRequestrequest body.testdata/zod/golden.tsis the Zod output.testdata/zod/zod.tsis the regular guts TS output, kept so the existingTestGenerationloop also covers this fixture.Tests
zod/zod_test.gopins each conversion case in isolation. Each test installs a small*guts.TypescriptviaSetNode, runsAsSchemas, and asserts substrings of the serialized output. Cases:TestObjectFields— keyword mappings (z.string,z.number,z.boolean) andQuestionToken.TestStringLiteralUnionBecomesEnum— union-of-literals collapses toz.enum, no fallback toz.literalchain.TestNullableField—T | nullcollapses to.nullable().TestOptionalNullableField—QuestionTokenplusT | nullchains to.nullable().optional().TestArrayField—z.array(...).TestReferenceField— bare reference resolves to<Name>Schema.TestRecordField—Record<K, V>toz.record(K, V).TestInlineObjectLiteral— inlineTypeLiteralNodetoz.object.TestSelfReferenceLazy— recursive type wraps inz.lazy((): z.ZodType => SelfSchema).TestHeritageExtend— single-base heritage toBaseSchema.extend.TestAppendsZodImport— pins theimport { z } from "zod"injection.TestMixedUnionStaysUnion— non-literal, non-T|nullunion stays asz.union.TestSingleMemberUnionUnwraps— single-member union skipsz.unionwrapper.TestPrefixedReference— cross-packageIdentifier.Prefixreaches both the schema declaration and the reference.zod/zod_e2e_test.godrives the full pipeline (EnumAsTypes, SimplifyOmitEmpty, AsSchemas, ExportTypes) againsttestdata/zod/types.goand diffs againsttestdata/zod/golden.ts. Run with-updateto 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.