Skip to content

Commit 2f30faf

Browse files
committed
feat(zod): add AsSchemas mutation for Zod v4 codegen
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.
1 parent 7b8e1a8 commit 2f30faf

8 files changed

Lines changed: 955 additions & 0 deletions

File tree

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ require (
1717
github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect
1818
github.com/google/go-cmp v0.7.0 // indirect
1919
github.com/google/pprof v0.0.0-20230207041349-798e818bf904 // indirect
20+
github.com/google/uuid v1.6.0 // indirect
2021
github.com/kr/pretty v0.3.1 // indirect
2122
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
2223
github.com/rogpeppe/go-internal v1.14.1 // indirect

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
1515
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
1616
github.com/google/pprof v0.0.0-20230207041349-798e818bf904 h1:4/hN5RUoecvl+RmJRE2YxKWtnnQls6rQjjW5oV7qg2U=
1717
github.com/google/pprof v0.0.0-20230207041349-798e818bf904/go.mod h1:uglQLonpP8qtYCYyzA+8c/9qtqgA3qsXGYqCPKARAFg=
18+
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
19+
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
1820
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
1921
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
2022
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=

testdata/zod/golden.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
// Code generated by 'guts'. DO NOT EDIT.
2+
3+
import { z } from "zod";
4+
5+
export type Base = z.infer<typeof BaseSchema>;
6+
7+
export const BaseSchema = z.object({
8+
id: z.string(),
9+
created_at: z.string(),
10+
updated_at: z.string()
11+
});
12+
13+
export type CreateTicketRequest = z.infer<typeof CreateTicketRequestSchema>;
14+
15+
export const CreateTicketRequestSchema = z.object({
16+
title: z.string(),
17+
description: z.string().optional(),
18+
priority: PrioritySchema,
19+
tags: z.array(z.string()).optional()
20+
});
21+
22+
export type Priority = z.infer<typeof PrioritySchema>;
23+
24+
export const PrioritySchema = z.union([z.literal(2), z.literal(0), z.literal(1)]);
25+
26+
export type Status = z.infer<typeof StatusSchema>;
27+
28+
export const StatusSchema = z.enum(["active", "closed", "pending"]);
29+
30+
export type Ticket = z.infer<typeof TicketSchema>;
31+
32+
export const TicketSchema = BaseSchema.extend({
33+
title: z.string(),
34+
description: z.string().optional(),
35+
status: StatusSchema,
36+
priority: PrioritySchema,
37+
assignee_id: z.string().optional(),
38+
tags: z.array(z.string()),
39+
metadata: z.record(z.string(), z.string()).nullable(),
40+
children: z.array(z.lazy((): z.ZodType => TicketSchema))
41+
});
42+

testdata/zod/types.go

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
// Package zod provides sample types for testing the Zod mutation.
2+
package zod
3+
4+
import (
5+
"time"
6+
7+
"github.com/google/uuid"
8+
)
9+
10+
type Status string
11+
12+
const (
13+
StatusActive Status = "active"
14+
StatusPending Status = "pending"
15+
StatusClosed Status = "closed"
16+
)
17+
18+
type Priority int
19+
20+
const (
21+
PriorityLow Priority = 0
22+
PriorityMedium Priority = 1
23+
PriorityHigh Priority = 2
24+
)
25+
26+
// Base is embedded by Ticket to test heritage/extend.
27+
type Base struct {
28+
ID uuid.UUID `json:"id"`
29+
CreatedAt time.Time `json:"created_at"`
30+
UpdatedAt time.Time `json:"updated_at"`
31+
}
32+
33+
// Ticket demonstrates a realistic struct with enums, nullable
34+
// pointers, embedded structs, arrays, and maps.
35+
type Ticket struct {
36+
Base
37+
38+
Title string `json:"title"`
39+
Description *string `json:"description,omitempty"`
40+
Status Status `json:"status"`
41+
Priority Priority `json:"priority"`
42+
AssigneeID *uuid.UUID `json:"assignee_id,omitempty"`
43+
Tags []string `json:"tags"`
44+
Metadata map[string]string `json:"metadata"`
45+
Children []Ticket `json:"children"`
46+
}
47+
48+
// CreateTicketRequest demonstrates a request body type.
49+
type CreateTicketRequest struct {
50+
Title string `json:"title"`
51+
Description string `json:"description,omitempty"`
52+
Priority Priority `json:"priority"`
53+
Tags []string `json:"tags,omitempty"`
54+
}

testdata/zod/zod.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
// Code generated by 'guts'. DO NOT EDIT.
2+
3+
// From zod/types.go
4+
/**
5+
* Base is embedded by Ticket to test heritage/extend.
6+
*/
7+
export interface Base {
8+
readonly id: string;
9+
readonly created_at: string;
10+
readonly updated_at: string;
11+
}
12+
13+
// From zod/types.go
14+
/**
15+
* CreateTicketRequest demonstrates a request body type.
16+
*/
17+
export interface CreateTicketRequest {
18+
readonly title: string;
19+
readonly description?: string;
20+
readonly priority: Priority;
21+
readonly tags?: readonly string[];
22+
}
23+
24+
export const Priorities: Priority[] = [2, 0, 1];
25+
26+
// From zod/types.go
27+
export type Priority = 2 | 0 | 1;
28+
29+
// From zod/types.go
30+
export type Status = "active" | "closed" | "pending";
31+
32+
export const Statuses: Status[] = ["active", "closed", "pending"];
33+
34+
// From zod/types.go
35+
/**
36+
* Ticket demonstrates a realistic struct with enums, nullable
37+
* pointers, embedded structs, arrays, and maps.
38+
*/
39+
export interface Ticket extends Base {
40+
readonly title: string;
41+
readonly description?: string | null;
42+
readonly status: Status;
43+
readonly priority: Priority;
44+
readonly assignee_id?: string | null;
45+
readonly tags: readonly string[];
46+
readonly metadata: Record<string, string> | null;
47+
readonly children: readonly Ticket[];
48+
}

0 commit comments

Comments
 (0)