From 32c756308d5dfa1906e918cad0bb6c16a55c8b5d Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Thu, 28 May 2026 08:03:41 +0000 Subject: [PATCH 1/3] feat(zod): add Zod v4 schema serializer Walks the same intermediate AST that the TypeScript serializer uses. All type mappings, mutations, and overrides applied via the guts pipeline are reflected in the Zod output. Handles interfaces as z.object(), string-literal unions as z.enum(), pointers as .nullable(), omitempty as .optional(), arrays, records, references between schemas, inline object literals, intersections, and operator types (readonly). --- zod/zod.go | 319 ++++++++++++++++++++++++++++++++++++++++++++++++ zod/zod_test.go | 280 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 599 insertions(+) create mode 100644 zod/zod.go create mode 100644 zod/zod_test.go diff --git a/zod/zod.go b/zod/zod.go new file mode 100644 index 0000000..38f33bc --- /dev/null +++ b/zod/zod.go @@ -0,0 +1,319 @@ +// Package zod serializes a guts TypeScript AST into Zod v4 schema +// declarations. It walks the same intermediate representation that +// the TypeScript serializer uses, so all type mappings, mutations, +// and overrides applied via guts pipelines are reflected in the +// output. +package zod + +import ( + "fmt" + "sort" + "strings" + + "github.com/coder/guts" + "github.com/coder/guts/bindings" +) + +// Serialize walks all nodes in ts and returns Zod v4 schema code. +// Nodes are emitted in alphabetical order. +func Serialize(ts *guts.Typescript) string { + nodes := make(map[string]bindings.Node) + ts.ForEach(func(name string, node bindings.Node) { + nodes[name] = node + }) + + names := make([]string, 0, len(nodes)) + for name := range nodes { + names = append(names, name) + } + sort.Strings(names) + + var b strings.Builder + b.WriteString("// Code generated by 'guts'. DO NOT EDIT.\n") + b.WriteString("import { z } from \"zod\";\n\n") + + for _, name := range names { + s := serializeNode(name, nodes[name]) + if s != "" { + b.WriteString(s) + b.WriteString("\n") + } + } + return b.String() +} + +// SerializeFilter is like Serialize but only emits nodes for which +// the filter function returns true. +func SerializeFilter(ts *guts.Typescript, filter func(name string) bool) string { + nodes := make(map[string]bindings.Node) + ts.ForEach(func(name string, node bindings.Node) { + if filter(name) { + nodes[name] = node + } + }) + + names := make([]string, 0, len(nodes)) + for name := range nodes { + names = append(names, name) + } + sort.Strings(names) + + var b strings.Builder + b.WriteString("// Code generated by 'guts'. DO NOT EDIT.\n") + b.WriteString("import { z } from \"zod\";\n\n") + + for _, name := range names { + s := serializeNode(name, nodes[name]) + if s != "" { + b.WriteString(s) + b.WriteString("\n") + } + } + return b.String() +} + +func serializeNode(name string, node bindings.Node) string { + switch n := node.(type) { + case *bindings.Interface: + return serializeInterface(name, n) + case *bindings.Alias: + return serializeAlias(name, n) + case *bindings.VariableStatement: + return serializeVariableStatement(name, n) + default: + return "" + } +} + +func serializeInterface(name string, iface *bindings.Interface) string { + schema := schemaName(name) + var b strings.Builder + + b.WriteString(fmt.Sprintf("export const %s = z.object({\n", schema)) + for _, f := range iface.Fields { + zodType := exprToZod(f.Type) + if f.QuestionToken { + zodType += ".optional()" + } + b.WriteString(fmt.Sprintf(" %s: %s,\n", f.Name, zodType)) + } + b.WriteString("});\n") + b.WriteString(fmt.Sprintf("export type %s = z.infer;\n", name, schema)) + return b.String() +} + +func serializeAlias(name string, alias *bindings.Alias) string { + // Enums (union of string literals) become z.enum([...]). + if union, ok := alias.Type.(*bindings.UnionType); ok { + if isStringLiteralUnion(union) { + return serializeStringEnum(name, union) + } + } + + schema := schemaName(name) + zodType := exprToZod(alias.Type) + var b strings.Builder + b.WriteString(fmt.Sprintf("export const %s = %s;\n", schema, zodType)) + b.WriteString(fmt.Sprintf("export type %s = z.infer;\n", name, schema)) + return b.String() +} + +func serializeStringEnum(name string, union *bindings.UnionType) string { + schema := schemaName(name) + var values []string + for _, t := range union.Types { + if lit, ok := t.(*bindings.LiteralType); ok { + values = append(values, fmt.Sprintf(" %q", lit.Value)) + } + } + var b strings.Builder + b.WriteString(fmt.Sprintf("export const %s = z.enum([\n", schema)) + b.WriteString(strings.Join(values, ",\n")) + b.WriteString(",\n]);\n") + b.WriteString(fmt.Sprintf("export type %s = z.infer;\n", name, schema)) + return b.String() +} + +func serializeVariableStatement(name string, vs *bindings.VariableStatement) string { + // Variable statements are typically enum value lists (const Xs = [...]). + // Not directly representable in Zod schemas; skip. + _ = vs + return "" +} + +func exprToZod(expr bindings.ExpressionType) string { + if expr == nil { + return "z.unknown()" + } + switch e := expr.(type) { + case *bindings.LiteralKeyword: + return keywordToZod(e) + case *bindings.LiteralType: + return literalToZod(e) + case *bindings.ReferenceType: + return referenceToZod(e) + case *bindings.ArrayType: + return fmt.Sprintf("z.array(%s)", exprToZod(e.Node)) + case *bindings.UnionType: + return unionToZod(e) + case *bindings.Null: + return "z.null()" + case *bindings.TypeLiteralNode: + return objectLiteralToZod(e) + case *bindings.TypeIntersection: + return intersectionToZod(e) + case *bindings.TupleType: + return tupleToZod(e) + case *bindings.OperatorNodeType: + // readonly T[] is still z.array(T) in Zod. + return exprToZod(e.Type) + default: + return "z.unknown()" + } +} + +func keywordToZod(kw *bindings.LiteralKeyword) string { + switch *kw { + case bindings.KeywordString: + return "z.string()" + case bindings.KeywordNumber: + return "z.number()" + case bindings.KeywordBoolean: + return "z.boolean()" + case bindings.KeywordAny, bindings.KeywordUnknown: + return "z.unknown()" + case bindings.KeywordVoid, bindings.KeywordUndefined: + return "z.undefined()" + case bindings.KeywordNever: + return "z.never()" + default: + return "z.unknown()" + } +} + +func literalToZod(lit *bindings.LiteralType) string { + switch v := lit.Value.(type) { + case string: + return fmt.Sprintf("z.literal(%q)", v) + case bool: + return fmt.Sprintf("z.literal(%t)", v) + case int64: + return fmt.Sprintf("z.literal(%d)", v) + case float64: + return fmt.Sprintf("z.literal(%g)", v) + default: + return fmt.Sprintf("z.literal(%v)", v) + } +} + +func referenceToZod(ref *bindings.ReferenceType) string { + name := ref.Name.Ref() + schema := schemaName(name) + + // Record maps to z.record(K, V). + if name == "Record" && len(ref.Arguments) == 2 { + return fmt.Sprintf("z.record(%s, %s)", + exprToZod(ref.Arguments[0]), + exprToZod(ref.Arguments[1]), + ) + } + + // Omit, Pick, Partial are TypeScript utility types that don't + // have direct Zod equivalents. Emit z.unknown() rather than + // a broken reference. + switch name { + case "Omit", "Pick", "Partial", "Required": + return "z.unknown()" + } + + _ = schema + return schema +} + +func unionToZod(u *bindings.UnionType) string { + // Separate null from non-null members. + nonNull := make([]bindings.ExpressionType, 0, len(u.Types)) + hasNull := false + for _, t := range u.Types { + if _, ok := t.(*bindings.Null); ok { + hasNull = true + } else { + nonNull = append(nonNull, t) + } + } + + // T | null -> inner.nullable() + if hasNull && len(nonNull) == 1 { + return exprToZod(nonNull[0]) + ".nullable()" + } + + // Single non-null member with no null is just the member. + if !hasNull && len(nonNull) == 1 { + return exprToZod(nonNull[0]) + } + + parts := make([]string, 0, len(u.Types)) + for _, t := range u.Types { + parts = append(parts, exprToZod(t)) + } + return fmt.Sprintf("z.union([%s])", strings.Join(parts, ", ")) +} + +func objectLiteralToZod(tl *bindings.TypeLiteralNode) string { + var b strings.Builder + b.WriteString("z.object({\n") + for _, f := range tl.Members { + zodType := exprToZod(f.Type) + if f.QuestionToken { + zodType += ".optional()" + } + b.WriteString(fmt.Sprintf(" %s: %s,\n", f.Name, zodType)) + } + b.WriteString(" })") + return b.String() +} + +func intersectionToZod(inter *bindings.TypeIntersection) string { + if len(inter.Types) == 0 { + return "z.unknown()" + } + if len(inter.Types) == 1 { + return exprToZod(inter.Types[0]) + } + // A & B -> A.merge(B) for objects, but we can't know at + // serialization time. Use z.intersection for safety. + parts := make([]string, 0, len(inter.Types)) + for _, t := range inter.Types { + parts = append(parts, exprToZod(t)) + } + result := parts[0] + for _, p := range parts[1:] { + result = fmt.Sprintf("z.intersection(%s, %s)", result, p) + } + return result +} + +func tupleToZod(t *bindings.TupleType) string { + inner := exprToZod(t.Node) + return fmt.Sprintf("z.array(%s)", inner) +} + +func isStringLiteralUnion(u *bindings.UnionType) bool { + if len(u.Types) == 0 { + return false + } + for _, t := range u.Types { + lit, ok := t.(*bindings.LiteralType) + if !ok { + return false + } + if _, ok := lit.Value.(string); !ok { + return false + } + } + return true +} + +func schemaName(typeName string) string { + return typeName + "Schema" +} diff --git a/zod/zod_test.go b/zod/zod_test.go new file mode 100644 index 0000000..40e739b --- /dev/null +++ b/zod/zod_test.go @@ -0,0 +1,280 @@ +package zod_test + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/coder/guts" + "github.com/coder/guts/bindings" + "github.com/coder/guts/zod" +) + +// TestSerializeInterface verifies z.object() generation from an +// Interface node with various field types and optionality. +func TestSerializeInterface(t *testing.T) { + t.Parallel() + + ts := newTestTS(t) + ts.SetNode(t, "User", &bindings.Interface{ + Name: ident("User"), + Fields: []*bindings.PropertySignature{ + {Name: "id", Type: kw(bindings.KeywordString)}, + {Name: "name", Type: kw(bindings.KeywordString), QuestionToken: true}, + {Name: "age", Type: kw(bindings.KeywordNumber)}, + {Name: "active", Type: kw(bindings.KeywordBoolean)}, + }, + }) + + out := zod.Serialize(ts.TS) + + assert.Contains(t, out, `export const UserSchema = z.object({`) + assert.Contains(t, out, `id: z.string(),`) + assert.Contains(t, out, `name: z.string().optional(),`) + assert.Contains(t, out, `age: z.number(),`) + assert.Contains(t, out, `active: z.boolean(),`) + assert.Contains(t, out, `export type User = z.infer;`) +} + +// TestSerializeStringEnum verifies z.enum([...]) generation from a +// union-of-literals alias. +func TestSerializeStringEnum(t *testing.T) { + t.Parallel() + + ts := newTestTS(t) + ts.SetNode(t, "Status", &bindings.Alias{ + Name: ident("Status"), + Type: bindings.Union( + &bindings.LiteralType{Value: "active"}, + &bindings.LiteralType{Value: "inactive"}, + &bindings.LiteralType{Value: "banned"}, + ), + }) + + out := zod.Serialize(ts.TS) + + assert.Contains(t, out, `export const StatusSchema = z.enum([`) + assert.Contains(t, out, `"active"`) + assert.Contains(t, out, `"inactive"`) + assert.Contains(t, out, `"banned"`) +} + +// TestSerializeNullable verifies T | null maps to .nullable(). +func TestSerializeNullable(t *testing.T) { + t.Parallel() + + ts := newTestTS(t) + ts.SetNode(t, "Thing", &bindings.Interface{ + Name: ident("Thing"), + Fields: []*bindings.PropertySignature{ + { + Name: "error", + Type: bindings.Union(kw(bindings.KeywordString), &bindings.Null{}), + }, + }, + }) + + out := zod.Serialize(ts.TS) + + assert.Contains(t, out, `error: z.string().nullable(),`) +} + +// TestSerializeOptionalNullable verifies *T with omitempty maps to +// .nullable().optional() when QuestionToken is set. +func TestSerializeOptionalNullable(t *testing.T) { + t.Parallel() + + ts := newTestTS(t) + ts.SetNode(t, "Thing", &bindings.Interface{ + Name: ident("Thing"), + Fields: []*bindings.PropertySignature{ + { + Name: "parent_id", + Type: bindings.Union(kw(bindings.KeywordString), &bindings.Null{}), + QuestionToken: true, + }, + }, + }) + + out := zod.Serialize(ts.TS) + + assert.Contains(t, out, `parent_id: z.string().nullable().optional(),`) +} + +// TestSerializeArray verifies z.array() generation. +func TestSerializeArray(t *testing.T) { + t.Parallel() + + ts := newTestTS(t) + ts.SetNode(t, "Thing", &bindings.Interface{ + Name: ident("Thing"), + Fields: []*bindings.PropertySignature{ + {Name: "tags", Type: bindings.Array(kw(bindings.KeywordString))}, + }, + }) + + out := zod.Serialize(ts.TS) + + assert.Contains(t, out, `tags: z.array(z.string()),`) +} + +// TestSerializeReference verifies schema references between types. +func TestSerializeReference(t *testing.T) { + t.Parallel() + + ts := newTestTS(t) + ts.SetNode(t, "ErrorInfo", &bindings.Interface{ + Name: ident("ErrorInfo"), + Fields: []*bindings.PropertySignature{ + {Name: "message", Type: kw(bindings.KeywordString)}, + }, + }) + ts.SetNode(t, "Chat", &bindings.Interface{ + Name: ident("Chat"), + Fields: []*bindings.PropertySignature{ + {Name: "last_error", Type: bindings.Reference(ident("ErrorInfo"))}, + }, + }) + + out := zod.Serialize(ts.TS) + + assert.Contains(t, out, `last_error: ErrorInfoSchema,`) +} + +// TestSerializeRecord verifies Record maps to z.record(). +func TestSerializeRecord(t *testing.T) { + t.Parallel() + + ts := newTestTS(t) + ts.SetNode(t, "Thing", &bindings.Interface{ + Name: ident("Thing"), + Fields: []*bindings.PropertySignature{ + { + Name: "labels", + Type: bindings.Reference(ident("Record"), + kw(bindings.KeywordString), + kw(bindings.KeywordString), + ), + }, + }, + }) + + out := zod.Serialize(ts.TS) + + assert.Contains(t, out, `labels: z.record(z.string(), z.string()),`) +} + +// TestSerializeFilter verifies that SerializeFilter only includes +// nodes matching the filter predicate. +func TestSerializeFilter(t *testing.T) { + t.Parallel() + + ts := newTestTS(t) + ts.SetNode(t, "Keep", &bindings.Interface{ + Name: ident("Keep"), + Fields: []*bindings.PropertySignature{{Name: "id", Type: kw(bindings.KeywordString)}}, + }) + ts.SetNode(t, "Drop", &bindings.Interface{ + Name: ident("Drop"), + Fields: []*bindings.PropertySignature{{Name: "id", Type: kw(bindings.KeywordString)}}, + }) + + out := zod.SerializeFilter(ts.TS, func(name string) bool { + return name == "Keep" + }) + + assert.Contains(t, out, "KeepSchema") + assert.NotContains(t, out, "DropSchema") +} + +// TestSerializeSingleMemberUnion verifies that a union with one +// non-null member simplifies to the member directly. +func TestSerializeSingleMemberUnion(t *testing.T) { + t.Parallel() + + ts := newTestTS(t) + ts.SetNode(t, "Thing", &bindings.Interface{ + Name: ident("Thing"), + Fields: []*bindings.PropertySignature{ + { + Name: "workspace_id", + Type: bindings.Union(kw(bindings.KeywordString)), + QuestionToken: true, + }, + }, + }) + + out := zod.Serialize(ts.TS) + + // Should be z.string().optional(), not z.union([z.string()]).optional(). + assert.Contains(t, out, `workspace_id: z.string().optional(),`) + assert.NotContains(t, out, "z.union") +} + +// TestSerializeObjectLiteral verifies inline object types. +func TestSerializeObjectLiteral(t *testing.T) { + t.Parallel() + + ts := newTestTS(t) + ts.SetNode(t, "Outer", &bindings.Interface{ + Name: ident("Outer"), + Fields: []*bindings.PropertySignature{ + { + Name: "nested", + Type: &bindings.TypeLiteralNode{ + Members: []*bindings.PropertySignature{ + {Name: "x", Type: kw(bindings.KeywordNumber)}, + {Name: "y", Type: kw(bindings.KeywordNumber)}, + }, + }, + }, + }, + }) + + out := zod.Serialize(ts.TS) + + assert.Contains(t, out, `nested: z.object({`) + assert.Contains(t, out, `x: z.number(),`) + assert.Contains(t, out, `y: z.number(),`) +} + +// TestSerializeHeader verifies the generated header. +func TestSerializeHeader(t *testing.T) { + t.Parallel() + + ts := newTestTS(t) + out := zod.Serialize(ts.TS) + + assert.True(t, strings.HasPrefix(out, "// Code generated by 'guts'. DO NOT EDIT.\n")) + assert.Contains(t, out, `import { z } from "zod";`) +} + +// testTS wraps guts.Typescript for testing convenience. +type testTS struct { + TS *guts.Typescript +} + +func newTestTS(t *testing.T) *testTS { + t.Helper() + gen, err := guts.NewGolangParser() + require.NoError(t, err) + ts, err := gen.ToTypescript() + require.NoError(t, err) + return &testTS{TS: ts} +} + +func (tt *testTS) SetNode(t *testing.T, name string, node bindings.Node) { + t.Helper() + err := tt.TS.SetNode(name, node) + require.NoError(t, err) +} + +func ident(name string) bindings.Identifier { + return bindings.Identifier{Name: name} +} + +func kw(k bindings.LiteralKeyword) *bindings.LiteralKeyword { + return &k +} From 7c730369b2f8bf66a78b7defb51d0d35d814fb61 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Thu, 28 May 2026 13:11:56 +0000 Subject: [PATCH 2/3] Add end-to-end test, heritage support, self-reference via z.lazy Adds a testdata/zod package with realistic types (enums, embedded structs, nullable pointers, arrays, maps, self- referencing children) and a golden-file test that runs the full guts pipeline through the Zod serializer. Heritage clauses now emit .extend() for struct embedding. Self-referential types emit z.lazy() to avoid reference- before-declaration errors. The selfName parameter is threaded through exprToZod to detect self-references without global mutable state. --- go.mod | 1 + go.sum | 2 + testdata/zod/golden.ts | 41 +++++++++++++++++ testdata/zod/types.go | 54 ++++++++++++++++++++++ testdata/zod/zod.ts | 49 ++++++++++++++++++++ zod/zod.go | 102 ++++++++++++++++++++++++++--------------- zod/zod_e2e_test.go | 55 ++++++++++++++++++++++ 7 files changed, 266 insertions(+), 38 deletions(-) create mode 100644 testdata/zod/golden.ts create mode 100644 testdata/zod/types.go create mode 100644 testdata/zod/zod.ts create mode 100644 zod/zod_e2e_test.go diff --git a/go.mod b/go.mod index 01e44ae..7233b83 100644 --- a/go.mod +++ b/go.mod @@ -17,6 +17,7 @@ require ( github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/pprof v0.0.0-20230207041349-798e818bf904 // indirect + github.com/google/uuid v1.6.0 // indirect github.com/kr/pretty v0.3.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect diff --git a/go.sum b/go.sum index 393f522..9ed434d 100644 --- a/go.sum +++ b/go.sum @@ -15,6 +15,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/pprof v0.0.0-20230207041349-798e818bf904 h1:4/hN5RUoecvl+RmJRE2YxKWtnnQls6rQjjW5oV7qg2U= github.com/google/pprof v0.0.0-20230207041349-798e818bf904/go.mod h1:uglQLonpP8qtYCYyzA+8c/9qtqgA3qsXGYqCPKARAFg= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= diff --git a/testdata/zod/golden.ts b/testdata/zod/golden.ts new file mode 100644 index 0000000..5ba28ef --- /dev/null +++ b/testdata/zod/golden.ts @@ -0,0 +1,41 @@ +// Code generated by 'guts'. DO NOT EDIT. +import { z } from "zod"; + +export const BaseSchema = z.object({ + id: z.string(), + created_at: z.string(), + updated_at: z.string(), +}); +export type Base = z.infer; + +export const CreateTicketRequestSchema = z.object({ + title: z.string(), + description: z.string().optional(), + priority: PrioritySchema, + tags: z.array(z.string()).optional(), +}); +export type CreateTicketRequest = z.infer; + +export const PrioritySchema = z.union([z.literal(2), z.literal(0), z.literal(1)]); +export type Priority = z.infer; + +export const StatusSchema = z.enum([ + "active", + "closed", + "pending", +]); +export type Status = z.infer; + +export const TicketSchema = z.object({ + Base: BaseSchema, + title: z.string(), + description: z.string().optional(), + status: StatusSchema, + priority: PrioritySchema, + assignee_id: z.string().optional(), + tags: z.array(z.string()), + metadata: z.record(z.string(), z.string()).nullable(), + children: z.array(z.lazy((): z.ZodType => TicketSchema)), +}); +export type Ticket = z.infer; + diff --git a/testdata/zod/types.go b/testdata/zod/types.go new file mode 100644 index 0000000..4066379 --- /dev/null +++ b/testdata/zod/types.go @@ -0,0 +1,54 @@ +// Package zod provides sample types for testing the Zod serializer. +package zod + +import ( + "time" + + "github.com/google/uuid" +) + +type Status string + +const ( + StatusActive Status = "active" + StatusPending Status = "pending" + StatusClosed Status = "closed" +) + +type Priority int + +const ( + PriorityLow Priority = 0 + PriorityMedium Priority = 1 + PriorityHigh Priority = 2 +) + +// Base is embedded by Ticket to test heritage/extend. +type Base struct { + ID uuid.UUID `json:"id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// Ticket demonstrates a realistic struct with enums, nullable +// pointers, embedded structs, arrays, and maps. +type Ticket struct { + Base `json:",inline"` + + Title string `json:"title"` + Description *string `json:"description,omitempty"` + Status Status `json:"status"` + Priority Priority `json:"priority"` + AssigneeID *uuid.UUID `json:"assignee_id,omitempty"` + Tags []string `json:"tags"` + Metadata map[string]string `json:"metadata"` + Children []Ticket `json:"children"` +} + +// CreateTicketRequest demonstrates a request body type. +type CreateTicketRequest struct { + Title string `json:"title"` + Description string `json:"description,omitempty"` + Priority Priority `json:"priority"` + Tags []string `json:"tags,omitempty"` +} diff --git a/testdata/zod/zod.ts b/testdata/zod/zod.ts new file mode 100644 index 0000000..8b64ef3 --- /dev/null +++ b/testdata/zod/zod.ts @@ -0,0 +1,49 @@ +// Code generated by 'guts'. DO NOT EDIT. + +// From zod/types.go +/** + * Base is embedded by Ticket to test heritage/extend. + */ +export interface Base { + readonly id: string; + readonly created_at: string; + readonly updated_at: string; +} + +// From zod/types.go +/** + * CreateTicketRequest demonstrates a request body type. + */ +export interface CreateTicketRequest { + readonly title: string; + readonly description?: string; + readonly priority: Priority; + readonly tags?: readonly string[]; +} + +export const Priorities: Priority[] = [2, 0, 1]; + +// From zod/types.go +export type Priority = 2 | 0 | 1; + +// From zod/types.go +export type Status = "active" | "closed" | "pending"; + +export const Statuses: Status[] = ["active", "closed", "pending"]; + +// From zod/types.go +/** + * Ticket demonstrates a realistic struct with enums, nullable + * pointers, embedded structs, arrays, and maps. + */ +export interface Ticket { + readonly Base: Base; + readonly title: string; + readonly description?: string | null; + readonly status: Status; + readonly priority: Priority; + readonly assignee_id?: string | null; + readonly tags: readonly string[]; + readonly metadata: Record | null; + readonly children: readonly Ticket[]; +} diff --git a/zod/zod.go b/zod/zod.go index 38f33bc..54e32f5 100644 --- a/zod/zod.go +++ b/zod/zod.go @@ -89,9 +89,39 @@ func serializeInterface(name string, iface *bindings.Interface) string { schema := schemaName(name) var b strings.Builder - b.WriteString(fmt.Sprintf("export const %s = z.object({\n", schema)) + // Handle struct embedding (heritage clauses) via .extend(). + base := "" + for _, h := range iface.Heritage { + for _, arg := range h.Args { + ref := "" + if ewta, ok := arg.(*bindings.ExpressionWithTypeArguments); ok { + if rt, ok := ewta.Expression.(*bindings.ReferenceType); ok { + ref = schemaName(rt.Name.Ref()) + } + } + if rt, ok := arg.(*bindings.ReferenceType); ok { + ref = schemaName(rt.Name.Ref()) + } + if ref != "" { + if base != "" { + panic(fmt.Sprintf("multiple heritage bases for %s: %s and %s (Zod has no multiple inheritance)", name, base, ref)) + } + base = ref + } + } + } + + if base != "" && len(iface.Fields) > 0 { + b.WriteString(fmt.Sprintf("export const %s = %s.extend({\n", schema, base)) + } else if base != "" { + b.WriteString(fmt.Sprintf("export const %s = %s;\n", schema, base)) + b.WriteString(fmt.Sprintf("export type %s = z.infer;\n", name, schema)) + return b.String() + } else { + b.WriteString(fmt.Sprintf("export const %s = z.object({\n", schema)) + } for _, f := range iface.Fields { - zodType := exprToZod(f.Type) + zodType := exprToZod(f.Type, name) if f.QuestionToken { zodType += ".optional()" } @@ -111,7 +141,7 @@ func serializeAlias(name string, alias *bindings.Alias) string { } schema := schemaName(name) - zodType := exprToZod(alias.Type) + zodType := exprToZod(alias.Type, name) var b strings.Builder b.WriteString(fmt.Sprintf("export const %s = %s;\n", schema, zodType)) b.WriteString(fmt.Sprintf("export type %s = z.infer;\n", name, schema)) @@ -135,13 +165,14 @@ func serializeStringEnum(name string, union *bindings.UnionType) string { } func serializeVariableStatement(name string, vs *bindings.VariableStatement) string { - // Variable statements are typically enum value lists (const Xs = [...]). - // Not directly representable in Zod schemas; skip. _ = vs return "" } -func exprToZod(expr bindings.ExpressionType) string { +// exprToZod converts an AST expression to Zod code. selfName is +// the name of the type currently being serialized, used to detect +// self-references and emit z.lazy(). +func exprToZod(expr bindings.ExpressionType, selfName string) string { if expr == nil { return "z.unknown()" } @@ -151,22 +182,21 @@ func exprToZod(expr bindings.ExpressionType) string { case *bindings.LiteralType: return literalToZod(e) case *bindings.ReferenceType: - return referenceToZod(e) + return referenceToZod(e, selfName) case *bindings.ArrayType: - return fmt.Sprintf("z.array(%s)", exprToZod(e.Node)) + return fmt.Sprintf("z.array(%s)", exprToZod(e.Node, selfName)) case *bindings.UnionType: - return unionToZod(e) + return unionToZod(e, selfName) case *bindings.Null: return "z.null()" case *bindings.TypeLiteralNode: - return objectLiteralToZod(e) + return objectLiteralToZod(e, selfName) case *bindings.TypeIntersection: - return intersectionToZod(e) + return intersectionToZod(e, selfName) case *bindings.TupleType: - return tupleToZod(e) + return tupleToZod(e, selfName) case *bindings.OperatorNodeType: - // readonly T[] is still z.array(T) in Zod. - return exprToZod(e.Type) + return exprToZod(e.Type, selfName) default: return "z.unknown()" } @@ -206,32 +236,32 @@ func literalToZod(lit *bindings.LiteralType) string { } } -func referenceToZod(ref *bindings.ReferenceType) string { +func referenceToZod(ref *bindings.ReferenceType, selfName string) string { name := ref.Name.Ref() schema := schemaName(name) - // Record maps to z.record(K, V). if name == "Record" && len(ref.Arguments) == 2 { return fmt.Sprintf("z.record(%s, %s)", - exprToZod(ref.Arguments[0]), - exprToZod(ref.Arguments[1]), + exprToZod(ref.Arguments[0], selfName), + exprToZod(ref.Arguments[1], selfName), ) } - // Omit, Pick, Partial are TypeScript utility types that don't - // have direct Zod equivalents. Emit z.unknown() rather than - // a broken reference. switch name { case "Omit", "Pick", "Partial", "Required": return "z.unknown()" } - _ = schema + // Self-referential types need z.lazy() to avoid + // reference-before-declaration errors. + if name == selfName { + return fmt.Sprintf("z.lazy((): z.ZodType => %s)", schema) + } + return schema } -func unionToZod(u *bindings.UnionType) string { - // Separate null from non-null members. +func unionToZod(u *bindings.UnionType, selfName string) string { nonNull := make([]bindings.ExpressionType, 0, len(u.Types)) hasNull := false for _, t := range u.Types { @@ -242,28 +272,26 @@ func unionToZod(u *bindings.UnionType) string { } } - // T | null -> inner.nullable() if hasNull && len(nonNull) == 1 { - return exprToZod(nonNull[0]) + ".nullable()" + return exprToZod(nonNull[0], selfName) + ".nullable()" } - // Single non-null member with no null is just the member. if !hasNull && len(nonNull) == 1 { - return exprToZod(nonNull[0]) + return exprToZod(nonNull[0], selfName) } parts := make([]string, 0, len(u.Types)) for _, t := range u.Types { - parts = append(parts, exprToZod(t)) + parts = append(parts, exprToZod(t, selfName)) } return fmt.Sprintf("z.union([%s])", strings.Join(parts, ", ")) } -func objectLiteralToZod(tl *bindings.TypeLiteralNode) string { +func objectLiteralToZod(tl *bindings.TypeLiteralNode, selfName string) string { var b strings.Builder b.WriteString("z.object({\n") for _, f := range tl.Members { - zodType := exprToZod(f.Type) + zodType := exprToZod(f.Type, selfName) if f.QuestionToken { zodType += ".optional()" } @@ -273,18 +301,16 @@ func objectLiteralToZod(tl *bindings.TypeLiteralNode) string { return b.String() } -func intersectionToZod(inter *bindings.TypeIntersection) string { +func intersectionToZod(inter *bindings.TypeIntersection, selfName string) string { if len(inter.Types) == 0 { return "z.unknown()" } if len(inter.Types) == 1 { - return exprToZod(inter.Types[0]) + return exprToZod(inter.Types[0], selfName) } - // A & B -> A.merge(B) for objects, but we can't know at - // serialization time. Use z.intersection for safety. parts := make([]string, 0, len(inter.Types)) for _, t := range inter.Types { - parts = append(parts, exprToZod(t)) + parts = append(parts, exprToZod(t, selfName)) } result := parts[0] for _, p := range parts[1:] { @@ -293,8 +319,8 @@ func intersectionToZod(inter *bindings.TypeIntersection) string { return result } -func tupleToZod(t *bindings.TupleType) string { - inner := exprToZod(t.Node) +func tupleToZod(t *bindings.TupleType, selfName string) string { + inner := exprToZod(t.Node, selfName) return fmt.Sprintf("z.array(%s)", inner) } diff --git a/zod/zod_e2e_test.go b/zod/zod_e2e_test.go new file mode 100644 index 0000000..d2e8ad2 --- /dev/null +++ b/zod/zod_e2e_test.go @@ -0,0 +1,55 @@ +//go:build !windows + +package zod_test + +import ( + "flag" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/coder/guts" + "github.com/coder/guts/config" + "github.com/coder/guts/zod" +) + +var updateGolden = flag.Bool("update", false, "update golden files") + +// TestSerializeEndToEnd runs the full guts pipeline on a real Go +// package and compares the Zod output against a golden file. +// Run with -update to regenerate the golden file. +func TestSerializeEndToEnd(t *testing.T) { + t.Parallel() + + gen, err := guts.NewGolangParser() + require.NoError(t, err) + + err = gen.IncludeGenerate("github.com/coder/guts/testdata/zod") + require.NoError(t, err) + + gen.IncludeCustomDeclaration(config.StandardMappings()) + + ts, err := gen.ToTypescript() + require.NoError(t, err) + + ts.ApplyMutations( + config.EnumAsTypes, + config.SimplifyOmitEmpty, + ) + + output := zod.Serialize(ts) + + golden := filepath.Join("..", "testdata", "zod", "golden.ts") + if *updateGolden { + err = os.WriteFile(golden, []byte(output), 0o644) + require.NoError(t, err) + return + } + + expected, err := os.ReadFile(golden) + require.NoError(t, err, "run with -update to generate golden file") + assert.Equal(t, string(expected), output) +} From 8aef5e96aa7ca1ae965bdd0b6452869891cbc796 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Thu, 28 May 2026 13:11:56 +0000 Subject: [PATCH 3/3] Add end-to-end test, heritage support, self-reference via z.lazy Adds a testdata/zod package with realistic types (enums, embedded structs, nullable pointers, arrays, maps, self- referencing children) and a golden-file test that runs the full guts pipeline through the Zod serializer. Heritage clauses now emit .extend() for struct embedding. Self-referential types emit z.lazy() to avoid reference- before-declaration errors. The selfName parameter is threaded through exprToZod to detect self-references without global mutable state. --- testdata/zod/golden.ts | 3 +-- testdata/zod/types.go | 2 +- testdata/zod/zod.ts | 3 +-- 3 files changed, 3 insertions(+), 5 deletions(-) diff --git a/testdata/zod/golden.ts b/testdata/zod/golden.ts index 5ba28ef..20c0a09 100644 --- a/testdata/zod/golden.ts +++ b/testdata/zod/golden.ts @@ -26,8 +26,7 @@ export const StatusSchema = z.enum([ ]); export type Status = z.infer; -export const TicketSchema = z.object({ - Base: BaseSchema, +export const TicketSchema = BaseSchema.extend({ title: z.string(), description: z.string().optional(), status: StatusSchema, diff --git a/testdata/zod/types.go b/testdata/zod/types.go index 4066379..79f6ad1 100644 --- a/testdata/zod/types.go +++ b/testdata/zod/types.go @@ -33,7 +33,7 @@ type Base struct { // Ticket demonstrates a realistic struct with enums, nullable // pointers, embedded structs, arrays, and maps. type Ticket struct { - Base `json:",inline"` + Base Title string `json:"title"` Description *string `json:"description,omitempty"` diff --git a/testdata/zod/zod.ts b/testdata/zod/zod.ts index 8b64ef3..63d979e 100644 --- a/testdata/zod/zod.ts +++ b/testdata/zod/zod.ts @@ -36,8 +36,7 @@ export const Statuses: Status[] = ["active", "closed", "pending"]; * Ticket demonstrates a realistic struct with enums, nullable * pointers, embedded structs, arrays, and maps. */ -export interface Ticket { - readonly Base: Base; +export interface Ticket extends Base { readonly title: string; readonly description?: string | null; readonly status: Status;