Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
c6ab066
Make Zod v4 the default output
satvik007 Mar 23, 2026
cb9a2f1
Add v4 support and make v3 opt-in. Add golden test files
ramilamparo Apr 1, 2026
695a26b
Add docker typechecks and fix issues
ramilamparo Apr 1, 2026
17dff2d
Fix comment
ramilamparo Apr 1, 2026
b6927cc
Fix comment
ramilamparo Apr 1, 2026
08d07d0
Merge branch 'main' into dev/ram/zod-v4-migration
ramilamparo Apr 2, 2026
58be5ad
Refactor string schema generation and consolidate tests
ramilamparo Apr 2, 2026
d67b838
Fix typecheck
ramilamparo Apr 2, 2026
af3a5e4
Improve tests
ramilamparo Apr 2, 2026
dee7a0e
Fix PR review feedback and improve code quality
ramilamparo Apr 3, 2026
843a69a
Add escaped characters test
ramilamparo Apr 3, 2026
b439c9b
Address second round of PR review feedback
ramilamparo Apr 3, 2026
dd8fdf7
Remove dead oneof guard and fix doc comment spacing
ramilamparo Apr 3, 2026
cc02003
Add partialRecords test
ramilamparo Apr 3, 2026
3c547b5
Address third round of PR review feedback
ramilamparo Apr 3, 2026
f165d5e
Add more custom tests
ramilamparo Apr 3, 2026
c7da97f
Add test for ignore tags
ramilamparo Apr 3, 2026
c4282c7
Add custom type tests, WithIgnoreTags test, remove dead z.literal branch
ramilamparo Apr 3, 2026
98dcbe3
Move enum-keyed map test out of TestZodV4Defaults
ramilamparo Apr 3, 2026
45d3b50
Fix runtime tests
ramilamparo Apr 3, 2026
4b97566
Panic on invalid tags and add more tests
ramilamparo Apr 3, 2026
e470ca9
Panic on unknown tag
ramilamparo Apr 3, 2026
1beae0b
Fix panicing
ramilamparo Apr 3, 2026
a71118d
Fix shadowed variable
ramilamparo Apr 3, 2026
36d03d0
Fix boolean schema
ramilamparo Apr 3, 2026
e17befb
Simplify enum processing
ramilamparo Apr 3, 2026
0df6343
Fix custom type handlers
ramilamparo Apr 3, 2026
5a3d986
Fixes
ramilamparo Apr 3, 2026
9e8a925
Fix tests
ramilamparo Apr 3, 2026
d5cd2ee
Fix bare dive panic
ramilamparo Apr 3, 2026
eb3bd88
Improve tests
ramilamparo Apr 3, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ name: CI

on:
push:
branches: ['main']
branches: ["main"]
pull_request:
types: [opened, synchronize]

Expand All @@ -23,7 +23,7 @@ jobs:

- uses: actions/setup-go@v5
with:
go-version: '^1.23.5'
go-version: "^1.23.5"
- run: go version

- name: Install gofumpt
Expand All @@ -43,3 +43,6 @@ jobs:

- name: Test
run: make test

- name: Run docker tests
run: make docker-test
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
.idea/
tests/
!tests/zod.test.ts
!tests/cases.ts
!tests/golden.test.ts
9 changes: 8 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,14 @@ lint: linters-install
test:
$(GOCMD) test -cover -race ./...

test-update:
GOLDEN_UPDATE=true $(GOCMD) test ./...


docker-test:
./docker-test.sh

bench:
$(GOCMD) test -bench=. -benchmem ./...

.PHONY: test lint linters-install
.PHONY: test test-update lint linters-install docker-test bench
18 changes: 17 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ Converts Go structs with [go-validator](https://github.com/go-playground/validat
Zen supports self-referential types and generic types. Other cyclic types (apart from self referential types) are not supported
as they are not supported by zod itself.

Zen emits Zod v4 schemas by default. Use `zen.WithZodV3()` if you need the previous output style for snapshot compatibility or incremental migration.

## Usage

```go
Expand Down Expand Up @@ -58,6 +60,19 @@ c.AddType(PairMap[string, int, bool]{})
fmt.Print(c.Export())
```

Legacy v3-compatible output is still available:

```go
fmt.Print(zen.StructToZodSchema(User{}, zen.WithZodV3()))
```

The main migration differences are:

- string format tags such as `email`, `http_url`, `ipv4`, `uuid4`, and `md5` now use Zod v4 helpers like `z.email()`, `z.httpUrl()`, `z.ipv4()`, `z.uuid({ version: "v4" })`, and `z.hash("md5")`
- `ip` and `ip_addr` now emit `z.union([z.ipv4(), z.ipv6()])`
- embedded anonymous structs now expand through `.shape` spreads instead of `.merge(...)`
- enum-like map keys now emit `z.partialRecord(...)`

Outputs:

```typescript
Expand Down Expand Up @@ -267,7 +282,8 @@ export const RequestSchema = z.object({
end: z.number().gt(0).optional(),
}).refine((val) => !val.start || !val.end || val.start < val.end, 'Start should be less than end'),
search: z.string().refine((val) => !val || /^[a-z0-9_]*$/.test(val), 'Invalid search identifier').optional(),
}).merge(SortParamsSchema.extend({field: z.enum(['title', 'address', 'age', 'dob'])}))
...SortParamsSchema.extend({field: z.enum(['title', 'address', 'age', 'dob'])}).shape,
})
export type Request = z.infer<typeof RequestSchema>
```

Expand Down
189 changes: 189 additions & 0 deletions docker-test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
#!/bin/bash
#
# Type-checks and runtime-tests golden files inside Docker (zod v3 + v4).
#
# Golden files must contain these metadata comments to be included:
# // @typecheck — present by default; files without it are skipped
# // @zod-version: v3|v4 — (optional) restrict to one zod major; omit for both

set -euo pipefail

PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"

echo "========================================"
echo "Golden File Type-Check (Docker)"
echo "========================================"
echo ""

docker run --rm \
-v "${PROJECT_DIR}/testdata:/golden:ro" \
-v "${PROJECT_DIR}/tests:/tests:ro" \
node:22-alpine \
sh -c '
set -e

mkdir -p /test/zod3 /test/zod4 /test/golden

zod3_count=0
zod4_count=0

for file in $(find /golden -name "*.golden" -type f); do
# Only process files with @typecheck metadata
head -5 "$file" | grep -q "^// @typecheck" || continue

# Extract zod version from metadata (empty = both)
version=$(sed -n "s|^// @zod-version: ||p" "$file" | head -1)

# Build unique .ts filename from relative path
relpath="${file#/golden/}"
ts_name="$(echo "$relpath" | sed "s|/|__|g; s|\.golden$|.ts|")"

prepare_ts() {
printf "import { z } from \"zod\";\n" > "$1"
sed "/^\/\/ @/d" "$file" >> "$1"
}

case "${version}" in
v3)
prepare_ts "/test/zod3/${ts_name}"
zod3_count=$((zod3_count + 1))
;;
v4)
prepare_ts "/test/zod4/${ts_name}"
zod4_count=$((zod4_count + 1))
;;
*)
prepare_ts "/test/zod3/${ts_name}"
prepare_ts "/test/zod4/${ts_name}"
zod3_count=$((zod3_count + 1))
zod4_count=$((zod4_count + 1))
;;
esac

# Also copy to /test/golden/ for runtime tests (all versions)
prepare_ts "/test/golden/${ts_name}"
done

echo "Found ${zod3_count} files for zod@3, ${zod4_count} files for zod@4"
echo ""

for dir in zod3 zod4; do
cat > "/test/${dir}/tsconfig.json" <<TSCONFIG
{
"compilerOptions": {
"strict": true,
"noEmit": true,
"moduleResolution": "node",
"esModuleInterop": true,
"target": "ES2020",
"module": "ES2020"
},
"include": ["*.ts"]
}
TSCONFIG
done

cat > /test/zod3/package.json <<PKG
{
"name": "zen-typecheck-zod3",
"private": true,
"dependencies": { "zod": "^3", "typescript": "^5" }
}
PKG

cat > /test/zod4/package.json <<PKG
{
"name": "zen-typecheck-zod4",
"private": true,
"dependencies": { "zod": "^4", "typescript": "^5" }
}
PKG

for dir in zod3 zod4; do
label="zod@${dir#zod}"
count_var="${dir}_count"
count=$(eval echo "\$$count_var")

if [ "$count" -eq 0 ]; then
echo "No files to type-check for ${label}, skipping..."
echo ""
continue
fi

echo "Type-checking ${count} golden files with ${label}..."
echo "----------------------------------------"

cd "/test/${dir}"
npm install --silent 2>&1
npx tsc --noEmit

echo ""
echo "✓ ${label}: PASSED"
echo ""
done

echo "========================================"
echo "Type checks passed!"
echo "========================================"
echo ""

# --- Phase 2: Runtime tests ---

echo "========================================"
echo "Runtime Tests (vitest)"
echo "========================================"
echo ""

for dir in zod3 zod4; do
label="zod@${dir#zod}"
version="${dir#zod}" # "3" or "4"
runtime_dir="/test/runtime-${dir}"

mkdir -p "${runtime_dir}"

# Copy test files
cp /tests/cases.ts "${runtime_dir}/"
cp /tests/golden.test.ts "${runtime_dir}/"

zod_dep="^${version}"

cat > "${runtime_dir}/package.json" <<PKG
{
"name": "zen-runtime-tests-${dir}",
"private": true,
"type": "module",
"dependencies": { "zod": "${zod_dep}", "vitest": "^3" }
}
PKG

cat > "${runtime_dir}/tsconfig.json" <<TSCONFIG
{
"compilerOptions": {
"strict": true,
"noEmit": true,
"moduleResolution": "bundler",
"esModuleInterop": true,
"target": "ES2022",
"module": "ES2022",
"skipLibCheck": true
},
"include": ["*.ts"]
}
TSCONFIG

echo "Running runtime tests with ${label}..."
echo "----------------------------------------"

cd "${runtime_dir}"
npm install --silent 2>&1
ZOD_VERSION="v${version}" npx vitest run --reporter=verbose

echo ""
echo "✓ ${label} runtime: PASSED"
echo ""
done

echo "========================================"
echo "All checks passed!"
echo "========================================"
'
3 changes: 2 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@ module github.com/hypersequent/zen

go 1.23

require github.com/stretchr/testify v1.8.3
require github.com/stretchr/testify v1.9.0

require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/xorcare/golden v0.8.3 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
13 changes: 13 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,10 +1,23 @@
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY=
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/xorcare/golden v0.8.3 h1:0sFBpM6/ju8YzhN2akrsPTgm6YEuIwuh0JaeAk5Ne3g=
github.com/xorcare/golden v0.8.3/go.mod h1:lRw6LV+0Pp37EBDMR4sXIz4Y7r75dDZ6bYm0ILDpIHY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
6 changes: 6 additions & 0 deletions testdata/TestConvertArray/multi.golden
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
// @typecheck
export const MultiArraySchema = z.object({
Arr: z.string().array().length(30).array().length(20).array().length(10),
})
export type MultiArray = z.infer<typeof MultiArraySchema>

6 changes: 6 additions & 0 deletions testdata/TestConvertArray/single.golden
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
// @typecheck
export const ArraySchema = z.object({
Arr: z.string().array().length(10),
})
export type Array = z.infer<typeof ArraySchema>

18 changes: 18 additions & 0 deletions testdata/TestConvertSlice.golden
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
// @typecheck
export const FooSchema = z.object({
Bar: z.string(),
Baz: z.string(),
Quz: z.string(),
})
export type Foo = z.infer<typeof FooSchema>

export const ZipSchema = z.object({
Zap: FooSchema.nullable(),
})
export type Zip = z.infer<typeof ZipSchema>

export const WhimSchema = z.object({
Wham: FooSchema.nullable(),
})
export type Whim = z.infer<typeof WhimSchema>

51 changes: 51 additions & 0 deletions testdata/TestConvertSliceWithValidations.golden
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
// @typecheck
export const requiredSchema = z.object({
value: z.string().array(),
})
export type required = z.infer<typeof requiredSchema>

export const minSchema = z.object({
value: z.string().array().min(1),
})
export type min = z.infer<typeof minSchema>

export const maxSchema = z.object({
value: z.string().array().max(1),
})
export type max = z.infer<typeof maxSchema>

export const lenSchema = z.object({
value: z.string().array().length(1),
})
export type len = z.infer<typeof lenSchema>

export const eqSchema = z.object({
value: z.string().array().length(1),
})
export type eq = z.infer<typeof eqSchema>

export const gtSchema = z.object({
value: z.string().array().min(2),
})
export type gt = z.infer<typeof gtSchema>

export const gteSchema = z.object({
value: z.string().array().min(1),
})
export type gte = z.infer<typeof gteSchema>

export const ltSchema = z.object({
value: z.string().array().max(0),
})
export type lt = z.infer<typeof ltSchema>

export const lteSchema = z.object({
value: z.string().array().max(1),
})
export type lte = z.infer<typeof lteSchema>

export const neSchema = z.object({
value: z.string().array().refine((val) => val.length !== 0),
})
export type ne = z.infer<typeof neSchema>

Loading
Loading