diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6f85e80..d29c9e8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,7 @@ name: CI on: push: - branches: ['main'] + branches: ["main"] pull_request: types: [opened, synchronize] @@ -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 @@ -43,3 +43,6 @@ jobs: - name: Test run: make test + + - name: Run docker tests + run: make docker-test diff --git a/.gitignore b/.gitignore index 3a5ac12..59676ea 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ .idea/ tests/ -!tests/zod.test.ts +!tests/cases.ts +!tests/golden.test.ts diff --git a/Makefile b/Makefile index ff90a84..6a261ea 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/README.md b/README.md index 0dd095d..9798070 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 @@ -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 ``` diff --git a/custom/decimal/go.mod b/custom/decimal/go.mod index ce2cba2..f2d3fc6 100644 --- a/custom/decimal/go.mod +++ b/custom/decimal/go.mod @@ -7,11 +7,12 @@ replace github.com/hypersequent/zen => ../.. require ( github.com/hypersequent/zen v0.0.0-00010101000000-000000000000 github.com/shopspring/decimal v1.3.1 - github.com/stretchr/testify v1.8.3 + github.com/stretchr/testify v1.11.1 ) require ( github.com/davecgh/go-spew v1.1.1 // indirect + github.com/kr/text v0.2.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/custom/decimal/go.sum b/custom/decimal/go.sum index 7dfe67b..241d742 100644 --- a/custom/decimal/go.sum +++ b/custom/decimal/go.sum @@ -1,12 +1,15 @@ 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/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 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/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/shopspring/decimal v1.3.1 h1:2Usl1nmF/WZucqkFZhnfFYxxxu8LG21F6nPQBE5gKV8= github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= -github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY= -github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/xorcare/golden v0.8.3 h1:0sFBpM6/ju8YzhN2akrsPTgm6YEuIwuh0JaeAk5Ne3g= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/custom/optional/go.mod b/custom/optional/go.mod index 7a3e146..1cc3b96 100644 --- a/custom/optional/go.mod +++ b/custom/optional/go.mod @@ -7,11 +7,12 @@ replace github.com/hypersequent/zen => ../.. require ( 4d63.com/optional v0.2.0 github.com/hypersequent/zen v0.0.0-00010101000000-000000000000 - github.com/stretchr/testify v1.8.3 + github.com/stretchr/testify v1.11.1 ) require ( github.com/davecgh/go-spew v1.1.1 // indirect + github.com/kr/text v0.2.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/custom/optional/go.sum b/custom/optional/go.sum index 6caa326..c2aa4cc 100644 --- a/custom/optional/go.sum +++ b/custom/optional/go.sum @@ -2,11 +2,14 @@ 4d63.com/optional v0.2.0/go.mod h1:DBA8tAdkYkYbvRq1lK3FyDBBzioAJzZzQPC6Vj+a3jk= 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/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 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/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY= -github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/xorcare/golden v0.8.3 h1:0sFBpM6/ju8YzhN2akrsPTgm6YEuIwuh0JaeAk5Ne3g= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/docker-test.sh b/docker-test.sh new file mode 100755 index 0000000..5081e27 --- /dev/null +++ b/docker-test.sh @@ -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" < /test/zod3/package.json < /test/zod4/package.json <&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" < "${runtime_dir}/tsconfig.json" <&1 + ZOD_VERSION="v${version}" npx vitest run --reporter=verbose + + echo "" + echo "✓ ${label} runtime: PASSED" + echo "" +done + +echo "========================================" +echo "All checks passed!" +echo "========================================" +' diff --git a/go.mod b/go.mod index bc34ef1..5990e81 100644 --- a/go.mod +++ b/go.mod @@ -2,10 +2,16 @@ module github.com/hypersequent/zen go 1.23 -require github.com/stretchr/testify v1.8.3 +require ( + github.com/stretchr/testify v1.11.1 + github.com/xorcare/golden v0.8.3 +) require ( github.com/davecgh/go-spew v1.1.1 // indirect + github.com/kr/pretty v0.3.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/rogpeppe/go-internal v1.14.1 // indirect + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 57c201b..9a44efd 100644 --- a/go.sum +++ b/go.sum @@ -1,10 +1,24 @@ +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/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 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/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY= -github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +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.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +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/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +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= diff --git a/go.work.sum b/go.work.sum index 6e91cd0..3895189 100644 --- a/go.work.sum +++ b/go.work.sum @@ -1,2 +1,15 @@ +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c= -github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= +golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/tools v0.26.0/go.mod h1:TPVVj70c7JJ3WCazhD8OdXcZg/og+b9+tH/KxylGwH0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/testdata/TestConvertArray/multi.golden b/testdata/TestConvertArray/multi.golden new file mode 100644 index 0000000..d4d026f --- /dev/null +++ b/testdata/TestConvertArray/multi.golden @@ -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 + diff --git a/testdata/TestConvertArray/single.golden b/testdata/TestConvertArray/single.golden new file mode 100644 index 0000000..1136a4d --- /dev/null +++ b/testdata/TestConvertArray/single.golden @@ -0,0 +1,6 @@ +// @typecheck +export const ArraySchema = z.object({ + Arr: z.string().array().length(10), +}) +export type Array = z.infer + diff --git a/testdata/TestConvertSlice.golden b/testdata/TestConvertSlice.golden new file mode 100644 index 0000000..56fde4e --- /dev/null +++ b/testdata/TestConvertSlice.golden @@ -0,0 +1,18 @@ +// @typecheck +export const FooSchema = z.object({ + Bar: z.string(), + Baz: z.string(), + Quz: z.string(), +}) +export type Foo = z.infer + +export const ZipSchema = z.object({ + Zap: FooSchema.nullable(), +}) +export type Zip = z.infer + +export const WhimSchema = z.object({ + Wham: FooSchema.nullable(), +}) +export type Whim = z.infer + diff --git a/testdata/TestConvertSliceWithValidations.golden b/testdata/TestConvertSliceWithValidations.golden new file mode 100644 index 0000000..2514c8c --- /dev/null +++ b/testdata/TestConvertSliceWithValidations.golden @@ -0,0 +1,51 @@ +// @typecheck +export const requiredSchema = z.object({ + value: z.string().array(), +}) +export type required = z.infer + +export const minSchema = z.object({ + value: z.string().array().min(1), +}) +export type min = z.infer + +export const maxSchema = z.object({ + value: z.string().array().max(1), +}) +export type max = z.infer + +export const lenSchema = z.object({ + value: z.string().array().length(1), +}) +export type len = z.infer + +export const eqSchema = z.object({ + value: z.string().array().length(1), +}) +export type eq = z.infer + +export const gtSchema = z.object({ + value: z.string().array().min(2), +}) +export type gt = z.infer + +export const gteSchema = z.object({ + value: z.string().array().min(1), +}) +export type gte = z.infer + +export const ltSchema = z.object({ + value: z.string().array().max(0), +}) +export type lt = z.infer + +export const lteSchema = z.object({ + value: z.string().array().max(1), +}) +export type lte = z.infer + +export const neSchema = z.object({ + value: z.string().array().refine((val) => val.length !== 0), +}) +export type ne = z.infer + diff --git a/testdata/TestConvertSliceWithValidations/dive_nested.golden b/testdata/TestConvertSliceWithValidations/dive_nested.golden new file mode 100644 index 0000000..9e68e8d --- /dev/null +++ b/testdata/TestConvertSliceWithValidations/dive_nested.golden @@ -0,0 +1,11 @@ +// @typecheck +export const dive1Schema = z.object({ + value: z.string().array().array().nullable(), +}) +export type dive1 = z.infer + +export const dive2Schema = z.object({ + value: z.string().array().min(1).array(), +}) +export type dive2 = z.infer + diff --git a/testdata/TestConvertSliceWithValidations/dive_oneof.golden b/testdata/TestConvertSliceWithValidations/dive_oneof.golden new file mode 100644 index 0000000..bacea2d --- /dev/null +++ b/testdata/TestConvertSliceWithValidations/dive_oneof.golden @@ -0,0 +1,6 @@ +// @typecheck +export const dive_oneofSchema = z.object({ + value: z.enum(["a", "b", "c"] as const).array().nullable(), +}) +export type dive_oneof = z.infer + diff --git a/testdata/TestCustomTag/v3.golden b/testdata/TestCustomTag/v3.golden new file mode 100644 index 0000000..1891285 --- /dev/null +++ b/testdata/TestCustomTag/v3.golden @@ -0,0 +1,17 @@ +// @zod-version: v3 +// @typecheck +export const SortParamsSchema = z.object({ + order: z.enum(["asc", "desc"] as const).optional(), + field: z.string().optional(), +}) +export type SortParams = z.infer + +export const RequestSchema = z.object({ + PaginationParams: z.object({ + start: z.number().gt(0).optional(), + 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'])})) +export type Request = z.infer + diff --git a/testdata/TestCustomTag/v4.golden b/testdata/TestCustomTag/v4.golden new file mode 100644 index 0000000..78f43fd --- /dev/null +++ b/testdata/TestCustomTag/v4.golden @@ -0,0 +1,18 @@ +// @zod-version: v4 +// @typecheck +export const SortParamsSchema = z.object({ + order: z.enum(["asc", "desc"] as const).optional(), + field: z.string().optional(), +}) +export type SortParams = z.infer + +export const RequestSchema = z.object({ + ...SortParamsSchema.extend({field: z.enum(['title', 'address', 'age', 'dob'])}).shape, + PaginationParams: z.object({ + start: z.number().gt(0).optional(), + 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(), +}) +export type Request = z.infer + diff --git a/testdata/TestCustomTypes/custom_type_mapped_to_string.golden b/testdata/TestCustomTypes/custom_type_mapped_to_string.golden new file mode 100644 index 0000000..9e58894 --- /dev/null +++ b/testdata/TestCustomTypes/custom_type_mapped_to_string.golden @@ -0,0 +1,7 @@ +// @typecheck +export const UserSchema = z.object({ + Name: z.string(), + Money: z.string(), +}) +export type User = z.infer + diff --git a/testdata/TestCustomTypes/custom_type_resolves_inner_generic_type.golden b/testdata/TestCustomTypes/custom_type_resolves_inner_generic_type.golden new file mode 100644 index 0000000..63d618b --- /dev/null +++ b/testdata/TestCustomTypes/custom_type_resolves_inner_generic_type.golden @@ -0,0 +1,14 @@ +// @typecheck +export const ProfileSchema = z.object({ + Bio: z.string(), +}) +export type Profile = z.infer + +export const UserSchema = z.object({ + MaybeName: z.string().optional().nullish(), + MaybeAge: z.number().optional().nullish(), + MaybeHeight: z.number().optional().nullish(), + MaybeProfile: ProfileSchema.optional().nullish(), +}) +export type User = z.infer + diff --git a/testdata/TestCustomTypes/custom_type_with_nullable_control.golden b/testdata/TestCustomTypes/custom_type_with_nullable_control.golden new file mode 100644 index 0000000..029d662 --- /dev/null +++ b/testdata/TestCustomTypes/custom_type_with_nullable_control.golden @@ -0,0 +1,7 @@ +// @typecheck +export const UserSchema = z.object({ + Name: z.string(), + Email: z.string().optional().nullable(), +}) +export type User = z.infer + diff --git a/testdata/TestDuration.golden b/testdata/TestDuration.golden new file mode 100644 index 0000000..a99c96c --- /dev/null +++ b/testdata/TestDuration.golden @@ -0,0 +1,6 @@ +// @typecheck +export const UserSchema = z.object({ + HowLong: z.number(), +}) +export type User = z.infer + diff --git a/testdata/TestEverything.golden b/testdata/TestEverything.golden new file mode 100644 index 0000000..836c9b1 --- /dev/null +++ b/testdata/TestEverything.golden @@ -0,0 +1,42 @@ +// @typecheck +export const PostSchema = z.object({ + Title: z.string(), +}) +export type Post = z.infer + +export const PostWithMetaDataSchema = z.object({ + Title: z.string(), + Post: PostSchema, +}) +export type PostWithMetaData = z.infer + +export const UserSchema = z.object({ + Name: z.string(), + Nickname: z.string().nullable(), + Age: z.number(), + Height: z.number(), + OldPostWithMetaData: PostWithMetaDataSchema, + Tags: z.string().array().nullable(), + TagsOptional: z.string().array().optional(), + TagsOptionalNullable: z.string().array().optional().nullable(), + Favourites: z.object({ + Name: z.string(), + }).array().nullable(), + Posts: PostSchema.array().nullable(), + Post: PostSchema, + PostOptional: PostSchema.optional(), + PostOptionalNullable: PostSchema.optional().nullable(), + Metadata: z.record(z.string(), z.string()).nullable(), + MetadataOptional: z.record(z.string(), z.string()).optional(), + MetadataOptionalNullable: z.record(z.string(), z.string()).optional().nullable(), + ExtendedProps: z.any(), + ExtendedPropsOptional: z.any(), + ExtendedPropsNullable: z.any(), + ExtendedPropsOptionalNullable: z.any(), + ExtendedPropsVeryIndirect: z.any(), + NewPostWithMetaData: PostWithMetaDataSchema, + VeryNewPost: PostSchema, + MapWithStruct: z.record(z.string(), PostWithMetaDataSchema).nullable(), +}) +export type User = z.infer + diff --git a/testdata/TestEverythingWithValidations.golden b/testdata/TestEverythingWithValidations.golden new file mode 100644 index 0000000..7b837ec --- /dev/null +++ b/testdata/TestEverythingWithValidations.golden @@ -0,0 +1,43 @@ +// @typecheck +export const PostSchema = z.object({ + Title: z.string().min(1), +}) +export type Post = z.infer + +export const PostWithMetaDataSchema = z.object({ + Title: z.string().min(1), + Post: PostSchema, +}) +export type PostWithMetaData = z.infer + +export const UserSchema = z.object({ + Name: z.string().min(1), + Nickname: z.string().nullable(), + Age: z.number().gte(18).refine((val) => val !== 0), + Height: z.number().gte(1.5).refine((val) => val !== 0), + OldPostWithMetaData: PostWithMetaDataSchema, + Tags: z.string().array().min(1), + TagsOptional: z.string().array().optional(), + TagsOptionalNullable: z.string().array().optional().nullable(), + Favourites: z.object({ + Name: z.string().min(1), + }).array().nullable(), + Posts: PostSchema.array(), + Post: PostSchema, + PostOptional: PostSchema.optional(), + PostOptionalNullable: PostSchema.optional().nullable(), + Metadata: z.record(z.string(), z.string()).nullable(), + MetadataLength: z.record(z.string(), z.string()).refine((val) => Object.keys(val).length >= 1, 'Map too small').refine((val) => Object.keys(val).length <= 10, 'Map too large'), + MetadataOptional: z.record(z.string(), z.string()).optional(), + MetadataOptionalNullable: z.record(z.string(), z.string()).optional().nullable(), + ExtendedProps: z.any(), + ExtendedPropsOptional: z.any(), + ExtendedPropsNullable: z.any(), + ExtendedPropsOptionalNullable: z.any(), + ExtendedPropsVeryIndirect: z.any(), + NewPostWithMetaData: PostWithMetaDataSchema, + VeryNewPost: PostSchema, + MapWithStruct: z.record(z.string(), PostWithMetaDataSchema).nullable(), +}) +export type User = z.infer + diff --git a/testdata/TestFormatValidators/format_only/v3.golden b/testdata/TestFormatValidators/format_only/v3.golden new file mode 100644 index 0000000..6c3af71 --- /dev/null +++ b/testdata/TestFormatValidators/format_only/v3.golden @@ -0,0 +1,117 @@ +// @zod-version: v3 +// @typecheck +export const emailSchema = z.object({ + value: z.string().email(), +}) +export type email = z.infer + +export const urlSchema = z.object({ + value: z.string().url(), +}) +export type url = z.infer + +export const http_urlSchema = z.object({ + value: z.string().url(), +}) +export type http_url = z.infer + +export const ipv4Schema = z.object({ + value: z.string().ip({ version: "v4" }), +}) +export type ipv4 = z.infer + +export const ip4_addrSchema = z.object({ + value: z.string().ip({ version: "v4" }), +}) +export type ip4_addr = z.infer + +export const ipv6Schema = z.object({ + value: z.string().ip({ version: "v6" }), +}) +export type ipv6 = z.infer + +export const ip6_addrSchema = z.object({ + value: z.string().ip({ version: "v6" }), +}) +export type ip6_addr = z.infer + +export const base64Schema = z.object({ + value: z.string().regex(/^(?:[A-Za-z0-9+\/]{4})*(?:[A-Za-z0-9+\/]{2}==|[A-Za-z0-9+\/]{3}=|[A-Za-z0-9+\/]{4})$/), +}) +export type base64 = z.infer + +export const datetimeSchema = z.object({ + value: z.string().datetime(), +}) +export type datetime = z.infer + +export const hexadecimalSchema = z.object({ + value: z.string().regex(/^(0[xX])?[0-9a-fA-F]+$/), +}) +export type hexadecimal = z.infer + +export const jwtSchema = z.object({ + value: z.string().regex(/^[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]*$/), +}) +export type jwt = z.infer + +export const uuidSchema = z.object({ + value: z.string().regex(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/), +}) +export type uuid = z.infer + +export const uuid3Schema = z.object({ + value: z.string().regex(/^[0-9a-f]{8}-[0-9a-f]{4}-3[0-9a-f]{3}-[0-9a-f]{4}-[0-9a-f]{12}$/), +}) +export type uuid3 = z.infer + +export const uuid3_rfc4122Schema = z.object({ + value: z.string().regex(/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-3[0-9a-fA-F]{3}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/), +}) +export type uuid3_rfc4122 = z.infer + +export const uuid4Schema = z.object({ + value: z.string().regex(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/), +}) +export type uuid4 = z.infer + +export const uuid4_rfc4122Schema = z.object({ + value: z.string().regex(/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$/), +}) +export type uuid4_rfc4122 = z.infer + +export const uuid5Schema = z.object({ + value: z.string().regex(/^[0-9a-f]{8}-[0-9a-f]{4}-5[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/), +}) +export type uuid5 = z.infer + +export const uuid5_rfc4122Schema = z.object({ + value: z.string().regex(/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-5[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$/), +}) +export type uuid5_rfc4122 = z.infer + +export const uuid_rfc4122Schema = z.object({ + value: z.string().regex(/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/), +}) +export type uuid_rfc4122 = z.infer + +export const md5Schema = z.object({ + value: z.string().regex(/^[0-9a-f]{32}$/), +}) +export type md5 = z.infer + +export const sha256Schema = z.object({ + value: z.string().regex(/^[0-9a-f]{64}$/), +}) +export type sha256 = z.infer + +export const sha384Schema = z.object({ + value: z.string().regex(/^[0-9a-f]{96}$/), +}) +export type sha384 = z.infer + +export const sha512Schema = z.object({ + value: z.string().regex(/^[0-9a-f]{128}$/), +}) +export type sha512 = z.infer + diff --git a/testdata/TestFormatValidators/format_only/v4.golden b/testdata/TestFormatValidators/format_only/v4.golden new file mode 100644 index 0000000..96e56b5 --- /dev/null +++ b/testdata/TestFormatValidators/format_only/v4.golden @@ -0,0 +1,117 @@ +// @zod-version: v4 +// @typecheck +export const emailSchema = z.object({ + value: z.string().check(z.email()), +}) +export type email = z.infer + +export const urlSchema = z.object({ + value: z.string().check(z.url()), +}) +export type url = z.infer + +export const http_urlSchema = z.object({ + value: z.string().check(z.httpUrl()), +}) +export type http_url = z.infer + +export const ipv4Schema = z.object({ + value: z.string().check(z.ipv4()), +}) +export type ipv4 = z.infer + +export const ip4_addrSchema = z.object({ + value: z.string().check(z.ipv4()), +}) +export type ip4_addr = z.infer + +export const ipv6Schema = z.object({ + value: z.string().check(z.ipv6()), +}) +export type ipv6 = z.infer + +export const ip6_addrSchema = z.object({ + value: z.string().check(z.ipv6()), +}) +export type ip6_addr = z.infer + +export const base64Schema = z.object({ + value: z.string().check(z.base64()), +}) +export type base64 = z.infer + +export const datetimeSchema = z.object({ + value: z.string().check(z.iso.datetime()), +}) +export type datetime = z.infer + +export const hexadecimalSchema = z.object({ + value: z.string().check(z.hex()), +}) +export type hexadecimal = z.infer + +export const jwtSchema = z.object({ + value: z.string().check(z.jwt()), +}) +export type jwt = z.infer + +export const uuidSchema = z.object({ + value: z.string().check(z.uuid()), +}) +export type uuid = z.infer + +export const uuid3Schema = z.object({ + value: z.string().check(z.uuid({ version: "v3" })), +}) +export type uuid3 = z.infer + +export const uuid3_rfc4122Schema = z.object({ + value: z.string().check(z.uuid({ version: "v3" })), +}) +export type uuid3_rfc4122 = z.infer + +export const uuid4Schema = z.object({ + value: z.string().check(z.uuid({ version: "v4" })), +}) +export type uuid4 = z.infer + +export const uuid4_rfc4122Schema = z.object({ + value: z.string().check(z.uuid({ version: "v4" })), +}) +export type uuid4_rfc4122 = z.infer + +export const uuid5Schema = z.object({ + value: z.string().check(z.uuid({ version: "v5" })), +}) +export type uuid5 = z.infer + +export const uuid5_rfc4122Schema = z.object({ + value: z.string().check(z.uuid({ version: "v5" })), +}) +export type uuid5_rfc4122 = z.infer + +export const uuid_rfc4122Schema = z.object({ + value: z.string().check(z.uuid()), +}) +export type uuid_rfc4122 = z.infer + +export const md5Schema = z.object({ + value: z.string().check(z.hash("md5")), +}) +export type md5 = z.infer + +export const sha256Schema = z.object({ + value: z.string().check(z.hash("sha256")), +}) +export type sha256 = z.infer + +export const sha384Schema = z.object({ + value: z.string().check(z.hash("sha384")), +}) +export type sha384 = z.infer + +export const sha512Schema = z.object({ + value: z.string().check(z.hash("sha512")), +}) +export type sha512 = z.infer + diff --git a/testdata/TestFormatValidators/format_with_required/v3.golden b/testdata/TestFormatValidators/format_with_required/v3.golden new file mode 100644 index 0000000..8aceef1 --- /dev/null +++ b/testdata/TestFormatValidators/format_with_required/v3.golden @@ -0,0 +1,117 @@ +// @zod-version: v3 +// @typecheck +export const emailSchema = z.object({ + value: z.string().min(1).email(), +}) +export type email = z.infer + +export const urlSchema = z.object({ + value: z.string().min(1).url(), +}) +export type url = z.infer + +export const http_urlSchema = z.object({ + value: z.string().min(1).url(), +}) +export type http_url = z.infer + +export const ipv4Schema = z.object({ + value: z.string().min(1).ip({ version: "v4" }), +}) +export type ipv4 = z.infer + +export const ip4_addrSchema = z.object({ + value: z.string().min(1).ip({ version: "v4" }), +}) +export type ip4_addr = z.infer + +export const ipv6Schema = z.object({ + value: z.string().min(1).ip({ version: "v6" }), +}) +export type ipv6 = z.infer + +export const ip6_addrSchema = z.object({ + value: z.string().min(1).ip({ version: "v6" }), +}) +export type ip6_addr = z.infer + +export const base64Schema = z.object({ + value: z.string().min(1).regex(/^(?:[A-Za-z0-9+\/]{4})*(?:[A-Za-z0-9+\/]{2}==|[A-Za-z0-9+\/]{3}=|[A-Za-z0-9+\/]{4})$/), +}) +export type base64 = z.infer + +export const datetimeSchema = z.object({ + value: z.string().min(1).datetime(), +}) +export type datetime = z.infer + +export const hexadecimalSchema = z.object({ + value: z.string().min(1).regex(/^(0[xX])?[0-9a-fA-F]+$/), +}) +export type hexadecimal = z.infer + +export const jwtSchema = z.object({ + value: z.string().min(1).regex(/^[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]*$/), +}) +export type jwt = z.infer + +export const uuidSchema = z.object({ + value: z.string().min(1).regex(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/), +}) +export type uuid = z.infer + +export const uuid3Schema = z.object({ + value: z.string().min(1).regex(/^[0-9a-f]{8}-[0-9a-f]{4}-3[0-9a-f]{3}-[0-9a-f]{4}-[0-9a-f]{12}$/), +}) +export type uuid3 = z.infer + +export const uuid3_rfc4122Schema = z.object({ + value: z.string().min(1).regex(/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-3[0-9a-fA-F]{3}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/), +}) +export type uuid3_rfc4122 = z.infer + +export const uuid4Schema = z.object({ + value: z.string().min(1).regex(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/), +}) +export type uuid4 = z.infer + +export const uuid4_rfc4122Schema = z.object({ + value: z.string().min(1).regex(/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$/), +}) +export type uuid4_rfc4122 = z.infer + +export const uuid5Schema = z.object({ + value: z.string().min(1).regex(/^[0-9a-f]{8}-[0-9a-f]{4}-5[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/), +}) +export type uuid5 = z.infer + +export const uuid5_rfc4122Schema = z.object({ + value: z.string().min(1).regex(/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-5[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$/), +}) +export type uuid5_rfc4122 = z.infer + +export const uuid_rfc4122Schema = z.object({ + value: z.string().min(1).regex(/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/), +}) +export type uuid_rfc4122 = z.infer + +export const md5Schema = z.object({ + value: z.string().min(1).regex(/^[0-9a-f]{32}$/), +}) +export type md5 = z.infer + +export const sha256Schema = z.object({ + value: z.string().min(1).regex(/^[0-9a-f]{64}$/), +}) +export type sha256 = z.infer + +export const sha384Schema = z.object({ + value: z.string().min(1).regex(/^[0-9a-f]{96}$/), +}) +export type sha384 = z.infer + +export const sha512Schema = z.object({ + value: z.string().min(1).regex(/^[0-9a-f]{128}$/), +}) +export type sha512 = z.infer + diff --git a/testdata/TestFormatValidators/format_with_required/v4.golden b/testdata/TestFormatValidators/format_with_required/v4.golden new file mode 100644 index 0000000..7cf7050 --- /dev/null +++ b/testdata/TestFormatValidators/format_with_required/v4.golden @@ -0,0 +1,117 @@ +// @zod-version: v4 +// @typecheck +export const emailSchema = z.object({ + value: z.string().min(1).check(z.email()), +}) +export type email = z.infer + +export const urlSchema = z.object({ + value: z.string().min(1).check(z.url()), +}) +export type url = z.infer + +export const http_urlSchema = z.object({ + value: z.string().min(1).check(z.httpUrl()), +}) +export type http_url = z.infer + +export const ipv4Schema = z.object({ + value: z.string().min(1).check(z.ipv4()), +}) +export type ipv4 = z.infer + +export const ip4_addrSchema = z.object({ + value: z.string().min(1).check(z.ipv4()), +}) +export type ip4_addr = z.infer + +export const ipv6Schema = z.object({ + value: z.string().min(1).check(z.ipv6()), +}) +export type ipv6 = z.infer + +export const ip6_addrSchema = z.object({ + value: z.string().min(1).check(z.ipv6()), +}) +export type ip6_addr = z.infer + +export const base64Schema = z.object({ + value: z.string().min(1).check(z.base64()), +}) +export type base64 = z.infer + +export const datetimeSchema = z.object({ + value: z.string().min(1).check(z.iso.datetime()), +}) +export type datetime = z.infer + +export const hexadecimalSchema = z.object({ + value: z.string().min(1).check(z.hex()), +}) +export type hexadecimal = z.infer + +export const jwtSchema = z.object({ + value: z.string().min(1).check(z.jwt()), +}) +export type jwt = z.infer + +export const uuidSchema = z.object({ + value: z.string().min(1).check(z.uuid()), +}) +export type uuid = z.infer + +export const uuid3Schema = z.object({ + value: z.string().min(1).check(z.uuid({ version: "v3" })), +}) +export type uuid3 = z.infer + +export const uuid3_rfc4122Schema = z.object({ + value: z.string().min(1).check(z.uuid({ version: "v3" })), +}) +export type uuid3_rfc4122 = z.infer + +export const uuid4Schema = z.object({ + value: z.string().min(1).check(z.uuid({ version: "v4" })), +}) +export type uuid4 = z.infer + +export const uuid4_rfc4122Schema = z.object({ + value: z.string().min(1).check(z.uuid({ version: "v4" })), +}) +export type uuid4_rfc4122 = z.infer + +export const uuid5Schema = z.object({ + value: z.string().min(1).check(z.uuid({ version: "v5" })), +}) +export type uuid5 = z.infer + +export const uuid5_rfc4122Schema = z.object({ + value: z.string().min(1).check(z.uuid({ version: "v5" })), +}) +export type uuid5_rfc4122 = z.infer + +export const uuid_rfc4122Schema = z.object({ + value: z.string().min(1).check(z.uuid()), +}) +export type uuid_rfc4122 = z.infer + +export const md5Schema = z.object({ + value: z.string().min(1).check(z.hash("md5")), +}) +export type md5 = z.infer + +export const sha256Schema = z.object({ + value: z.string().min(1).check(z.hash("sha256")), +}) +export type sha256 = z.infer + +export const sha384Schema = z.object({ + value: z.string().min(1).check(z.hash("sha384")), +}) +export type sha384 = z.infer + +export const sha512Schema = z.object({ + value: z.string().min(1).check(z.hash("sha512")), +}) +export type sha512 = z.infer + diff --git a/testdata/TestFormatValidators/union_only/v3.golden b/testdata/TestFormatValidators/union_only/v3.golden new file mode 100644 index 0000000..0d4bbef --- /dev/null +++ b/testdata/TestFormatValidators/union_only/v3.golden @@ -0,0 +1,12 @@ +// @zod-version: v3 +// @typecheck +export const ipSchema = z.object({ + value: z.string().ip(), +}) +export type ip = z.infer + +export const ip_addrSchema = z.object({ + value: z.string().ip(), +}) +export type ip_addr = z.infer + diff --git a/testdata/TestFormatValidators/union_only/v4.golden b/testdata/TestFormatValidators/union_only/v4.golden new file mode 100644 index 0000000..83dc5b7 --- /dev/null +++ b/testdata/TestFormatValidators/union_only/v4.golden @@ -0,0 +1,12 @@ +// @zod-version: v4 +// @typecheck +export const ipSchema = z.object({ + value: z.union([z.string().check(z.ipv4()), z.string().check(z.ipv6())]), +}) +export type ip = z.infer + +export const ip_addrSchema = z.object({ + value: z.union([z.string().check(z.ipv4()), z.string().check(z.ipv6())]), +}) +export type ip_addr = z.infer + diff --git a/testdata/TestFormatValidators/union_with_required/v3.golden b/testdata/TestFormatValidators/union_with_required/v3.golden new file mode 100644 index 0000000..de44142 --- /dev/null +++ b/testdata/TestFormatValidators/union_with_required/v3.golden @@ -0,0 +1,12 @@ +// @zod-version: v3 +// @typecheck +export const ipSchema = z.object({ + value: z.string().min(1).ip(), +}) +export type ip = z.infer + +export const ip_addrSchema = z.object({ + value: z.string().min(1).ip(), +}) +export type ip_addr = z.infer + diff --git a/testdata/TestFormatValidators/union_with_required/v4.golden b/testdata/TestFormatValidators/union_with_required/v4.golden new file mode 100644 index 0000000..df29b72 --- /dev/null +++ b/testdata/TestFormatValidators/union_with_required/v4.golden @@ -0,0 +1,12 @@ +// @zod-version: v4 +// @typecheck +export const ipSchema = z.object({ + value: z.union([z.string().check(z.ipv4()).min(1), z.string().check(z.ipv6()).min(1)]), +}) +export type ip = z.infer + +export const ip_addrSchema = z.object({ + value: z.union([z.string().check(z.ipv4()).min(1), z.string().check(z.ipv6()).min(1)]), +}) +export type ip_addr = z.infer + diff --git a/testdata/TestGenerics.golden b/testdata/TestGenerics.golden new file mode 100644 index 0000000..1d9da4b --- /dev/null +++ b/testdata/TestGenerics.golden @@ -0,0 +1,18 @@ +// @typecheck +export const StringIntPairSchema = z.object({ + First: z.string(), + Second: z.number(), +}) +export type StringIntPair = z.infer + +export const GenericPairIntBoolSchema = z.object({ + First: z.number(), + Second: z.boolean(), +}) +export type GenericPairIntBool = z.infer + +export const PairMapStringIntBoolSchema = z.object({ + items: z.record(z.string(), GenericPairIntBoolSchema).nullable(), +}) +export type PairMapStringIntBool = z.infer + diff --git a/testdata/TestInterfaceAny.golden b/testdata/TestInterfaceAny.golden new file mode 100644 index 0000000..091f4dd --- /dev/null +++ b/testdata/TestInterfaceAny.golden @@ -0,0 +1,7 @@ +// @typecheck +export const UserSchema = z.object({ + Name: z.string(), + Metadata: z.any(), +}) +export type User = z.infer + diff --git a/testdata/TestInterfaceEmptyAny.golden b/testdata/TestInterfaceEmptyAny.golden new file mode 100644 index 0000000..091f4dd --- /dev/null +++ b/testdata/TestInterfaceEmptyAny.golden @@ -0,0 +1,7 @@ +// @typecheck +export const UserSchema = z.object({ + Name: z.string(), + Metadata: z.any(), +}) +export type User = z.infer + diff --git a/testdata/TestInterfacePointerAny.golden b/testdata/TestInterfacePointerAny.golden new file mode 100644 index 0000000..091f4dd --- /dev/null +++ b/testdata/TestInterfacePointerAny.golden @@ -0,0 +1,7 @@ +// @typecheck +export const UserSchema = z.object({ + Name: z.string(), + Metadata: z.any(), +}) +export type User = z.infer + diff --git a/testdata/TestInterfacePointerEmptyAny.golden b/testdata/TestInterfacePointerEmptyAny.golden new file mode 100644 index 0000000..091f4dd --- /dev/null +++ b/testdata/TestInterfacePointerEmptyAny.golden @@ -0,0 +1,7 @@ +// @typecheck +export const UserSchema = z.object({ + Name: z.string(), + Metadata: z.any(), +}) +export type User = z.infer + diff --git a/testdata/TestMapStringToInterface.golden b/testdata/TestMapStringToInterface.golden new file mode 100644 index 0000000..abc7d79 --- /dev/null +++ b/testdata/TestMapStringToInterface.golden @@ -0,0 +1,7 @@ +// @typecheck +export const UserSchema = z.object({ + Name: z.string(), + Metadata: z.record(z.string(), z.any()).nullable(), +}) +export type User = z.infer + diff --git a/testdata/TestMapStringToString.golden b/testdata/TestMapStringToString.golden new file mode 100644 index 0000000..31fec50 --- /dev/null +++ b/testdata/TestMapStringToString.golden @@ -0,0 +1,7 @@ +// @typecheck +export const UserSchema = z.object({ + Name: z.string(), + Metadata: z.record(z.string(), z.string()).nullable(), +}) +export type User = z.infer + diff --git a/testdata/TestMapWithEnumKey/v3.golden b/testdata/TestMapWithEnumKey/v3.golden new file mode 100644 index 0000000..8d55639 --- /dev/null +++ b/testdata/TestMapWithEnumKey/v3.golden @@ -0,0 +1,7 @@ +// @zod-version: v3 +// @typecheck +export const PayloadSchema = z.object({ + Metadata: z.record(z.enum(["draft", "published"] as const), z.string()).nullable(), +}) +export type Payload = z.infer + diff --git a/testdata/TestMapWithEnumKey/v4.golden b/testdata/TestMapWithEnumKey/v4.golden new file mode 100644 index 0000000..50fb1d7 --- /dev/null +++ b/testdata/TestMapWithEnumKey/v4.golden @@ -0,0 +1,7 @@ +// @zod-version: v4 +// @typecheck +export const PayloadSchema = z.object({ + Metadata: z.partialRecord(z.enum(["draft", "published"] as const), z.string()).nullable(), +}) +export type Payload = z.infer + diff --git a/testdata/TestMapWithNonStringKey/float_key.golden b/testdata/TestMapWithNonStringKey/float_key.golden new file mode 100644 index 0000000..02d494a --- /dev/null +++ b/testdata/TestMapWithNonStringKey/float_key.golden @@ -0,0 +1,7 @@ +// @typecheck +export const Map3Schema = z.object({ + Name: z.string(), + Metadata: z.record(z.coerce.number(), z.string()).nullable(), +}) +export type Map3 = z.infer + diff --git a/testdata/TestMapWithNonStringKey/int_key.golden b/testdata/TestMapWithNonStringKey/int_key.golden new file mode 100644 index 0000000..8ee1192 --- /dev/null +++ b/testdata/TestMapWithNonStringKey/int_key.golden @@ -0,0 +1,7 @@ +// @typecheck +export const Map1Schema = z.object({ + Name: z.string(), + Metadata: z.record(z.coerce.number(), z.string()).nullable(), +}) +export type Map1 = z.infer + diff --git a/testdata/TestMapWithNonStringKey/time_key.golden b/testdata/TestMapWithNonStringKey/time_key.golden new file mode 100644 index 0000000..e74453d --- /dev/null +++ b/testdata/TestMapWithNonStringKey/time_key.golden @@ -0,0 +1,7 @@ +// @typecheck +export const Map2Schema = z.object({ + Name: z.string(), + Metadata: z.record(z.string(), z.string()).nullable(), +}) +export type Map2 = z.infer + diff --git a/testdata/TestMapWithStruct.golden b/testdata/TestMapWithStruct.golden new file mode 100644 index 0000000..0fe0476 --- /dev/null +++ b/testdata/TestMapWithStruct.golden @@ -0,0 +1,11 @@ +// @typecheck +export const PostWithMetaDataSchema = z.object({ + Title: z.string(), +}) +export type PostWithMetaData = z.infer + +export const UserSchema = z.object({ + MapWithStruct: z.record(z.string(), PostWithMetaDataSchema).nullable(), +}) +export type User = z.infer + diff --git a/testdata/TestMapWithValidations.golden b/testdata/TestMapWithValidations.golden new file mode 100644 index 0000000..6f1fd98 --- /dev/null +++ b/testdata/TestMapWithValidations.golden @@ -0,0 +1,61 @@ +// @typecheck +export const requiredSchema = z.object({ + value: z.record(z.string(), z.string()), +}) +export type required = z.infer + +export const minSchema = z.object({ + value: z.record(z.string(), z.string()).refine((val) => Object.keys(val).length >= 1, 'Map too small'), +}) +export type min = z.infer + +export const maxSchema = z.object({ + value: z.record(z.string(), z.string()).refine((val) => Object.keys(val).length <= 1, 'Map too large'), +}) +export type max = z.infer + +export const lenSchema = z.object({ + value: z.record(z.string(), z.string()).refine((val) => Object.keys(val).length === 1, 'Map wrong size'), +}) +export type len = z.infer + +export const minmaxSchema = z.object({ + value: z.record(z.string(), z.string()).refine((val) => Object.keys(val).length >= 1, 'Map too small').refine((val) => Object.keys(val).length <= 2, 'Map too large'), +}) +export type minmax = z.infer + +export const eqSchema = z.object({ + value: z.record(z.string(), z.string()).refine((val) => Object.keys(val).length === 1, 'Map wrong size'), +}) +export type eq = z.infer + +export const neSchema = z.object({ + value: z.record(z.string(), z.string()).refine((val) => Object.keys(val).length !== 1, 'Map wrong size'), +}) +export type ne = z.infer + +export const gtSchema = z.object({ + value: z.record(z.string(), z.string()).refine((val) => Object.keys(val).length > 1, 'Map too small'), +}) +export type gt = z.infer + +export const gteSchema = z.object({ + value: z.record(z.string(), z.string()).refine((val) => Object.keys(val).length >= 1, 'Map too small'), +}) +export type gte = z.infer + +export const ltSchema = z.object({ + value: z.record(z.string(), z.string()).refine((val) => Object.keys(val).length < 1, 'Map too large'), +}) +export type lt = z.infer + +export const lteSchema = z.object({ + value: z.record(z.string(), z.string()).refine((val) => Object.keys(val).length <= 1, 'Map too large'), +}) +export type lte = z.infer + +export const dive1Schema = z.object({ + value: z.record(z.string(), z.string().refine((val) => [...val].length >= 2, 'String must contain at least 2 character(s)')).nullable(), +}) +export type dive1 = z.infer + diff --git a/testdata/TestMapWithValidations/dive_nested.golden b/testdata/TestMapWithValidations/dive_nested.golden new file mode 100644 index 0000000..f285098 --- /dev/null +++ b/testdata/TestMapWithValidations/dive_nested.golden @@ -0,0 +1,11 @@ +// @typecheck +export const dive2Schema = z.object({ + value: z.record(z.string(), z.string().refine((val) => [...val].length >= 3, 'String must contain at least 3 character(s)')).refine((val) => Object.keys(val).length >= 2, 'Map too small').array(), +}) +export type dive2 = z.infer + +export const dive3Schema = z.object({ + value: z.record(z.string().refine((val) => [...val].length >= 3, 'String must contain at least 3 character(s)'), z.string().refine((val) => [...val].length <= 4, 'String must contain at most 4 character(s)')).refine((val) => Object.keys(val).length >= 2, 'Map too small').array(), +}) +export type dive3 = z.infer + diff --git a/testdata/TestNestedStruct/v3.golden b/testdata/TestNestedStruct/v3.golden new file mode 100644 index 0000000..0fb2f9e --- /dev/null +++ b/testdata/TestNestedStruct/v3.golden @@ -0,0 +1,17 @@ +// @zod-version: v3 +// @typecheck +export const HasIDSchema = z.object({ + ID: z.string(), +}) +export type HasID = z.infer + +export const HasNameSchema = z.object({ + name: z.string(), +}) +export type HasName = z.infer + +export const UserSchema = z.object({ + Tags: z.string().array().nullable(), +}).merge(HasIDSchema).merge(HasNameSchema) +export type User = z.infer + diff --git a/testdata/TestNestedStruct/v4.golden b/testdata/TestNestedStruct/v4.golden new file mode 100644 index 0000000..0605c86 --- /dev/null +++ b/testdata/TestNestedStruct/v4.golden @@ -0,0 +1,19 @@ +// @zod-version: v4 +// @typecheck +export const HasIDSchema = z.object({ + ID: z.string(), +}) +export type HasID = z.infer + +export const HasNameSchema = z.object({ + name: z.string(), +}) +export type HasName = z.infer + +export const UserSchema = z.object({ + ...HasIDSchema.shape, + ...HasNameSchema.shape, + Tags: z.string().array().nullable(), +}) +export type User = z.infer + diff --git a/testdata/TestNullableWithValidations.golden b/testdata/TestNullableWithValidations.golden new file mode 100644 index 0000000..f207104 --- /dev/null +++ b/testdata/TestNullableWithValidations.golden @@ -0,0 +1,36 @@ +// @typecheck +export const UserSchema = z.object({ + Name: z.string().min(1), + PtrMapOptionalNullable1: z.record(z.string(), z.any()).optional().nullable(), + PtrMapOptionalNullable2: z.record(z.string(), z.any()).refine((val) => Object.keys(val).length >= 2, 'Map too small').refine((val) => Object.keys(val).length <= 5, 'Map too large').optional().nullable(), + PtrMap1: z.record(z.string(), z.any()).refine((val) => Object.keys(val).length >= 2, 'Map too small').refine((val) => Object.keys(val).length <= 5, 'Map too large'), + PtrMap2: z.record(z.string(), z.any()).refine((val) => Object.keys(val).length >= 2, 'Map too small').refine((val) => Object.keys(val).length <= 5, 'Map too large'), + PtrMapNullable: z.record(z.string(), z.any()).refine((val) => Object.keys(val).length >= 2, 'Map too small').refine((val) => Object.keys(val).length <= 5, 'Map too large').nullable(), + MapOptional1: z.record(z.string(), z.any()).optional(), + MapOptional2: z.record(z.string(), z.any()).refine((val) => Object.keys(val).length >= 2, 'Map too small').refine((val) => Object.keys(val).length <= 5, 'Map too large').optional(), + Map1: z.record(z.string(), z.any()).refine((val) => Object.keys(val).length >= 2, 'Map too small').refine((val) => Object.keys(val).length <= 5, 'Map too large'), + Map2: z.record(z.string(), z.any()).refine((val) => Object.keys(val).length >= 2, 'Map too small').refine((val) => Object.keys(val).length <= 5, 'Map too large'), + MapNullable: z.record(z.string(), z.any()).refine((val) => Object.keys(val).length >= 2, 'Map too small').refine((val) => Object.keys(val).length <= 5, 'Map too large').nullable(), + PtrSliceOptionalNullable1: z.string().array().optional().nullable(), + PtrSliceOptionalNullable2: z.string().array().min(2).max(5).optional().nullable(), + PtrSlice1: z.string().array().min(2).max(5), + PtrSlice2: z.string().array().min(2).max(5), + PtrSliceNullable: z.string().array().min(2).max(5).nullable(), + SliceOptional1: z.string().array().optional(), + SliceOptional2: z.string().array().min(2).max(5).optional(), + Slice1: z.string().array().min(2).max(5), + Slice2: z.string().array().min(2).max(5), + SliceNullable: z.string().array().min(2).max(5).nullable(), + PtrIntOptional1: z.number().optional(), + PtrIntOptional2: z.number().gte(2).lte(5).optional(), + PtrInt1: z.number().gte(2).lte(5), + PtrInt2: z.number().gte(2).lte(5), + PtrIntNullable: z.number().gte(2).lte(5).nullable(), + PtrStringOptional1: z.string().optional(), + PtrStringOptional2: z.string().refine((val) => [...val].length >= 2, 'String must contain at least 2 character(s)').refine((val) => [...val].length <= 5, 'String must contain at most 5 character(s)').optional(), + PtrString1: z.string().refine((val) => [...val].length >= 2, 'String must contain at least 2 character(s)').refine((val) => [...val].length <= 5, 'String must contain at most 5 character(s)'), + PtrString2: z.string().refine((val) => [...val].length >= 2, 'String must contain at least 2 character(s)').refine((val) => [...val].length <= 5, 'String must contain at most 5 character(s)'), + PtrStringNullable: z.string().refine((val) => [...val].length >= 2, 'String must contain at least 2 character(s)').refine((val) => [...val].length <= 5, 'String must contain at most 5 character(s)').nullable(), +}) +export type User = z.infer + diff --git a/testdata/TestNumberValidations.golden b/testdata/TestNumberValidations.golden new file mode 100644 index 0000000..53d7d3a --- /dev/null +++ b/testdata/TestNumberValidations.golden @@ -0,0 +1,36 @@ +// @typecheck +export const gte_lteSchema = z.object({ + value: z.number().gte(18).lte(60), +}) +export type gte_lte = z.infer + +export const gt_ltSchema = z.object({ + value: z.number().gt(18).lt(60), +}) +export type gt_lt = z.infer + +export const eqSchema = z.object({ + value: z.number().refine((val) => val === 18), +}) +export type eq = z.infer + +export const neSchema = z.object({ + value: z.number().refine((val) => val !== 18), +}) +export type ne = z.infer + +export const oneofSchema = z.object({ + value: z.number().refine((val) => [18, 19, 20].includes(val)), +}) +export type oneof = z.infer + +export const min_maxSchema = z.object({ + value: z.number().gte(18).lte(60), +}) +export type min_max = z.infer + +export const lenSchema = z.object({ + value: z.number().refine((val) => val === 18), +}) +export type len = z.infer + diff --git a/testdata/TestOmitZero.golden b/testdata/TestOmitZero.golden new file mode 100644 index 0000000..602cd0f --- /dev/null +++ b/testdata/TestOmitZero.golden @@ -0,0 +1,8 @@ +// @typecheck +export const PayloadSchema = z.object({ + Name: z.string(), + Nickname: z.string().optional(), + Email: z.string().optional(), +}) +export type Payload = z.infer + diff --git a/testdata/TestOneofRequired.golden b/testdata/TestOneofRequired.golden new file mode 100644 index 0000000..a18d223 --- /dev/null +++ b/testdata/TestOneofRequired.golden @@ -0,0 +1,8 @@ +// @typecheck +export const PayloadSchema = z.object({ + status: z.enum(["active", "inactive"] as const), + statusImplicitRequired: z.enum(["active", "inactive"] as const), + channel: z.enum(["email", "sms"] as const).optional(), +}) +export type Payload = z.infer + diff --git a/testdata/TestRecursive1/v3.golden b/testdata/TestRecursive1/v3.golden new file mode 100644 index 0000000..e674ab4 --- /dev/null +++ b/testdata/TestRecursive1/v3.golden @@ -0,0 +1,20 @@ +// @zod-version: v3 +// @typecheck +export type NestedItem = { + id: number, + title: string, + pos: number, + parent_id: number, + project_id: number, + children: NestedItem[] | null, +} +const NestedItemSchemaShape = { + id: z.number(), + title: z.string(), + pos: z.number(), + parent_id: z.number(), + project_id: z.number(), + children: z.lazy(() => NestedItemSchema).array().nullable(), +} +export const NestedItemSchema: z.ZodType = z.object(NestedItemSchemaShape) + diff --git a/testdata/TestRecursive1/v4.golden b/testdata/TestRecursive1/v4.golden new file mode 100644 index 0000000..26d07cb --- /dev/null +++ b/testdata/TestRecursive1/v4.golden @@ -0,0 +1,20 @@ +// @zod-version: v4 +// @typecheck +export type NestedItem = { + id: number, + title: string, + pos: number, + parent_id: number, + project_id: number, + children: NestedItem[] | null, +} +const NestedItemSchemaShape = { + id: z.number(), + title: z.string(), + pos: z.number(), + parent_id: z.number(), + project_id: z.number(), + get children() { return NestedItemSchema.array().nullable(); }, +} +export const NestedItemSchema: z.ZodType = z.object(NestedItemSchemaShape) + diff --git a/testdata/TestRecursive2/v3.golden b/testdata/TestRecursive2/v3.golden new file mode 100644 index 0000000..be726d5 --- /dev/null +++ b/testdata/TestRecursive2/v3.golden @@ -0,0 +1,17 @@ +// @zod-version: v3 +// @typecheck +export type Node = { + value: number, + next: Node | null, +} +const NodeSchemaShape = { + value: z.number(), + next: z.lazy(() => NodeSchema).nullable(), +} +export const NodeSchema: z.ZodType = z.object(NodeSchemaShape) + +export const ParentSchema = z.object({ + child: NodeSchema.nullable(), +}) +export type Parent = z.infer + diff --git a/testdata/TestRecursive2/v4.golden b/testdata/TestRecursive2/v4.golden new file mode 100644 index 0000000..f479004 --- /dev/null +++ b/testdata/TestRecursive2/v4.golden @@ -0,0 +1,17 @@ +// @zod-version: v4 +// @typecheck +export type Node = { + value: number, + next: Node | null, +} +const NodeSchemaShape = { + value: z.number(), + get next() { return NodeSchema.nullable(); }, +} +export const NodeSchema: z.ZodType = z.object(NodeSchemaShape) + +export const ParentSchema = z.object({ + child: NodeSchema.nullable(), +}) +export type Parent = z.infer + diff --git a/testdata/TestRecursiveEmbeddedStruct/v3.golden b/testdata/TestRecursiveEmbeddedStruct/v3.golden new file mode 100644 index 0000000..34cf577 --- /dev/null +++ b/testdata/TestRecursiveEmbeddedStruct/v3.golden @@ -0,0 +1,41 @@ +// @zod-version: v3 +// @typecheck +export type ItemA = { + Name: string, + Children: ItemA[] | null, +} +const ItemASchemaShape = { + Name: z.string(), + Children: z.lazy(() => ItemASchema).array().nullable(), +} +export const ItemASchema: z.ZodType = z.object(ItemASchemaShape) + +export const ItemBSchema = z.object({ + ...ItemASchemaShape, +}) +export type ItemB = z.infer + +export const ItemCSchema = z.object({ +}).merge(ItemBSchema) +export type ItemC = z.infer + +export const ItemDSchema = z.object({ + ItemA: ItemASchema, +}) +export type ItemD = z.infer + +export type ItemE = Omit & ItemD & { + Children: ItemE[] | null, +} +const ItemESchemaShape = { + ...ItemASchemaShape, + ...ItemDSchema.shape, + Children: z.lazy(() => ItemESchema).array().nullable(), +} +export const ItemESchema: z.ZodType = z.object(ItemESchemaShape) + +export const ItemFSchema = z.object({ + ...ItemESchemaShape, +}) +export type ItemF = z.infer + diff --git a/testdata/TestRecursiveEmbeddedStruct/v4.golden b/testdata/TestRecursiveEmbeddedStruct/v4.golden new file mode 100644 index 0000000..5617b0c --- /dev/null +++ b/testdata/TestRecursiveEmbeddedStruct/v4.golden @@ -0,0 +1,42 @@ +// @zod-version: v4 +// @typecheck +export type ItemA = { + Name: string, + Children: ItemA[] | null, +} +const ItemASchemaShape = { + Name: z.string(), + get Children() { return ItemASchema.array().nullable(); }, +} +export const ItemASchema: z.ZodType = z.object(ItemASchemaShape) + +export const ItemBSchema = z.object({ + ...ItemASchemaShape, +}) +export type ItemB = z.infer + +export const ItemCSchema = z.object({ + ...ItemBSchema.shape, +}) +export type ItemC = z.infer + +export const ItemDSchema = z.object({ + ItemA: ItemASchema, +}) +export type ItemD = z.infer + +export type ItemE = Omit & ItemD & { + Children: ItemE[] | null, +} +const ItemESchemaShape = { + ...ItemASchemaShape, + ...ItemDSchema.shape, + get Children() { return ItemESchema.array().nullable(); }, +} +export const ItemESchema: z.ZodType = z.object(ItemESchemaShape) + +export const ItemFSchema = z.object({ + ...ItemESchemaShape, +}) +export type ItemF = z.infer + diff --git a/testdata/TestRecursiveEmbeddedWithPointersAndDates/embedded_struct_with_pointer_to_self_and_date/v3.golden b/testdata/TestRecursiveEmbeddedWithPointersAndDates/embedded_struct_with_pointer_to_self_and_date/v3.golden new file mode 100644 index 0000000..5ab4f4c --- /dev/null +++ b/testdata/TestRecursiveEmbeddedWithPointersAndDates/embedded_struct_with_pointer_to_self_and_date/v3.golden @@ -0,0 +1,20 @@ +// @zod-version: v3 +// @typecheck +export type Comment = { + Text: string, + Timestamp: Date, + Reply: Comment | null, +} +const CommentSchemaShape = { + Text: z.string(), + Timestamp: z.coerce.date(), + Reply: z.lazy(() => CommentSchema).nullable(), +} +export const CommentSchema: z.ZodType = z.object(CommentSchemaShape) + +export const ArticleSchema = z.object({ + ...CommentSchemaShape, + Title: z.string(), +}) +export type Article = z.infer + diff --git a/testdata/TestRecursiveEmbeddedWithPointersAndDates/embedded_struct_with_pointer_to_self_and_date/v4.golden b/testdata/TestRecursiveEmbeddedWithPointersAndDates/embedded_struct_with_pointer_to_self_and_date/v4.golden new file mode 100644 index 0000000..415b304 --- /dev/null +++ b/testdata/TestRecursiveEmbeddedWithPointersAndDates/embedded_struct_with_pointer_to_self_and_date/v4.golden @@ -0,0 +1,20 @@ +// @zod-version: v4 +// @typecheck +export type Comment = { + Text: string, + Timestamp: Date, + Reply: Comment | null, +} +const CommentSchemaShape = { + Text: z.string(), + Timestamp: z.coerce.date(), + get Reply() { return CommentSchema.nullable(); }, +} +export const CommentSchema: z.ZodType = z.object(CommentSchemaShape) + +export const ArticleSchema = z.object({ + ...CommentSchemaShape, + Title: z.string(), +}) +export type Article = z.infer + diff --git a/testdata/TestRecursiveEmbeddedWithPointersAndDates/recursive_struct_with_pointer_field_and_date/v3.golden b/testdata/TestRecursiveEmbeddedWithPointersAndDates/recursive_struct_with_pointer_field_and_date/v3.golden new file mode 100644 index 0000000..fccf80b --- /dev/null +++ b/testdata/TestRecursiveEmbeddedWithPointersAndDates/recursive_struct_with_pointer_field_and_date/v3.golden @@ -0,0 +1,20 @@ +// @zod-version: v3 +// @typecheck +export type TreeNode = { + Value: string, + CreatedAt: Date, + Children: TreeNode[] | null, +} +const TreeNodeSchemaShape = { + Value: z.string(), + CreatedAt: z.coerce.date(), + Children: z.lazy(() => TreeNodeSchema).array().nullable(), +} +export const TreeNodeSchema: z.ZodType = z.object(TreeNodeSchemaShape) + +export const TreeSchema = z.object({ + ...TreeNodeSchemaShape, + UpdatedAt: z.coerce.date(), +}) +export type Tree = z.infer + diff --git a/testdata/TestRecursiveEmbeddedWithPointersAndDates/recursive_struct_with_pointer_field_and_date/v4.golden b/testdata/TestRecursiveEmbeddedWithPointersAndDates/recursive_struct_with_pointer_field_and_date/v4.golden new file mode 100644 index 0000000..06a670d --- /dev/null +++ b/testdata/TestRecursiveEmbeddedWithPointersAndDates/recursive_struct_with_pointer_field_and_date/v4.golden @@ -0,0 +1,20 @@ +// @zod-version: v4 +// @typecheck +export type TreeNode = { + Value: string, + CreatedAt: Date, + Children: TreeNode[] | null, +} +const TreeNodeSchemaShape = { + Value: z.string(), + CreatedAt: z.coerce.date(), + get Children() { return TreeNodeSchema.array().nullable(); }, +} +export const TreeNodeSchema: z.ZodType = z.object(TreeNodeSchemaShape) + +export const TreeSchema = z.object({ + ...TreeNodeSchemaShape, + UpdatedAt: z.coerce.date(), +}) +export type Tree = z.infer + diff --git a/testdata/TestSliceFields.golden b/testdata/TestSliceFields.golden new file mode 100644 index 0000000..2a5dfe2 --- /dev/null +++ b/testdata/TestSliceFields.golden @@ -0,0 +1,12 @@ +// @typecheck +export const TestSliceFieldsStructSchema = z.object({ + NoValidate: z.number().array().nullable(), + Required: z.number().array(), + Min: z.number().array().min(1), + OmitEmpty: z.number().array().nullable(), + JSONOmitEmpty: z.number().array().optional(), + MinOmitEmpty: z.number().array().min(1).nullable(), + JSONMinOmitEmpty: z.number().array().min(1).optional(), +}) +export type TestSliceFieldsStruct = z.infer + diff --git a/testdata/TestStringArray.golden b/testdata/TestStringArray.golden new file mode 100644 index 0000000..6ed1cb2 --- /dev/null +++ b/testdata/TestStringArray.golden @@ -0,0 +1,6 @@ +// @typecheck +export const UserSchema = z.object({ + Tags: z.string().array().nullable(), +}) +export type User = z.infer + diff --git a/testdata/TestStringArrayNullable.golden b/testdata/TestStringArrayNullable.golden new file mode 100644 index 0000000..a7ae5c7 --- /dev/null +++ b/testdata/TestStringArrayNullable.golden @@ -0,0 +1,7 @@ +// @typecheck +export const UserSchema = z.object({ + Name: z.string(), + Tags: z.string().array().nullable(), +}) +export type User = z.infer + diff --git a/testdata/TestStringNestedArray.golden b/testdata/TestStringNestedArray.golden new file mode 100644 index 0000000..2c39c32 --- /dev/null +++ b/testdata/TestStringNestedArray.golden @@ -0,0 +1,6 @@ +// @typecheck +export const UserSchema = z.object({ + TagPairs: z.string().array().length(2).array().nullable(), +}) +export type User = z.infer + diff --git a/testdata/TestStringNullable.golden b/testdata/TestStringNullable.golden new file mode 100644 index 0000000..a9d3f5f --- /dev/null +++ b/testdata/TestStringNullable.golden @@ -0,0 +1,7 @@ +// @typecheck +export const UserSchema = z.object({ + Name: z.string(), + Nickname: z.string().nullable(), +}) +export type User = z.infer + diff --git a/testdata/TestStringOptional.golden b/testdata/TestStringOptional.golden new file mode 100644 index 0000000..d44d0aa --- /dev/null +++ b/testdata/TestStringOptional.golden @@ -0,0 +1,7 @@ +// @typecheck +export const UserSchema = z.object({ + Name: z.string(), + Nickname: z.string().optional(), +}) +export type User = z.infer + diff --git a/testdata/TestStringOptionalNotNullable.golden b/testdata/TestStringOptionalNotNullable.golden new file mode 100644 index 0000000..d44d0aa --- /dev/null +++ b/testdata/TestStringOptionalNotNullable.golden @@ -0,0 +1,7 @@ +// @typecheck +export const UserSchema = z.object({ + Name: z.string(), + Nickname: z.string().optional(), +}) +export type User = z.infer + diff --git a/testdata/TestStringOptionalNullable.golden b/testdata/TestStringOptionalNullable.golden new file mode 100644 index 0000000..f2833e3 --- /dev/null +++ b/testdata/TestStringOptionalNullable.golden @@ -0,0 +1,7 @@ +// @typecheck +export const UserSchema = z.object({ + Name: z.string(), + Nickname: z.string().optional().nullable(), +}) +export type User = z.infer + diff --git a/testdata/TestStringValidations.golden b/testdata/TestStringValidations.golden new file mode 100644 index 0000000..8d4d20b Binary files /dev/null and b/testdata/TestStringValidations.golden differ diff --git a/testdata/TestStringValidations/enum_ignores_other_validators.golden b/testdata/TestStringValidations/enum_ignores_other_validators.golden new file mode 100644 index 0000000..a22d98d --- /dev/null +++ b/testdata/TestStringValidations/enum_ignores_other_validators.golden @@ -0,0 +1,26 @@ +// @typecheck +export const RequiredOneofSchema = z.object({ + v: z.enum(["a", "b"] as const), +}) +export type RequiredOneof = z.infer + +export const OneofContainsSchema = z.object({ + v: z.enum(["a", "b"] as const), +}) +export type OneofContains = z.infer + +export const OneofStartswithSchema = z.object({ + v: z.enum(["a", "b"] as const), +}) +export type OneofStartswith = z.infer + +export const OneofEndswithSchema = z.object({ + v: z.enum(["a", "b"] as const), +}) +export type OneofEndswith = z.infer + +export const OneofIpSchema = z.object({ + v: z.enum(["127.0.0.1", "::1"] as const), +}) +export type OneofIp = z.infer + diff --git a/testdata/TestStringValidations/special_chars_in_tag_values_are_escaped_in_output.golden b/testdata/TestStringValidations/special_chars_in_tag_values_are_escaped_in_output.golden new file mode 100644 index 0000000..6cc3d8b --- /dev/null +++ b/testdata/TestStringValidations/special_chars_in_tag_values_are_escaped_in_output.golden @@ -0,0 +1,11 @@ +// @typecheck +export const ContainsQuoteSchema = z.object({ + value: z.string().includes("foo\"bar"), +}) +export type ContainsQuote = z.infer + +export const EqBackslashSchema = z.object({ + value: z.string().refine((val) => val === "a\\b"), +}) +export type EqBackslash = z.infer + diff --git a/testdata/TestStringValidations/string_tag_order_is_preserved_around_v4_format_helpers/v3.golden b/testdata/TestStringValidations/string_tag_order_is_preserved_around_v4_format_helpers/v3.golden new file mode 100644 index 0000000..b3600f8 --- /dev/null +++ b/testdata/TestStringValidations/string_tag_order_is_preserved_around_v4_format_helpers/v3.golden @@ -0,0 +1,8 @@ +// @zod-version: v3 +// @typecheck +export const PayloadSchema = z.object({ + TrimmedThenEmail: z.string().trim().email(), + EmailThenTrimmed: z.string().email().trim(), +}) +export type Payload = z.infer + diff --git a/testdata/TestStringValidations/string_tag_order_is_preserved_around_v4_format_helpers/v4.golden b/testdata/TestStringValidations/string_tag_order_is_preserved_around_v4_format_helpers/v4.golden new file mode 100644 index 0000000..3e5ad3f --- /dev/null +++ b/testdata/TestStringValidations/string_tag_order_is_preserved_around_v4_format_helpers/v4.golden @@ -0,0 +1,8 @@ +// @zod-version: v4 +// @typecheck +export const PayloadSchema = z.object({ + TrimmedThenEmail: z.string().trim().check(z.email()), + EmailThenTrimmed: z.string().check(z.email()).trim(), +}) +export type Payload = z.infer + diff --git a/testdata/TestStructSimple.golden b/testdata/TestStructSimple.golden new file mode 100644 index 0000000..97a833b --- /dev/null +++ b/testdata/TestStructSimple.golden @@ -0,0 +1,8 @@ +// @typecheck +export const UserSchema = z.object({ + Name: z.string(), + Age: z.number(), + Height: z.number(), +}) +export type User = z.infer + diff --git a/testdata/TestStructSimplePrefix.golden b/testdata/TestStructSimplePrefix.golden new file mode 100644 index 0000000..3b5a67c --- /dev/null +++ b/testdata/TestStructSimplePrefix.golden @@ -0,0 +1,8 @@ +// @typecheck +export const BotUserSchema = z.object({ + Name: z.string(), + Age: z.number(), + Height: z.number(), +}) +export type BotUser = z.infer + diff --git a/testdata/TestStructSimpleWithOmittedField.golden b/testdata/TestStructSimpleWithOmittedField.golden new file mode 100644 index 0000000..97a833b --- /dev/null +++ b/testdata/TestStructSimpleWithOmittedField.golden @@ -0,0 +1,8 @@ +// @typecheck +export const UserSchema = z.object({ + Name: z.string(), + Age: z.number(), + Height: z.number(), +}) +export type User = z.infer + diff --git a/testdata/TestStructSlice.golden b/testdata/TestStructSlice.golden new file mode 100644 index 0000000..93c7153 --- /dev/null +++ b/testdata/TestStructSlice.golden @@ -0,0 +1,8 @@ +// @typecheck +export const UserSchema = z.object({ + Favourites: z.object({ + Name: z.string(), + }).array().nullable(), +}) +export type User = z.infer + diff --git a/testdata/TestStructSliceOptional.golden b/testdata/TestStructSliceOptional.golden new file mode 100644 index 0000000..5a4c6d7 --- /dev/null +++ b/testdata/TestStructSliceOptional.golden @@ -0,0 +1,8 @@ +// @typecheck +export const UserSchema = z.object({ + Favourites: z.object({ + Name: z.string(), + }).array().optional(), +}) +export type User = z.infer + diff --git a/testdata/TestStructSliceOptionalNullable.golden b/testdata/TestStructSliceOptionalNullable.golden new file mode 100644 index 0000000..6d50465 --- /dev/null +++ b/testdata/TestStructSliceOptionalNullable.golden @@ -0,0 +1,8 @@ +// @typecheck +export const UserSchema = z.object({ + Favourites: z.object({ + Name: z.string(), + }).array().optional().nullable(), +}) +export type User = z.infer + diff --git a/testdata/TestStructTime.golden b/testdata/TestStructTime.golden new file mode 100644 index 0000000..cb6ca7d --- /dev/null +++ b/testdata/TestStructTime.golden @@ -0,0 +1,7 @@ +// @typecheck +export const UserSchema = z.object({ + Name: z.string(), + When: z.coerce.date(), +}) +export type User = z.infer + diff --git a/testdata/TestTimeWithRequired.golden b/testdata/TestTimeWithRequired.golden new file mode 100644 index 0000000..c2e6824 --- /dev/null +++ b/testdata/TestTimeWithRequired.golden @@ -0,0 +1,6 @@ +// @typecheck +export const UserSchema = z.object({ + When: z.coerce.date().refine((val) => val.getTime() !== new Date('0001-01-01T00:00:00Z').getTime() && val.getTime() !== new Date(0).getTime(), 'Invalid date'), +}) +export type User = z.infer + diff --git a/testdata/TestWithIgnoreTags/ignores_specified_tag.golden b/testdata/TestWithIgnoreTags/ignores_specified_tag.golden new file mode 100644 index 0000000..d012f70 --- /dev/null +++ b/testdata/TestWithIgnoreTags/ignores_specified_tag.golden @@ -0,0 +1,6 @@ +// @typecheck +export const UserSchema = z.object({ + Name: z.string().min(1), +}) +export type User = z.infer + diff --git a/testdata/TestZodV4Defaults/custom_tag_before_required_base64_preserves_min(1).golden b/testdata/TestZodV4Defaults/custom_tag_before_required_base64_preserves_min(1).golden new file mode 100644 index 0000000..a203d82 --- /dev/null +++ b/testdata/TestZodV4Defaults/custom_tag_before_required_base64_preserves_min(1).golden @@ -0,0 +1,8 @@ +// @zod-version: v4 +// @typecheck +export const PayloadSchema = z.object({ + Data: z.string().trim().min(1).check(z.base64()), + Hex: z.string().trim().min(1).check(z.hex()), +}) +export type Payload = z.infer + diff --git a/testdata/TestZodV4Defaults/embedded_structs_use_shape_spreads.golden b/testdata/TestZodV4Defaults/embedded_structs_use_shape_spreads.golden new file mode 100644 index 0000000..0605c86 --- /dev/null +++ b/testdata/TestZodV4Defaults/embedded_structs_use_shape_spreads.golden @@ -0,0 +1,19 @@ +// @zod-version: v4 +// @typecheck +export const HasIDSchema = z.object({ + ID: z.string(), +}) +export type HasID = z.infer + +export const HasNameSchema = z.object({ + name: z.string(), +}) +export type HasName = z.infer + +export const UserSchema = z.object({ + ...HasIDSchema.shape, + ...HasNameSchema.shape, + Tags: z.string().array().nullable(), +}) +export type User = z.infer + diff --git a/testdata/TestZodV4Defaults/ip_unions_inherit_generic_string_constraints.golden b/testdata/TestZodV4Defaults/ip_unions_inherit_generic_string_constraints.golden new file mode 100644 index 0000000..a714864 --- /dev/null +++ b/testdata/TestZodV4Defaults/ip_unions_inherit_generic_string_constraints.golden @@ -0,0 +1,7 @@ +// @zod-version: v4 +// @typecheck +export const PayloadSchema = z.object({ + Address: z.union([z.string().check(z.ipv4()).min(1).refine((val) => [...val].length <= 45, 'String must contain at most 45 character(s)'), z.string().check(z.ipv6()).min(1).refine((val) => [...val].length <= 45, 'String must contain at most 45 character(s)')]), +}) +export type Payload = z.infer + diff --git a/testdata/TestZodV4Defaults/named_field_shadows_embedded_field/v3.golden b/testdata/TestZodV4Defaults/named_field_shadows_embedded_field/v3.golden new file mode 100644 index 0000000..7a572f0 --- /dev/null +++ b/testdata/TestZodV4Defaults/named_field_shadows_embedded_field/v3.golden @@ -0,0 +1,13 @@ +// @zod-version: v3 +// @typecheck +export const BaseSchema = z.object({ + id: z.string(), + name: z.string(), +}) +export type Base = z.infer + +export const ChildSchema = z.object({ + id: z.number(), +}).merge(BaseSchema) +export type Child = z.infer + diff --git a/testdata/TestZodV4Defaults/named_field_shadows_embedded_field/v4.golden b/testdata/TestZodV4Defaults/named_field_shadows_embedded_field/v4.golden new file mode 100644 index 0000000..6ebcc95 --- /dev/null +++ b/testdata/TestZodV4Defaults/named_field_shadows_embedded_field/v4.golden @@ -0,0 +1,14 @@ +// @zod-version: v4 +// @typecheck +export const BaseSchema = z.object({ + id: z.string(), + name: z.string(), +}) +export type Base = z.infer + +export const ChildSchema = z.object({ + ...BaseSchema.shape, + id: z.number(), +}) +export type Child = z.infer + diff --git a/testdata/TestZodV4Defaults/optional_format_with_nullable_pointer/v3.golden b/testdata/TestZodV4Defaults/optional_format_with_nullable_pointer/v3.golden new file mode 100644 index 0000000..64f34fb --- /dev/null +++ b/testdata/TestZodV4Defaults/optional_format_with_nullable_pointer/v3.golden @@ -0,0 +1,7 @@ +// @zod-version: v3 +// @typecheck +export const PayloadSchema = z.object({ + email: z.string().email().nullable(), +}) +export type Payload = z.infer + diff --git a/testdata/TestZodV4Defaults/optional_format_with_nullable_pointer/v4.golden b/testdata/TestZodV4Defaults/optional_format_with_nullable_pointer/v4.golden new file mode 100644 index 0000000..ed5ed5a --- /dev/null +++ b/testdata/TestZodV4Defaults/optional_format_with_nullable_pointer/v4.golden @@ -0,0 +1,7 @@ +// @zod-version: v4 +// @typecheck +export const PayloadSchema = z.object({ + email: z.string().check(z.email()).nullable(), +}) +export type Payload = z.infer + diff --git a/testdata/TestZodV4Defaults/recursive_embedded_shapes_keep_named_fields_after_spreads_to_override_embedded_fields.golden b/testdata/TestZodV4Defaults/recursive_embedded_shapes_keep_named_fields_after_spreads_to_override_embedded_fields.golden new file mode 100644 index 0000000..2ea46b2 --- /dev/null +++ b/testdata/TestZodV4Defaults/recursive_embedded_shapes_keep_named_fields_after_spreads_to_override_embedded_fields.golden @@ -0,0 +1,22 @@ +// @zod-version: v4 +// @typecheck +export type TreeNode = { + Value: string, + CreatedAt: Date, + Children: TreeNode[] | null, + UpdatedAt: string, +} +const TreeNodeSchemaShape = { + Value: z.string(), + CreatedAt: z.coerce.date(), + get Children() { return TreeNodeSchema.array().nullable(); }, + UpdatedAt: z.string(), +} +export const TreeNodeSchema: z.ZodType = z.object(TreeNodeSchemaShape) + +export const TreeSchema = z.object({ + ...TreeNodeSchemaShape, + UpdatedAt: z.coerce.date(), +}) +export type Tree = z.infer + diff --git a/testdata/TestZodV4Defaults/recursive_embedded_shapes_preserve_encounter_order_for_duplicate_keys.golden b/testdata/TestZodV4Defaults/recursive_embedded_shapes_preserve_encounter_order_for_duplicate_keys.golden new file mode 100644 index 0000000..27e4d4d --- /dev/null +++ b/testdata/TestZodV4Defaults/recursive_embedded_shapes_preserve_encounter_order_for_duplicate_keys.golden @@ -0,0 +1,18 @@ +// @zod-version: v4 +// @typecheck +export const BaseSchema = z.object({ + id: z.string(), +}) +export type Base = z.infer + +export type Node = Omit & { + id: number, + next: Node | null, +} +const NodeSchemaShape = { + ...BaseSchema.shape, + id: z.number(), + get next() { return NodeSchema.nullable(); }, +} +export const NodeSchema: z.ZodType = z.object(NodeSchemaShape) + diff --git a/testdata/TestZodV4Defaults/string_formats_use_zod_v4_builders.golden b/testdata/TestZodV4Defaults/string_formats_use_zod_v4_builders.golden new file mode 100644 index 0000000..f3bbb37 --- /dev/null +++ b/testdata/TestZodV4Defaults/string_formats_use_zod_v4_builders.golden @@ -0,0 +1,11 @@ +// @zod-version: v4 +// @typecheck +export const PayloadSchema = z.object({ + Email: z.string().check(z.email()), + Link: z.string().check(z.httpUrl()), + Base64: z.string().check(z.base64()), + ID: z.string().check(z.uuid({ version: "v4" })), + Checksum: z.string().check(z.hash("md5")), +}) +export type Payload = z.infer + diff --git a/tests/cases.ts b/tests/cases.ts new file mode 100644 index 0000000..c24b782 --- /dev/null +++ b/tests/cases.ts @@ -0,0 +1,1886 @@ +/** + * Runtime test cases for golden file schemas. + * + * Each case references a golden file, a schema name exported from it, + * test input, whether parsing should succeed, and the expected output. + * + * Golden files are copied into the Docker test environment by docker-typecheck.sh. + * The import paths here are relative to the test runner's location in the container. + */ + +export interface TestCase { + /** Description of what this test verifies */ + name: string; + /** Path to golden file relative to testdata/, or a directory containing v3.golden */ + golden: string; + /** Name of the exported schema to test (e.g. "UserSchema") */ + schema: string; + /** Input to pass to schema.safeParse() */ + input: unknown; + /** Whether parsing should succeed */ + success: boolean; + /** Expected output after parsing (only checked if success=true, if not provided expected output will be the same as the input) */ + output?: unknown; +} + +export const cases: TestCase[] = [ + // --------------------------------------------------------------------------- + // SIMPLE STRUCTS + // --------------------------------------------------------------------------- + + // --- TestStructSimple --- + { + name: "simple struct: parses valid object", + golden: "TestStructSimple.golden", + schema: "UserSchema", + input: { Name: "John", Age: 30, Height: 5.9 }, + success: true, + }, + { + name: "simple struct: rejects type error (string for Age)", + golden: "TestStructSimple.golden", + schema: "UserSchema", + input: { Name: "John", Age: "thirty", Height: 5.9 }, + success: false, + }, + + // --- TestStructSimplePrefix --- + { + name: "simple struct prefix: parses valid BotUser", + golden: "TestStructSimplePrefix.golden", + schema: "BotUserSchema", + input: { Name: "Bot", Age: 1, Height: 3.0 }, + success: true, + }, + + // --- TestStructSimpleWithOmittedField --- + { + name: "omitted field: parses valid object (omitted field not in schema)", + golden: "TestStructSimpleWithOmittedField.golden", + schema: "UserSchema", + input: { Name: "John", Age: 30, Height: 5.9 }, + success: true, + }, + + // --- TestStringOptional --- + { + name: "string optional: parses with Nickname present", + golden: "TestStringOptional.golden", + schema: "UserSchema", + input: { Name: "John", Nickname: "Johnny" }, + success: true, + }, + { + name: "string optional: parses without Nickname (undefined)", + golden: "TestStringOptional.golden", + schema: "UserSchema", + input: { Name: "John" }, + success: true, + }, + + // --- TestStringNullable --- + { + name: "string nullable: parses with null Nickname", + golden: "TestStringNullable.golden", + schema: "UserSchema", + input: { Name: "John", Nickname: null }, + success: true, + }, + + // --- TestStringOptionalNotNullable --- + { + name: "string optional not nullable: parses with undefined Nickname", + golden: "TestStringOptionalNotNullable.golden", + schema: "UserSchema", + input: { Name: "John" }, + success: true, + }, + + // --- TestStringOptionalNullable --- + { + name: "string optional nullable: parses with null Nickname", + golden: "TestStringOptionalNullable.golden", + schema: "UserSchema", + input: { Name: "John", Nickname: null }, + success: true, + }, + { + name: "string optional nullable: parses with undefined Nickname", + golden: "TestStringOptionalNullable.golden", + schema: "UserSchema", + input: { Name: "John" }, + success: true, + }, + + // --- TestDuration --- + { + name: "duration: parses valid number", + golden: "TestDuration.golden", + schema: "UserSchema", + input: { HowLong: 3600 }, + success: true, + }, + + // --- TestStructTime --- + { + name: "time: parses ISO string to Date", + golden: "TestStructTime.golden", + schema: "UserSchema", + input: { Name: "John", When: "2021-01-01T00:00:00Z" }, + success: true, + output: { Name: "John", When: new Date("2021-01-01T00:00:00Z") }, + }, + { + name: "time: parses unix timestamp to Date", + golden: "TestStructTime.golden", + schema: "UserSchema", + input: { Name: "John", When: 1609459200000 }, + success: true, + output: { Name: "John", When: new Date("2021-01-01T00:00:00Z") }, + }, + { + name: "time: coerces null to epoch Date", + golden: "TestStructTime.golden", + schema: "UserSchema", + input: { Name: "John", When: null }, + success: true, + output: { Name: "John", When: new Date(0) }, + }, + { + name: "time: parses zero date string", + golden: "TestStructTime.golden", + schema: "UserSchema", + input: { Name: "John", When: "0001-01-01T00:00:00Z" }, + success: true, + output: { Name: "John", When: new Date("0001-01-01T00:00:00Z") }, + }, + { + name: "time: rejects empty string", + golden: "TestStructTime.golden", + schema: "UserSchema", + input: { Name: "John", When: "" }, + success: false, + }, + + // --- TestTimeWithRequired --- + { + name: "required time: parses valid date", + golden: "TestTimeWithRequired.golden", + schema: "UserSchema", + input: { When: "2021-01-01T00:00:00Z" }, + success: true, + output: { When: new Date("2021-01-01T00:00:00Z") }, + }, + { + name: "required time: parses unix timestamp", + golden: "TestTimeWithRequired.golden", + schema: "UserSchema", + input: { When: 1609459200000 }, + success: true, + output: { When: new Date("2021-01-01T00:00:00Z") }, + }, + { + name: "required time: rejects null (zero date)", + golden: "TestTimeWithRequired.golden", + schema: "UserSchema", + input: { When: null }, + success: false, + }, + { + name: "required time: rejects zero date string", + golden: "TestTimeWithRequired.golden", + schema: "UserSchema", + input: { When: "0001-01-01T00:00:00Z" }, + success: false, + }, + { + name: "required time: rejects empty string", + golden: "TestTimeWithRequired.golden", + schema: "UserSchema", + input: { When: "" }, + success: false, + }, + + // --------------------------------------------------------------------------- + // ARRAYS + // --------------------------------------------------------------------------- + + // --- TestStringArray --- + { + name: "string array: parses valid array", + golden: "TestStringArray.golden", + schema: "UserSchema", + input: { Tags: ["a", "b", "c"] }, + success: true, + }, + { + name: "string array: parses null", + golden: "TestStringArray.golden", + schema: "UserSchema", + input: { Tags: null }, + success: true, + }, + + // --- TestStringArrayNullable --- + { + name: "string array nullable: parses valid array", + golden: "TestStringArrayNullable.golden", + schema: "UserSchema", + input: { Name: "John", Tags: ["x"] }, + success: true, + }, + + // --- TestStringNestedArray --- + { + name: "nested array: parses valid nested array (inner length 2)", + golden: "TestStringNestedArray.golden", + schema: "UserSchema", + input: { + TagPairs: [ + ["a", "b"], + ["c", "d"], + ], + }, + success: true, + }, + + // --- TestConvertArray/single --- + { + name: "fixed array: parses array of length 10", + golden: "TestConvertArray/single.golden", + schema: "ArraySchema", + input: { Arr: ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"] }, + success: true, + }, + { + name: "fixed array: rejects wrong count", + golden: "TestConvertArray/single.golden", + schema: "ArraySchema", + input: { Arr: ["a", "b"] }, + success: false, + }, + + // --- TestConvertArray/multi --- + { + name: "multi-dim array: parses valid 3D array", + golden: "TestConvertArray/multi.golden", + schema: "MultiArraySchema", + input: { + Arr: Array.from({ length: 10 }, () => + Array.from({ length: 20 }, () => Array.from({ length: 30 }, () => "x")), + ), + }, + success: true, + }, + + // --- TestConvertSlice --- + { + name: "convert slice: ZipSchema with valid Foo", + golden: "TestConvertSlice.golden", + schema: "ZipSchema", + input: { Zap: { Bar: "a", Baz: "b", Quz: "c" } }, + success: true, + }, + { + name: "convert slice: ZipSchema with null", + golden: "TestConvertSlice.golden", + schema: "ZipSchema", + input: { Zap: null }, + success: true, + }, + { + name: "convert slice: WhimSchema with valid Foo", + golden: "TestConvertSlice.golden", + schema: "WhimSchema", + input: { Wham: { Bar: "a", Baz: "b", Quz: "c" } }, + success: true, + }, + + // --- TestStructSlice --- + { + name: "struct slice: parses valid array", + golden: "TestStructSlice.golden", + schema: "UserSchema", + input: { Favourites: [{ Name: "Alice" }, { Name: "Bob" }] }, + success: true, + }, + { + name: "struct slice: parses null", + golden: "TestStructSlice.golden", + schema: "UserSchema", + input: { Favourites: null }, + success: true, + }, + + // --- TestStructSliceOptional --- + { + name: "struct slice optional: parses valid array", + golden: "TestStructSliceOptional.golden", + schema: "UserSchema", + input: { Favourites: [{ Name: "Alice" }] }, + success: true, + }, + { + name: "struct slice optional: parses undefined", + golden: "TestStructSliceOptional.golden", + schema: "UserSchema", + input: {}, + success: true, + }, + + // --- TestStructSliceOptionalNullable --- + { + name: "struct slice optional nullable: parses valid array", + golden: "TestStructSliceOptionalNullable.golden", + schema: "UserSchema", + input: { Favourites: [{ Name: "Alice" }] }, + success: true, + }, + { + name: "struct slice optional nullable: parses null", + golden: "TestStructSliceOptionalNullable.golden", + schema: "UserSchema", + input: { Favourites: null }, + success: true, + }, + { + name: "struct slice optional nullable: parses undefined", + golden: "TestStructSliceOptionalNullable.golden", + schema: "UserSchema", + input: {}, + success: true, + }, + + // --- TestSliceFields --- + { + name: "slice fields: parses valid object with all fields", + golden: "TestSliceFields.golden", + schema: "TestSliceFieldsStructSchema", + input: { + NoValidate: [1, 2], + Required: [1], + Min: [1], + OmitEmpty: [1, 2], + JSONOmitEmpty: [1, 2], + MinOmitEmpty: [1], + JSONMinOmitEmpty: [1], + }, + success: true, + }, + + // --- TestConvertSliceWithValidations --- + { + name: "slice validations: requiredSchema accepts array", + golden: "TestConvertSliceWithValidations.golden", + schema: "requiredSchema", + input: { value: ["a"] }, + success: true, + }, + { + name: "slice validations: minSchema accepts array with >= 1 item", + golden: "TestConvertSliceWithValidations.golden", + schema: "minSchema", + input: { value: ["a"] }, + success: true, + }, + { + name: "slice validations: minSchema rejects empty array", + golden: "TestConvertSliceWithValidations.golden", + schema: "minSchema", + input: { value: [] }, + success: false, + }, + { + name: "slice validations: maxSchema accepts array with <= 1 item", + golden: "TestConvertSliceWithValidations.golden", + schema: "maxSchema", + input: { value: ["a"] }, + success: true, + }, + { + name: "slice validations: maxSchema rejects array with > 1 item", + golden: "TestConvertSliceWithValidations.golden", + schema: "maxSchema", + input: { value: ["a", "b"] }, + success: false, + }, + { + name: "slice validations: lenSchema accepts array of length 1", + golden: "TestConvertSliceWithValidations.golden", + schema: "lenSchema", + input: { value: ["a"] }, + success: true, + }, + { + name: "slice validations: lenSchema rejects wrong length", + golden: "TestConvertSliceWithValidations.golden", + schema: "lenSchema", + input: { value: ["a", "b"] }, + success: false, + }, + { + name: "slice validations: eqSchema accepts array of length 1", + golden: "TestConvertSliceWithValidations.golden", + schema: "eqSchema", + input: { value: ["a"] }, + success: true, + }, + { + name: "slice validations: gtSchema accepts array with >= 2 items", + golden: "TestConvertSliceWithValidations.golden", + schema: "gtSchema", + input: { value: ["a", "b"] }, + success: true, + }, + { + name: "slice validations: gtSchema rejects array with < 2 items", + golden: "TestConvertSliceWithValidations.golden", + schema: "gtSchema", + input: { value: ["a"] }, + success: false, + }, + { + name: "slice validations: gteSchema accepts array with >= 1 items", + golden: "TestConvertSliceWithValidations.golden", + schema: "gteSchema", + input: { value: ["a"] }, + success: true, + }, + { + name: "slice validations: ltSchema accepts empty array", + golden: "TestConvertSliceWithValidations.golden", + schema: "ltSchema", + input: { value: [] }, + success: true, + }, + { + name: "slice validations: ltSchema rejects array with >= 1 items", + golden: "TestConvertSliceWithValidations.golden", + schema: "ltSchema", + input: { value: ["a"] }, + success: false, + }, + { + name: "slice validations: lteSchema accepts array with <= 1 item", + golden: "TestConvertSliceWithValidations.golden", + schema: "lteSchema", + input: { value: ["a"] }, + success: true, + }, + { + name: "slice validations: neSchema accepts non-empty array", + golden: "TestConvertSliceWithValidations.golden", + schema: "neSchema", + input: { value: ["a"] }, + success: true, + }, + { + name: "slice validations: neSchema rejects empty array", + golden: "TestConvertSliceWithValidations.golden", + schema: "neSchema", + input: { value: [] }, + success: false, + }, + + // --- TestConvertSliceWithValidations/dive_nested --- + { + name: "dive nested: dive1Schema accepts nested array", + golden: "TestConvertSliceWithValidations/dive_nested.golden", + schema: "dive1Schema", + input: { value: [["a", "b"], ["c"]] }, + success: true, + }, + { + name: "dive nested: dive2Schema accepts array of arrays with min 1", + golden: "TestConvertSliceWithValidations/dive_nested.golden", + schema: "dive2Schema", + input: { value: [["a"], ["b", "c"]] }, + success: true, + }, + + // --- TestConvertSliceWithValidations/dive_oneof --- + { + name: "dive oneof: accepts array of valid enum values", + golden: "TestConvertSliceWithValidations/dive_oneof.golden", + schema: "dive_oneofSchema", + input: { value: ["a", "b", "c"] }, + success: true, + }, + { + name: "dive oneof: rejects array with invalid enum value", + golden: "TestConvertSliceWithValidations/dive_oneof.golden", + schema: "dive_oneofSchema", + input: { value: ["a", "d"] }, + success: false, + }, + + // --------------------------------------------------------------------------- + // MAPS + // --------------------------------------------------------------------------- + + // --- TestMapStringToString --- + { + name: "map string to string: parses valid map", + golden: "TestMapStringToString.golden", + schema: "UserSchema", + input: { Name: "John", Metadata: { key: "val" } }, + success: true, + }, + { + name: "map string to string: parses null", + golden: "TestMapStringToString.golden", + schema: "UserSchema", + input: { Name: "John", Metadata: null }, + success: true, + }, + + // --- TestMapStringToInterface --- + { + name: "map string to interface: parses valid map with any values", + golden: "TestMapStringToInterface.golden", + schema: "UserSchema", + input: { Name: "John", Metadata: { key: 42, nested: { a: true } } }, + success: true, + }, + + // --- TestMapWithStruct --- + { + name: "map with struct: parses valid map", + golden: "TestMapWithStruct.golden", + schema: "UserSchema", + input: { MapWithStruct: { hello: { Title: "World" } } }, + success: true, + }, + + // --- TestMapWithValidations --- + { + name: "map validations: requiredSchema accepts map", + golden: "TestMapWithValidations.golden", + schema: "requiredSchema", + input: { value: { a: "b" } }, + success: true, + }, + { + name: "map validations: minSchema accepts map with >= 1 keys", + golden: "TestMapWithValidations.golden", + schema: "minSchema", + input: { value: { a: "b" } }, + success: true, + }, + { + name: "map validations: minSchema rejects empty map", + golden: "TestMapWithValidations.golden", + schema: "minSchema", + input: { value: {} }, + success: false, + }, + { + name: "map validations: maxSchema accepts map with <= 1 keys", + golden: "TestMapWithValidations.golden", + schema: "maxSchema", + input: { value: { a: "b" } }, + success: true, + }, + { + name: "map validations: maxSchema rejects map with > 1 keys", + golden: "TestMapWithValidations.golden", + schema: "maxSchema", + input: { value: { a: "b", c: "d" } }, + success: false, + }, + { + name: "map validations: lenSchema accepts map with exactly 1 key", + golden: "TestMapWithValidations.golden", + schema: "lenSchema", + input: { value: { a: "b" } }, + success: true, + }, + { + name: "map validations: lenSchema rejects map with != 1 keys", + golden: "TestMapWithValidations.golden", + schema: "lenSchema", + input: { value: { a: "b", c: "d" } }, + success: false, + }, + { + name: "map validations: eqSchema accepts map with exactly 1 key", + golden: "TestMapWithValidations.golden", + schema: "eqSchema", + input: { value: { a: "b" } }, + success: true, + }, + { + name: "map validations: neSchema accepts map with != 1 keys", + golden: "TestMapWithValidations.golden", + schema: "neSchema", + input: { value: { a: "b", c: "d" } }, + success: true, + }, + { + name: "map validations: neSchema rejects map with exactly 1 key", + golden: "TestMapWithValidations.golden", + schema: "neSchema", + input: { value: { a: "b" } }, + success: false, + }, + { + name: "map validations: gtSchema accepts map with > 1 keys", + golden: "TestMapWithValidations.golden", + schema: "gtSchema", + input: { value: { a: "b", c: "d" } }, + success: true, + }, + { + name: "map validations: gtSchema rejects map with <= 1 keys", + golden: "TestMapWithValidations.golden", + schema: "gtSchema", + input: { value: { a: "b" } }, + success: false, + }, + { + name: "map validations: gteSchema accepts map with >= 1 keys", + golden: "TestMapWithValidations.golden", + schema: "gteSchema", + input: { value: { a: "b" } }, + success: true, + }, + { + name: "map validations: ltSchema accepts empty map", + golden: "TestMapWithValidations.golden", + schema: "ltSchema", + input: { value: {} }, + success: true, + }, + { + name: "map validations: ltSchema rejects map with >= 1 keys", + golden: "TestMapWithValidations.golden", + schema: "ltSchema", + input: { value: { a: "b" } }, + success: false, + }, + { + name: "map validations: lteSchema accepts map with <= 1 keys", + golden: "TestMapWithValidations.golden", + schema: "lteSchema", + input: { value: { a: "b" } }, + success: true, + }, + { + name: "map validations: dive1Schema accepts map with values >= 2 chars", + golden: "TestMapWithValidations.golden", + schema: "dive1Schema", + input: { value: { key: "ab" } }, + success: true, + }, + + // --- TestMapWithValidations/dive_nested --- + { + name: "map dive nested: dive2Schema accepts array of maps", + golden: "TestMapWithValidations/dive_nested.golden", + schema: "dive2Schema", + input: { value: [{ aaa: "bbb", ccc: "ddd" }] }, + success: true, + }, + { + name: "map dive nested: dive3Schema accepts array of maps with key/value constraints", + golden: "TestMapWithValidations/dive_nested.golden", + schema: "dive3Schema", + input: { value: [{ abc: "abcd", def: "ef" }] }, + success: true, + }, + + // --- TestMapWithNonStringKey/int_key --- + { + name: "map int key: parses valid map with coerced number keys", + golden: "TestMapWithNonStringKey/int_key.golden", + schema: "Map1Schema", + input: { Name: "John", Metadata: { "1": "one", "2": "two" } }, + success: true, + }, + + // --- TestMapWithNonStringKey/float_key --- + { + name: "map float key: parses valid map with coerced number keys", + golden: "TestMapWithNonStringKey/float_key.golden", + schema: "Map3Schema", + input: { Name: "John", Metadata: { "1.5": "one-half", "2.5": "two-half" } }, + success: true, + }, + + // --- TestMapWithNonStringKey/time_key --- + { + name: "map time key: parses valid map with string keys", + golden: "TestMapWithNonStringKey/time_key.golden", + schema: "Map2Schema", + input: { Name: "John", Metadata: { "2021-01-01T00:00:00Z": "new year" } }, + success: true, + }, + + // --- TestNullableWithValidations --- + { + name: "nullable with validations: parses full valid object", + golden: "TestNullableWithValidations.golden", + schema: "UserSchema", + input: { + Name: "John", + PtrMapOptionalNullable1: null, + PtrMapOptionalNullable2: null, + PtrMap1: { a: 1, b: 2, c: 3 }, + PtrMap2: { a: 1, b: 2, c: 3 }, + PtrMapNullable: { a: 1, b: 2, c: 3 }, + MapOptional1: undefined, + MapOptional2: undefined, + Map1: { a: 1, b: 2, c: 3 }, + Map2: { a: 1, b: 2, c: 3 }, + MapNullable: { a: 1, b: 2, c: 3 }, + PtrSliceOptionalNullable1: null, + PtrSliceOptionalNullable2: null, + PtrSlice1: ["a", "b", "c"], + PtrSlice2: ["a", "b", "c"], + PtrSliceNullable: ["a", "b", "c"], + SliceOptional1: undefined, + SliceOptional2: undefined, + Slice1: ["a", "b", "c"], + Slice2: ["a", "b", "c"], + SliceNullable: ["a", "b", "c"], + PtrIntOptional1: undefined, + PtrIntOptional2: undefined, + PtrInt1: 3, + PtrInt2: 3, + PtrIntNullable: 3, + PtrStringOptional1: undefined, + PtrStringOptional2: undefined, + PtrString1: "abc", + PtrString2: "abc", + PtrStringNullable: "abc", + }, + success: true, + }, + + // --------------------------------------------------------------------------- + // NESTED V4 + // --------------------------------------------------------------------------- + + // --- TestNestedStruct/v4 --- + { + name: "nested struct v4: parses valid object with spread shapes", + golden: "TestNestedStruct", + schema: "UserSchema", + input: { Tags: ["a", "b"], ID: "123", name: "John" }, + success: true, + }, + + // --- TestRecursive1/v4 --- + { + name: "recursive1 v4: parses nested children", + golden: "TestRecursive1", + schema: "NestedItemSchema", + input: { + id: 1, + title: "Root", + pos: 0, + parent_id: 0, + project_id: 1, + children: [ + { + id: 2, + title: "Child", + pos: 1, + parent_id: 1, + project_id: 1, + children: null, + }, + ], + }, + success: true, + }, + + // --- TestRecursive2/v4 --- + { + name: "recursive2 v4: parses ParentSchema with nested next", + golden: "TestRecursive2", + schema: "ParentSchema", + input: { + child: { + value: 1, + next: { + value: 2, + next: null, + }, + }, + }, + success: true, + }, + + // --- TestRecursiveEmbeddedStruct/v4 --- + { + name: "recursive embedded v4: parses ItemBSchema", + golden: "TestRecursiveEmbeddedStruct", + schema: "ItemBSchema", + input: { + Name: "root", + Children: [ + { Name: "child1", Children: null }, + { Name: "child2", Children: [{ Name: "grandchild", Children: null }] }, + ], + }, + success: true, + }, + + // --- TestRecursiveEmbeddedWithPointersAndDates/recursive_struct_with_pointer_field_and_date/v4 --- + { + name: "recursive with dates v4: parses TreeSchema", + golden: + "TestRecursiveEmbeddedWithPointersAndDates/recursive_struct_with_pointer_field_and_date", + schema: "TreeSchema", + input: { + UpdatedAt: "2021-01-01T00:00:00Z", + Value: "root", + CreatedAt: "2021-01-01T00:00:00Z", + Children: [ + { + Value: "child", + CreatedAt: "2021-02-01T00:00:00Z", + Children: null, + }, + ], + }, + success: true, + output: { + UpdatedAt: new Date("2021-01-01T00:00:00Z"), + Value: "root", + CreatedAt: new Date("2021-01-01T00:00:00Z"), + Children: [ + { + Value: "child", + CreatedAt: new Date("2021-02-01T00:00:00Z"), + Children: null, + }, + ], + }, + }, + + // --- TestRecursiveEmbeddedWithPointersAndDates/embedded_struct_with_pointer_to_self_and_date/v4 --- + { + name: "embedded self-pointer with dates v4: parses ArticleSchema", + golden: + "TestRecursiveEmbeddedWithPointersAndDates/embedded_struct_with_pointer_to_self_and_date", + schema: "ArticleSchema", + input: { + Title: "Article", + Text: "Hello", + Timestamp: "2021-01-01T00:00:00Z", + Reply: { + Text: "Reply", + Timestamp: "2021-02-01T00:00:00Z", + Reply: null, + }, + }, + success: true, + output: { + Title: "Article", + Text: "Hello", + Timestamp: new Date("2021-01-01T00:00:00Z"), + Reply: { + Text: "Reply", + Timestamp: new Date("2021-02-01T00:00:00Z"), + Reply: null, + }, + }, + }, + + // --------------------------------------------------------------------------- + // STRING VALIDATIONS + // --------------------------------------------------------------------------- + + // --- eqSchema --- + { + name: "string eq: accepts exact match 'hello'", + golden: "TestStringValidations.golden", + schema: "eqSchema", + input: { value: "hello" }, + success: true, + }, + { + name: "string eq: rejects non-match", + golden: "TestStringValidations.golden", + schema: "eqSchema", + input: { value: "world" }, + success: false, + }, + + // --- neSchema --- + { + name: "string ne: accepts value not 'hello'", + golden: "TestStringValidations.golden", + schema: "neSchema", + input: { value: "world" }, + success: true, + }, + { + name: "string ne: rejects 'hello'", + golden: "TestStringValidations.golden", + schema: "neSchema", + input: { value: "hello" }, + success: false, + }, + + // --- oneofSchema --- + { + name: "string oneof: accepts 'hello'", + golden: "TestStringValidations.golden", + schema: "oneofSchema", + input: { value: "hello" }, + success: true, + }, + { + name: "string oneof: rejects invalid value", + golden: "TestStringValidations.golden", + schema: "oneofSchema", + input: { value: "invalid" }, + success: false, + }, + + // --- TestOneofRequired --- + { + name: "oneof required: accepts valid status", + golden: "TestOneofRequired.golden", + schema: "PayloadSchema", + input: { status: "active", statusImplicitRequired: "active" }, + success: true, + }, + { + name: "oneof required: rejects empty status", + golden: "TestOneofRequired.golden", + schema: "PayloadSchema", + input: { status: "", statusImplicitRequired: "active" }, + success: false, + }, + { + name: "oneof required: rejects invalid status", + golden: "TestOneofRequired.golden", + schema: "PayloadSchema", + input: { status: "deleted", statusImplicitRequired: "active" }, + success: false, + }, + { + name: "oneof optional: accepts with channel present", + golden: "TestOneofRequired.golden", + schema: "PayloadSchema", + input: { + status: "active", + statusImplicitRequired: "active", + channel: "email", + }, + success: true, + }, + { + name: "oneof optional: accepts without channel (optional)", + golden: "TestOneofRequired.golden", + schema: "PayloadSchema", + input: { status: "active", statusImplicitRequired: "active" }, + success: true, + }, + { + name: "oneof optional: rejects invalid channel", + golden: "TestOneofRequired.golden", + schema: "PayloadSchema", + input: { + status: "active", + statusImplicitRequired: "active", + channel: "phone", + }, + success: false, + }, + + // --- OneofIpSchema (enum ignores ip validator) --- + { + name: "oneof+ip: accepts value in oneof list", + golden: "TestStringValidations/enum_ignores_other_validators.golden", + schema: "OneofIpSchema", + input: { v: "127.0.0.1" }, + success: true, + }, + { + name: "oneof+ip: accepts second value in oneof list", + golden: "TestStringValidations/enum_ignores_other_validators.golden", + schema: "OneofIpSchema", + input: { v: "::1" }, + success: true, + }, + { + name: "oneof+ip: rejects valid ip not in oneof list", + golden: "TestStringValidations/enum_ignores_other_validators.golden", + schema: "OneofIpSchema", + input: { v: "192.168.1.1" }, + success: false, + }, + { + name: "oneof+ip: rejects non-ip string", + golden: "TestStringValidations/enum_ignores_other_validators.golden", + schema: "OneofIpSchema", + input: { v: "hello" }, + success: false, + }, + + // --- TestOmitZero --- + { + name: "omitzero: accepts all fields present", + golden: "TestOmitZero.golden", + schema: "PayloadSchema", + input: { Name: "alice", Nickname: "ally", Email: "a@b.com" }, + success: true, + }, + { + name: "omitzero: accepts omitzero fields missing", + golden: "TestOmitZero.golden", + schema: "PayloadSchema", + input: { Name: "alice" }, + success: true, + }, + { + name: "omitzero: rejects required field missing", + golden: "TestOmitZero.golden", + schema: "PayloadSchema", + input: { Nickname: "ally" }, + success: false, + }, + + // --- TestZodV4Defaults/custom_tag_before_required_base64_preserves_min(1) --- + { + name: "base64 with trim and required: accepts valid base64", + golden: + "TestZodV4Defaults/custom_tag_before_required_base64_preserves_min(1).golden", + schema: "PayloadSchema", + input: { Data: "aGVsbG8=", Hex: "deadbeef" }, + success: true, + }, + { + name: "base64 with trim: trims whitespace before validating", + golden: + "TestZodV4Defaults/custom_tag_before_required_base64_preserves_min(1).golden", + schema: "PayloadSchema", + input: { Data: " aGVsbG8= ", Hex: " deadbeef " }, + output: { Data: "aGVsbG8=", Hex: "deadbeef" }, + success: true, + }, + { + name: "base64 with trim and required: rejects empty string", + golden: + "TestZodV4Defaults/custom_tag_before_required_base64_preserves_min(1).golden", + schema: "PayloadSchema", + input: { Data: "", Hex: "deadbeef" }, + success: false, + }, + { + name: "hex with trim and required: rejects empty string", + golden: + "TestZodV4Defaults/custom_tag_before_required_base64_preserves_min(1).golden", + schema: "PayloadSchema", + input: { Data: "aGVsbG8=", Hex: "" }, + success: false, + }, + + // --- lenSchema --- + { + name: "string len: accepts string of length 5", + golden: "TestStringValidations.golden", + schema: "lenSchema", + input: { value: "abcde" }, + success: true, + }, + { + name: "string len: rejects string of wrong length", + golden: "TestStringValidations.golden", + schema: "lenSchema", + input: { value: "abc" }, + success: false, + }, + + // --- minSchema --- + { + name: "string min: accepts string of length >= 5", + golden: "TestStringValidations.golden", + schema: "minSchema", + input: { value: "abcde" }, + success: true, + }, + { + name: "string min: rejects string of length < 5", + golden: "TestStringValidations.golden", + schema: "minSchema", + input: { value: "abc" }, + success: false, + }, + + // --- maxSchema --- + { + name: "string max: accepts string of length <= 5", + golden: "TestStringValidations.golden", + schema: "maxSchema", + input: { value: "abcde" }, + success: true, + }, + { + name: "string max: rejects string of length > 5", + golden: "TestStringValidations.golden", + schema: "maxSchema", + input: { value: "abcdef" }, + success: false, + }, + + // --- containsSchema --- + { + name: "string contains: accepts string containing 'hello'", + golden: "TestStringValidations.golden", + schema: "containsSchema", + input: { value: "say hello world" }, + success: true, + }, + { + name: "string contains: rejects string not containing 'hello'", + golden: "TestStringValidations.golden", + schema: "containsSchema", + input: { value: "goodbye" }, + success: false, + }, + + // --- startswithSchema --- + { + name: "string startswith: accepts string starting with 'hello'", + golden: "TestStringValidations.golden", + schema: "startswithSchema", + input: { value: "hello world" }, + success: true, + }, + { + name: "string startswith: rejects string not starting with 'hello'", + golden: "TestStringValidations.golden", + schema: "startswithSchema", + input: { value: "world hello" }, + success: false, + }, + + // --- endswithSchema --- + { + name: "string endswith: accepts string ending with 'hello'", + golden: "TestStringValidations.golden", + schema: "endswithSchema", + input: { value: "world hello" }, + success: true, + }, + { + name: "string endswith: rejects string not ending with 'hello'", + golden: "TestStringValidations.golden", + schema: "endswithSchema", + input: { value: "hello world" }, + success: false, + }, + + // --- requiredSchema --- + { + name: "string required: accepts non-empty string", + golden: "TestStringValidations.golden", + schema: "requiredSchema", + input: { value: "a" }, + success: true, + }, + { + name: "string required: rejects empty string", + golden: "TestStringValidations.golden", + schema: "requiredSchema", + input: { value: "" }, + success: false, + }, + + // --- lowercaseSchema --- + { + name: "string lowercase: accepts lowercase", + golden: "TestStringValidations.golden", + schema: "lowercaseSchema", + input: { value: "hello" }, + success: true, + }, + { + name: "string lowercase: rejects uppercase", + golden: "TestStringValidations.golden", + schema: "lowercaseSchema", + input: { value: "Hello" }, + success: false, + }, + + // --- uppercaseSchema --- + { + name: "string uppercase: accepts uppercase", + golden: "TestStringValidations.golden", + schema: "uppercaseSchema", + input: { value: "HELLO" }, + success: true, + }, + { + name: "string uppercase: rejects lowercase", + golden: "TestStringValidations.golden", + schema: "uppercaseSchema", + input: { value: "Hello" }, + success: false, + }, + + // --- boolean_validatorSchema --- + { + name: "string boolean: accepts 'true'", + golden: "TestStringValidations.golden", + schema: "boolean_validatorSchema", + input: { value: "true" }, + success: true, + }, + { + name: "string boolean: rejects 'yes'", + golden: "TestStringValidations.golden", + schema: "boolean_validatorSchema", + input: { value: "yes" }, + success: false, + }, + + // --- json_validatorSchema --- + { + name: "string json: accepts valid JSON", + golden: "TestStringValidations.golden", + schema: "json_validatorSchema", + input: { value: '{"key":"value"}' }, + success: true, + }, + { + name: "string json: rejects invalid JSON", + golden: "TestStringValidations.golden", + schema: "json_validatorSchema", + input: { value: "{invalid" }, + success: false, + }, + + // --- alphaSchema --- + { + name: "string alpha: accepts alpha-only", + golden: "TestStringValidations.golden", + schema: "alphaSchema", + input: { value: "hello" }, + success: true, + }, + { + name: "string alpha: rejects non-alpha", + golden: "TestStringValidations.golden", + schema: "alphaSchema", + input: { value: "hello123" }, + success: false, + }, + + // --- number_validatorSchema --- + { + name: "string number: accepts digits only", + golden: "TestStringValidations.golden", + schema: "number_validatorSchema", + input: { value: "12345" }, + success: true, + }, + { + name: "string number: rejects non-digit", + golden: "TestStringValidations.golden", + schema: "number_validatorSchema", + input: { value: "123abc" }, + success: false, + }, + + // --------------------------------------------------------------------------- + // NUMBER VALIDATIONS + // --------------------------------------------------------------------------- + + // --- gte_lteSchema --- + { + name: "number gte_lte: accepts 18", + golden: "TestNumberValidations.golden", + schema: "gte_lteSchema", + input: { value: 18 }, + success: true, + }, + { + name: "number gte_lte: accepts 60", + golden: "TestNumberValidations.golden", + schema: "gte_lteSchema", + input: { value: 60 }, + success: true, + }, + { + name: "number gte_lte: rejects 17", + golden: "TestNumberValidations.golden", + schema: "gte_lteSchema", + input: { value: 17 }, + success: false, + }, + { + name: "number gte_lte: rejects 61", + golden: "TestNumberValidations.golden", + schema: "gte_lteSchema", + input: { value: 61 }, + success: false, + }, + + // --- gt_ltSchema --- + { + name: "number gt_lt: accepts 19", + golden: "TestNumberValidations.golden", + schema: "gt_ltSchema", + input: { value: 19 }, + success: true, + }, + { + name: "number gt_lt: rejects 18 (not >18)", + golden: "TestNumberValidations.golden", + schema: "gt_ltSchema", + input: { value: 18 }, + success: false, + }, + { + name: "number gt_lt: rejects 60 (not <60)", + golden: "TestNumberValidations.golden", + schema: "gt_ltSchema", + input: { value: 60 }, + success: false, + }, + + // --- number eqSchema --- + { + name: "number eq: accepts 18", + golden: "TestNumberValidations.golden", + schema: "eqSchema", + input: { value: 18 }, + success: true, + }, + { + name: "number eq: rejects 19", + golden: "TestNumberValidations.golden", + schema: "eqSchema", + input: { value: 19 }, + success: false, + }, + + // --- number neSchema --- + { + name: "number ne: accepts 19", + golden: "TestNumberValidations.golden", + schema: "neSchema", + input: { value: 19 }, + success: true, + }, + { + name: "number ne: rejects 18", + golden: "TestNumberValidations.golden", + schema: "neSchema", + input: { value: 18 }, + success: false, + }, + + // --- number oneofSchema --- + { + name: "number oneof: accepts 18", + golden: "TestNumberValidations.golden", + schema: "oneofSchema", + input: { value: 18 }, + success: true, + }, + { + name: "number oneof: rejects 21", + golden: "TestNumberValidations.golden", + schema: "oneofSchema", + input: { value: 21 }, + success: false, + }, + + // --- number min_maxSchema --- + { + name: "number min_max: accepts 30", + golden: "TestNumberValidations.golden", + schema: "min_maxSchema", + input: { value: 30 }, + success: true, + }, + + // --- number lenSchema --- + { + name: "number len: accepts 18", + golden: "TestNumberValidations.golden", + schema: "lenSchema", + input: { value: 18 }, + success: true, + }, + + // --------------------------------------------------------------------------- + // FORMAT VALIDATORS V4 + // --------------------------------------------------------------------------- + + // --- emailSchema --- + { + name: "email: accepts valid email", + golden: "TestFormatValidators/format_only", + schema: "emailSchema", + input: { value: "test@example.com" }, + success: true, + }, + { + name: "email: rejects invalid email", + golden: "TestFormatValidators/format_only", + schema: "emailSchema", + input: { value: "notanemail" }, + success: false, + }, + + // --- urlSchema (z.url() accepts any scheme) --- + { + name: "url: accepts https", + golden: "TestFormatValidators/format_only", + schema: "urlSchema", + input: { value: "https://example.com" }, + success: true, + }, + { + name: "url: accepts ftp", + golden: "TestFormatValidators/format_only", + schema: "urlSchema", + input: { value: "ftp://files.example.com/file.txt" }, + success: true, + }, + { + name: "url: accepts mailto", + golden: "TestFormatValidators/format_only", + schema: "urlSchema", + input: { value: "mailto:user@example.com" }, + success: true, + }, + { + name: "url: rejects invalid url", + golden: "TestFormatValidators/format_only", + schema: "urlSchema", + input: { value: "not a url" }, + success: false, + }, + + // --- http_urlSchema (z.httpUrl() only accepts http/https) zod 3 accepts any URL --- + { + name: "http_url: accepts https", + golden: "TestFormatValidators/format_only", + schema: "http_urlSchema", + input: { value: "https://example.com" }, + success: true, + }, + { + name: "http_url: rejects invalid url", + golden: "TestFormatValidators/format_only", + schema: "http_urlSchema", + input: { value: "invalid_url" }, + success: false, + }, + { + name: "http_url: v3 accepts non-http url", + golden: "TestFormatValidators/format_only/v3.golden", + schema: "http_urlSchema", + input: { value: "ftp://files.example.com/file.txt" }, + success: true, + }, + { + name: "http_url: rejects ftp", + golden: "TestFormatValidators/format_only/v4.golden", + schema: "http_urlSchema", + input: { value: "ftp://files.example.com/file.txt" }, + success: false, + }, + { + name: "http_url: rejects mailto", + golden: "TestFormatValidators/format_only/v4.golden", + schema: "http_urlSchema", + input: { value: "mailto:user@example.com" }, + success: false, + }, + + // --- ipv4Schema --- + { + name: "ipv4: accepts valid ipv4", + golden: "TestFormatValidators/format_only", + schema: "ipv4Schema", + input: { value: "127.0.0.1" }, + success: true, + }, + { + name: "ipv4: rejects invalid ipv4", + golden: "TestFormatValidators/format_only", + schema: "ipv4Schema", + input: { value: "999.999.999.999" }, + success: false, + }, + + // --- ipv6Schema --- + { + name: "ipv6: accepts valid ipv6", + golden: "TestFormatValidators/format_only", + schema: "ipv6Schema", + input: { value: "::1" }, + success: true, + }, + { + name: "ipv6: rejects invalid ipv6", + golden: "TestFormatValidators/format_only", + schema: "ipv6Schema", + input: { value: "not-ipv6" }, + success: false, + }, + + // --- base64Schema --- + { + name: "base64: accepts valid base64", + golden: "TestFormatValidators/format_only", + schema: "base64Schema", + input: { value: "SGVsbG8=" }, + success: true, + }, + { + name: "base64: rejects invalid base64", + golden: "TestFormatValidators/format_only", + schema: "base64Schema", + input: { value: "not base64!!!" }, + success: false, + }, + + // --- uuid4Schema --- + { + name: "uuid4: accepts valid uuid v4", + golden: "TestFormatValidators/format_only", + schema: "uuid4Schema", + input: { value: "550e8400-e29b-41d4-a716-446655440000" }, + success: true, + }, + { + name: "uuid4: rejects invalid uuid", + golden: "TestFormatValidators/format_only", + schema: "uuid4Schema", + input: { value: "not-a-uuid" }, + success: false, + }, + + // --- md5Schema --- + { + name: "md5: accepts valid md5 hash", + golden: "TestFormatValidators/format_only", + schema: "md5Schema", + input: { value: "d41d8cd98f00b204e9800998ecf8427e" }, + success: true, + }, + { + name: "md5: rejects invalid md5", + golden: "TestFormatValidators/format_only", + schema: "md5Schema", + input: { value: "not-a-hash" }, + success: false, + }, + + // --------------------------------------------------------------------------- + // UNION V4 + // --------------------------------------------------------------------------- + + // --- ipSchema --- + { + name: "ip union: accepts valid ipv4", + golden: "TestFormatValidators/union_only", + schema: "ipSchema", + input: { value: "127.0.0.1" }, + success: true, + }, + { + name: "ip union: accepts valid ipv6", + golden: "TestFormatValidators/union_only", + schema: "ipSchema", + input: { value: "::1" }, + success: true, + }, + { + name: "ip union: rejects invalid ip", + golden: "TestFormatValidators/union_only", + schema: "ipSchema", + input: { value: "notanip" }, + success: false, + }, + + // --------------------------------------------------------------------------- + // SPECIAL + // --------------------------------------------------------------------------- + + // --- TestEverything --- + { + name: "everything: parses full valid object", + golden: "TestEverything.golden", + schema: "UserSchema", + input: { + Name: "John", + Nickname: null, + Age: 30, + Height: 5.9, + OldPostWithMetaData: { Title: "Hello", Post: { Title: "World" } }, + Tags: ["a", "b"], + TagsOptional: ["x"], + TagsOptionalNullable: null, + Favourites: [{ Name: "Alice" }], + Posts: [{ Title: "Post1" }], + Post: { Title: "Main" }, + PostOptional: { Title: "Optional" }, + PostOptionalNullable: null, + Metadata: { key: "val" }, + MetadataOptional: undefined, + MetadataOptionalNullable: null, + ExtendedProps: { any: "thing" }, + ExtendedPropsOptional: null, + ExtendedPropsNullable: null, + ExtendedPropsOptionalNullable: null, + ExtendedPropsVeryIndirect: null, + NewPostWithMetaData: { Title: "New", Post: { Title: "Inner" } }, + VeryNewPost: { Title: "VeryNew" }, + MapWithStruct: { k: { Title: "T", Post: { Title: "P" } } }, + }, + success: true, + }, + + // --- TestEverythingWithValidations --- + { + name: "everything with validations: parses full valid object", + golden: "TestEverythingWithValidations.golden", + schema: "UserSchema", + input: { + Name: "John", + Nickname: null, + Age: 18, + Height: 1.5, + OldPostWithMetaData: { Title: "Hello", Post: { Title: "World" } }, + Tags: ["a", "b"], + TagsOptional: ["a", "b"], + TagsOptionalNullable: ["a", "b"], + Favourites: null, + Posts: [{ Title: "Hello" }], + Post: { Title: "Hello" }, + PostOptional: { Title: "Hello" }, + PostOptionalNullable: { Title: "Hello" }, + Metadata: null, + MetadataLength: { Hello: "World" }, + MetadataOptional: undefined, + MetadataOptionalNullable: null, + ExtendedProps: null, + ExtendedPropsOptional: undefined, + ExtendedPropsNullable: null, + ExtendedPropsOptionalNullable: null, + ExtendedPropsVeryIndirect: null, + NewPostWithMetaData: { Title: "Hello", Post: { Title: "World" } }, + VeryNewPost: { Title: "Hello" }, + MapWithStruct: { + Hello: { Title: "World", Post: { Title: "Hello" } }, + }, + }, + success: true, + }, + + // --- TestGenerics --- + { + name: "generics: StringIntPairSchema", + golden: "TestGenerics.golden", + schema: "StringIntPairSchema", + input: { First: "hello", Second: 42 }, + success: true, + }, + { + name: "generics: GenericPairIntBoolSchema", + golden: "TestGenerics.golden", + schema: "GenericPairIntBoolSchema", + input: { First: 1, Second: true }, + success: true, + }, + { + name: "generics: PairMapStringIntBoolSchema", + golden: "TestGenerics.golden", + schema: "PairMapStringIntBoolSchema", + input: { items: { key: { First: 1, Second: false } } }, + success: true, + }, + + // --- TestInterfaceAny --- + { + name: "interface any: accepts any value for Metadata", + golden: "TestInterfaceAny.golden", + schema: "UserSchema", + input: { Name: "John", Metadata: { anything: [1, 2, 3] } }, + success: true, + }, + + // --- TestCustomTag/v4 --- + { + name: "custom tag v4: parses SortParamsSchema", + golden: "TestCustomTag", + schema: "SortParamsSchema", + input: { order: "asc", field: "name" }, + success: true, + }, + + // --- TestZodV4Defaults/enum_keyed_maps_become_partial_records --- + { + name: "v4 defaults: enum keyed maps become partial records", + golden: "TestMapWithEnumKey", + schema: "PayloadSchema", + input: { Metadata: { draft: "some note" } }, + success: true, + }, + { + name: "v4 defaults: enum keyed maps for partial records reject invalid keys", + golden: "TestMapWithEnumKey", + schema: "PayloadSchema", + input: { Metadata: { invalid: "some note" } }, + success: false, + }, + + // --- TestZodV4Defaults/ip_unions_inherit_generic_string_constraints --- + { + name: "v4 defaults: ip unions inherit generic string constraints", + golden: + "TestZodV4Defaults/ip_unions_inherit_generic_string_constraints.golden", + schema: "PayloadSchema", + input: { Address: "127.0.0.1" }, + success: true, + }, + + // --- TestZodV4Defaults/optional_format_with_nullable_pointer/v4 --- + { + name: "v4 defaults: optional format with nullable pointer accepts null", + golden: "TestZodV4Defaults/optional_format_with_nullable_pointer", + schema: "PayloadSchema", + input: { email: null }, + success: true, + }, + { + name: "v4 defaults: optional format with nullable pointer accepts valid email", + golden: "TestZodV4Defaults/optional_format_with_nullable_pointer", + schema: "PayloadSchema", + input: { email: "test@example.com" }, + success: true, + }, + + // --- TestZodV4Defaults/string_formats_use_zod_v4_builders --- + { + name: "v4 defaults: string formats use zod v4 builders", + golden: "TestZodV4Defaults/string_formats_use_zod_v4_builders.golden", + schema: "PayloadSchema", + input: { + Email: "test@example.com", + Link: "https://example.com", + Base64: "SGVsbG8=", + ID: "550e8400-e29b-41d4-a716-446655440000", + Checksum: "d41d8cd98f00b204e9800998ecf8427e", + }, + success: true, + }, + + // --- Custom types --- + { + name: "custom type: mapped to string", + golden: "TestCustomTypes/custom_type_mapped_to_string.golden", + schema: "UserSchema", + input: { Name: "John", Money: "123.45" }, + success: true, + }, + { + name: "custom type: resolves inner generic type", + golden: "TestCustomTypes/custom_type_resolves_inner_generic_type.golden", + schema: "UserSchema", + input: { + MaybeName: "John", + MaybeAge: 30, + MaybeHeight: 1.8, + MaybeProfile: { Bio: "Hello" }, + }, + success: true, + }, + { + name: "custom type: resolves inner generic with nullish", + golden: "TestCustomTypes/custom_type_resolves_inner_generic_type.golden", + schema: "UserSchema", + input: { + MaybeName: null, + MaybeAge: undefined, + MaybeHeight: null, + MaybeProfile: undefined, + }, + success: true, + }, + { + name: "custom type: nullable pointer with custom handler", + golden: "TestCustomTypes/custom_type_with_nullable_control.golden", + schema: "UserSchema", + input: { Name: "John", Email: null }, + success: true, + }, + + // --------------------------------------------------------------------------- + // STRING TAG ORDER WITH FORMAT HELPERS (trim + email) + // --------------------------------------------------------------------------- + + // --- TestStringValidations/string_tag_order_is_preserved_around_v4_format_helpers --- + { + name: "trim then email: valid email passes", + golden: + "TestStringValidations/string_tag_order_is_preserved_around_v4_format_helpers", + schema: "PayloadSchema", + input: { + TrimmedThenEmail: "user@example.com", + EmailThenTrimmed: "user@example.com", + }, + success: true, + }, + { + name: "trim then email: invalid email rejects", + golden: + "TestStringValidations/string_tag_order_is_preserved_around_v4_format_helpers", + schema: "PayloadSchema", + input: { + TrimmedThenEmail: "not-an-email", + EmailThenTrimmed: "user@example.com", + }, + success: false, + }, + { + name: "email then trimmed: invalid email rejects", + golden: + "TestStringValidations/string_tag_order_is_preserved_around_v4_format_helpers", + schema: "PayloadSchema", + input: { + TrimmedThenEmail: "user@example.com", + EmailThenTrimmed: "not-an-email", + }, + success: false, + }, + { + name: "trim then email: spaces trimmed before validation passes", + golden: + "TestStringValidations/string_tag_order_is_preserved_around_v4_format_helpers", + schema: "PayloadSchema", + input: { + TrimmedThenEmail: " user@example.com ", + EmailThenTrimmed: "user@example.com", + }, + success: true, + output: { + TrimmedThenEmail: "user@example.com", + EmailThenTrimmed: "user@example.com", + }, + }, + { + name: "email then trimmed: spaces cause check to fail before trim runs", + golden: + "TestStringValidations/string_tag_order_is_preserved_around_v4_format_helpers", + schema: "PayloadSchema", + input: { + TrimmedThenEmail: "user@example.com", + EmailThenTrimmed: " user@example.com ", + }, + success: false, + }, +]; diff --git a/tests/golden.test.ts b/tests/golden.test.ts new file mode 100644 index 0000000..b7da4e7 --- /dev/null +++ b/tests/golden.test.ts @@ -0,0 +1,107 @@ +/** + * Runtime tests for golden file schemas. + * + * Dynamically imports schemas from golden files and tests them against + * the cases defined in cases.ts. Run inside Docker via docker-typecheck.sh. + * + * The ZOD_VERSION env var ("v3" or "v4") determines which zod version is active. + * Golden files with a @zod-version metadata that doesn't match are skipped. + */ +import { describe, expect, it } from "vitest"; +import { existsSync, readFileSync } from "fs"; +import { cases } from "./cases"; + +// Golden files are copied to /test/golden/ as .ts files by the docker script. +const GOLDEN_DIR = "/test/golden"; + +// Which zod version we're testing under (set by docker script) +const currentZodVersion = process.env.ZOD_VERSION || "v4"; + +// Cache for imported golden modules +const moduleCache = new Map>(); + +// Cache for golden file zod version metadata +const versionCache = new Map(); + +/** + * Resolves a golden path to a concrete .golden file path. + * + * If the path ends with ".golden", it is used as-is. + * Otherwise it is treated as a directory containing v3.golden / v4.golden, + * and the file matching currentZodVersion is returned. + */ +function resolveGolden(golden: string): string { + if (golden.endsWith(".golden")) { + return golden; + } + // Directory path — check that at least one version file exists + const dir = golden.endsWith("/") ? golden : golden + "/"; + const hasV3 = existsSync(`/golden/${dir}v3.golden`); + const hasV4 = existsSync(`/golden/${dir}v4.golden`); + if (!hasV3 && !hasV4) { + throw new Error( + `No golden files found in directory "${golden}" — expected v3.golden or v4.golden` + ); + } + return dir + currentZodVersion + ".golden"; +} + +function getGoldenZodVersion(golden: string): string | null { + const resolved = resolveGolden(golden); + if (!versionCache.has(resolved)) { + try { + const goldenSource = readFileSync(`/golden/${resolved}`, "utf-8"); + const match = goldenSource.match(/^\/\/ @zod-version: (v\d+)/m); + versionCache.set(resolved, match ? match[1] : null); + } catch { + versionCache.set(resolved, null); + } + } + return versionCache.get(resolved)!; +} + +function shouldSkip(golden: string): boolean { + const version = getGoldenZodVersion(golden); + // null means "both versions" — always run + if (version === null) return false; + // Skip if the golden file's version doesn't match the current zod version + return version !== currentZodVersion; +} + +async function getSchema(golden: string, schemaName: string) { + const resolved = resolveGolden(golden); + if (!moduleCache.has(resolved)) { + const tsName = resolved.replace(/\//g, "__").replace(/\.golden$/, ".ts"); + const mod = await import(`${GOLDEN_DIR}/${tsName}`); + moduleCache.set(resolved, mod); + } + const mod = moduleCache.get(resolved)!; + const schema = mod[schemaName]; + if (!schema || typeof (schema as any).safeParse !== "function") { + throw new Error( + `Schema "${schemaName}" not found or not a Zod schema in ${resolved}` + ); + } + return schema as { safeParse: (input: unknown) => any }; +} + +describe(`Golden file runtime tests (zod@${currentZodVersion})`, () => { + for (const tc of cases) { + const skip = shouldSkip(tc.golden); + + const testFn = skip ? it.skip : it; + + testFn(tc.name, async () => { + const schema = await getSchema(tc.golden, tc.schema); + const result = schema.safeParse(tc.input); + + if (tc.success) { + expect(result.success).toBe(true); + const expected = tc.output !== undefined ? tc.output : tc.input; + expect(result.data).toEqual(expected); + } else { + expect(result.success).toBe(false); + } + }); + } +}); diff --git a/tests/zod.test.ts b/tests/zod.test.ts deleted file mode 100644 index 437be64..0000000 --- a/tests/zod.test.ts +++ /dev/null @@ -1,292 +0,0 @@ -import {z} from "zod" -import {describe, expect, it} from 'vitest' - -describe("Zod time tests", () => { - it('TestStructTime', () => { - const UserSchema = z.object({ - Name: z.string(), - When: z.coerce.date() - }) - - const user1 = UserSchema.parse({ - Name: "John", - When: "2021-01-01T00:00:00Z", - }) - expect(user1).toEqual({ - Name: "John", - When: new Date("2021-01-01T00:00:00Z"), - }) - - const user2 = UserSchema.parse({ - Name: "John", - When: 1609459200000, - }) - expect(user2).toEqual({ - Name: "John", - When: new Date("2021-01-01T00:00:00Z"), - }) - - const user3 = UserSchema.parse({ - Name: "John", - When: null, - }) - expect(user3).toEqual({ - Name: "John", - When: new Date(0), - }) - - const user4 = UserSchema.parse({ - Name: "John", - When: "0001-01-01T00:00:00Z" - }) - expect(user4).toEqual({ - Name: "John", - When: new Date("0001-01-01T00:00:00Z"), - }) - - const user5 = UserSchema.safeParse({ - Name: "John", - When: "", - }); - expect(user5.success).toBe(false) - }) - - it('TestTimeWithRequired', () => { - const UserSchema = z.object({ - Name: z.string(), - When: z.coerce.date().refine( - (val) => val.getTime() !== new Date('0001-01-01T00:00:00Z').getTime() && val.getTime() !== new Date(0).getTime(), - 'Invalid date' - ), - }) - - const user1 = UserSchema.parse({ - Name: "John", - When: "2021-01-01T00:00:00Z", - }) - expect(user1).toEqual({ - Name: "John", - When: new Date("2021-01-01T00:00:00Z"), - }) - - const user2 = UserSchema.parse({ - Name: "John", - When: 1609459200000, - }) - expect(user2).toEqual({ - Name: "John", - When: new Date("2021-01-01T00:00:00Z"), - }) - - const user3 = UserSchema.safeParse({ - Name: "John", - When: null, - }) - expect(user3.success).toBe(false) - - const user4 = UserSchema.safeParse({ - Name: "John", - When: "0001-01-01T00:00:00Z" - }) - expect(user4.success).toBe(false) - - const user5 = UserSchema.safeParse({ - Name: "John", - When: "", - }); - expect(user5.success).toBe(false) - }) -}) - -describe("Zod test everything validations", () => { - it('TestEverything', () => { - const PostSchema = z.object({ - Title: z.string().min(1), - }) - type Post = z.infer - - const PostWithMetaDataSchema = z.object({ - Title: z.string().min(1), - Post: PostSchema, - }) - type PostWithMetaData = z.infer - - const UserSchema = z.object({ - Name: z.string().min(1), - Nickname: z.string().nullable(), - Age: z.number().gte(18).refine((val) => val !== 0), - Height: z.number().gte(1.5).refine((val) => val !== 0), - OldPostWithMetaData: PostWithMetaDataSchema, - Tags: z.string().array().nonempty().min(1), - TagsOptional: z.string().array().optional(), - TagsOptionalNullable: z.string().array().optional().nullable(), - Favourites: z.object({ - Name: z.string().min(1), - }).array().nullable(), - Posts: PostSchema.array().nonempty(), - Post: PostSchema, - PostOptional: PostSchema.optional(), - PostOptionalNullable: PostSchema.optional().nullable(), - Metadata: z.record(z.string(), z.string()).nullable(), - MetadataLength: z.record(z.string(), z.string()).refine((val) => Object.keys(val).length > 0, 'Empty map').refine((val) => Object.keys(val).length >= 1, 'Map too small').refine((val) => Object.keys(val).length <= 10, 'Map too large'), - MetadataOptional: z.record(z.string(), z.string()).optional(), - MetadataOptionalNullable: z.record(z.string(), z.string()).optional().nullable(), - ExtendedProps: z.any(), - ExtendedPropsOptional: z.any(), - ExtendedPropsNullable: z.any(), - ExtendedPropsOptionalNullable: z.any(), - ExtendedPropsVeryIndirect: z.any(), - NewPostWithMetaData: PostWithMetaDataSchema, - VeryNewPost: PostSchema, - MapWithStruct: z.record(z.string(), PostWithMetaDataSchema).nullable(), - }) - type User = z.infer - - const user1 = UserSchema.parse({ - Name: "John", - Nickname: null, - Age: 18, - Height: 1.5, - OldPostWithMetaData: { - Title: "Hello", - Post: { - Title: "World", - }, - }, - Tags: ["a", "b"], - TagsOptional: ["a", "b"], - TagsOptionalNullable: ["a", "b"], - Favourites: null, - Posts: [ - { - Title: "Hello", - }, - ], - Post: { - Title: "Hello", - }, - PostOptional: { - Title: "Hello", - }, - PostOptionalNullable: { - Title: "Hello", - }, - Metadata: null, - MetadataLength: { - "Hello": "World", - }, - MetadataOptional: undefined, - MetadataOptionalNullable: null, - ExtendedProps: null, - ExtendedPropsOptional: undefined, - ExtendedPropsNullable: null, - ExtendedPropsOptionalNullable: null, - ExtendedPropsVeryIndirect: null, - NewPostWithMetaData: { - Title: "Hello", - Post: { - Title: "World", - }, - }, - VeryNewPost: { - Title: "Hello", - }, - MapWithStruct: { - "Hello": { - Title: "World", - Post: { - Title: "Hello", - }, - }, - }, - }) - expect(user1).toEqual({ - Name: "John", - Nickname: null, - Age: 18, - Height: 1.5, - OldPostWithMetaData: { - Title: "Hello", - Post: { - Title: "World", - }, - }, - Tags: ["a", "b"], - TagsOptional: ["a", "b"], - TagsOptionalNullable: ["a", "b"], - Favourites: null, - Posts: [ - { - Title: "Hello", - }, - ], - Post: { - Title: "Hello", - }, - PostOptional: { - Title: "Hello", - }, - PostOptionalNullable: { - Title: "Hello", - }, - Metadata: null, - MetadataLength: { - "Hello": "World", - }, - MetadataOptional: undefined, - MetadataOptionalNullable: null, - ExtendedProps: null, - ExtendedPropsOptional: undefined, - ExtendedPropsNullable: null, - ExtendedPropsOptionalNullable: null, - ExtendedPropsVeryIndirect: null, - NewPostWithMetaData: { - Title: "Hello", - Post: { - Title: "World", - }, - }, - VeryNewPost: { - Title: "Hello", - }, - MapWithStruct: { - "Hello": { - Title: "World", - Post: { - Title: "Hello", - }, - }, - }, - }) - }) -}) - -describe("Zod test enum", () => { - it('TestEnum1', () => { - const EnumSchema = z.enum(["a", "b"]) - type Enum = z.infer - - const enum1 = EnumSchema.parse("a") - testStringType(enum1) - // testConstType(enum1) - // Does not work with const - }) - - it('TestEnum2', () => { - const EnumSchema = z.enum(["abc", "def"] as const) - type Enum = z.infer - - const enum1 = EnumSchema.parse("abc") - testStringType(enum1) - testConstType(enum1) - // Works with both string and const - }) -}) - -function testStringType(x: string) { - console.log(x) -} - -function testConstType(x: "abc"|"def") { - console.log(x) -} diff --git a/zod.go b/zod.go index 84c627b..d285a4e 100644 --- a/zod.go +++ b/zod.go @@ -1,6 +1,7 @@ package zen import ( + "encoding/json" "fmt" "reflect" "regexp" @@ -49,6 +50,13 @@ func WithIgnoreTags(ignores ...string) Opt { } } +// Emits legacy Zod v3-compatible schemas instead of the default Zod v4 output. +func WithZodV3() Opt { + return func(c *Converter) { + c.zodV3 = true + } +} + // NewConverterWithOpts initializes and returns a new converter instance. func NewConverterWithOpts(opts ...Opt) *Converter { c := &Converter{ @@ -74,21 +82,29 @@ func NewConverter(customTypes map[string]CustomFn) Converter { return *NewConverterWithOpts(WithCustomTypes(customTypes)) } +// AddTypeWithName converts a struct type to corresponding zod schema using a custom name +// instead of the struct's type name. Useful for anonymous structs from reflect.StructOf. +func (c *Converter) AddTypeWithName(input interface{}, name string) { + c.addType(reflect.TypeOf(input), name) +} + // AddType converts a struct type to corresponding zod schema. AddType can be called // multiple times, followed by Export to get the corresponding zod schemas. func (c *Converter) AddType(input interface{}) { t := reflect.TypeOf(input) + c.addType(t, typeName(t)) +} +func (c *Converter) addType(t reflect.Type, name string) { if t.Kind() != reflect.Struct { panic("input must be a struct") } - name := typeName(t) if _, ok := c.outputs[name]; ok { return } - data, selfRef := c.convertStructTopLevel(t) + data, selfRef := c.convertStructTopLevel(t, name) c.addSchema(name, data, selfRef) } @@ -159,11 +175,17 @@ type meta struct { selfRef bool } +type stringValidator struct { + tag string // "email", "ip", "required", "trim", "max", "_custom", etc. + arg string // "45" for max=45, raw text for _custom +} + type Converter struct { prefix string customTypes map[string]CustomFn customTags map[string]CustomFn ignoreTags []string + zodV3 bool structs int outputs map[string]entry stack []meta @@ -240,10 +262,9 @@ func typeName(t reflect.Type) string { return "UNKNOWN" } -func (c *Converter) convertStructTopLevel(t reflect.Type) (string, bool) { +func (c *Converter) convertStructTopLevel(t reflect.Type, name string) (string, bool) { output := strings.Builder{} - name := typeName(t) c.stack = append(c.stack, meta{name, false}) data := c.convertStruct(t, 0) @@ -288,12 +309,10 @@ func (c *Converter) getStructShape(input reflect.Type, indent int) string { optional := isOptional(field) nullable := isNullable(field) - line, shouldMerge := c.convertField(field, indent+1, optional, nullable) - - if !shouldMerge { - output.WriteString(line) + if field.Anonymous { + output.WriteString(c.convertEmbeddedFieldSpread(field, indent+1)) } else { - output.WriteString(fmt.Sprintf("%s...%s.shape,\n", indentation(indent+1), schemaName(c.prefix, typeName(field.Type)))) + output.WriteString(c.convertNamedField(field, indent+1, optional, nullable)) } } @@ -310,6 +329,8 @@ func (c *Converter) convertStruct(input reflect.Type, indent int) string { `) merges := []string{} + embeddedFields := []string{} + namedFields := []string{} fields := input.NumField() for i := 0; i < fields; i++ { @@ -317,14 +338,34 @@ func (c *Converter) convertStruct(input reflect.Type, indent int) string { optional := isOptional(field) nullable := isNullable(field) - line, shouldMerge := c.convertField(field, indent+1, optional, nullable) + if field.Anonymous { + if c.zodV3 { + line, shouldMerge := c.convertEmbeddedFieldMerge(field, indent+1) + if shouldMerge { + merges = append(merges, line) + } else { + output.WriteString(line) + } + } else { + embeddedFields = append(embeddedFields, c.convertEmbeddedFieldSpread(field, indent+1)) + } + } else { + namedFields = append(namedFields, c.convertNamedField(field, indent+1, optional, nullable)) + } + } - if !shouldMerge { + // In v4, embedded spreads are written before named fields so that named + // fields override embedded ones (last key wins in JS object literals). + // This matches Go's shadowing semantics where the outer struct's field + // takes precedence over the embedded struct's field. + if !c.zodV3 { + for _, line := range embeddedFields { output.WriteString(line) - } else { - merges = append(merges, line) } } + for _, line := range namedFields { + output.WriteString(line) + } output.WriteString(indentation(indent)) output.WriteString(`})`) @@ -345,7 +386,18 @@ func (c *Converter) getTypeStruct(input reflect.Type, indent int) string { merges := []string{} + // Collect own (non-anonymous) field names to detect shadowing. fields := input.NumField() + ownFieldNames := map[string]bool{} + for i := 0; i < fields; i++ { + f := input.Field(i) + if !f.Anonymous { + if name := fieldName(f); name != "-" { + ownFieldNames[name] = true + } + } + } + for i := 0; i < fields; i++ { field := input.Field(i) optional := isOptional(field) @@ -356,6 +408,27 @@ func (c *Converter) getTypeStruct(input reflect.Type, indent int) string { if !shouldMerge { output.WriteString(line) } else { + // When own fields shadow embedded fields, wrap in Omit<> so the + // TypeScript intersection doesn't produce conflicting property types. + embeddedType := field.Type + if embeddedType.Kind() == reflect.Ptr { + embeddedType = embeddedType.Elem() + } + var shadowedKeys []string + if embeddedType.Kind() == reflect.Struct { + for j := 0; j < embeddedType.NumField(); j++ { + if name := fieldName(embeddedType.Field(j)); name != "-" && ownFieldNames[name] { + shadowedKeys = append(shadowedKeys, name) + } + } + } + if len(shadowedKeys) > 0 { + quoted := make([]string, len(shadowedKeys)) + for k, key := range shadowedKeys { + quoted[k] = fmt.Sprintf("'%s'", key) + } + line = fmt.Sprintf("Omit<%s, %s>", line, strings.Join(quoted, " | ")) + } merges = append(merges, line) } } @@ -408,19 +481,28 @@ func (c *Converter) handleCustomType(t reflect.Type, validate string, indent int return "", false } +type convertResult struct { + text string + selfRef bool +} + // ConvertType should be called from custom converter functions. func (c *Converter) ConvertType(t reflect.Type, validate string, indent int) string { + return c.convertType(t, validate, indent).text +} + +func (c *Converter) convertType(t reflect.Type, validate string, indent int) convertResult { if t.Kind() == reflect.Ptr { inner := t.Elem() validate = strings.TrimPrefix(validate, "omitempty") validate = strings.TrimPrefix(validate, ",") - return c.ConvertType(inner, validate, indent) + return c.convertType(inner, validate, indent) } // Custom types should be handled before maps/slices, as we might have // custom types that are maps/slices. if custom, ok := c.handleCustomType(t, validate, indent); ok { - return custom + return convertResult{text: custom} } if t.Kind() == reflect.Slice || t.Kind() == reflect.Array { @@ -428,12 +510,13 @@ func (c *Converter) ConvertType(t reflect.Type, validate string, indent int) str } if t.Kind() == reflect.Map { - return c.convertMap(t, validate, indent) + return convertResult{text: c.convertMap(t, validate, indent)} } if t.Kind() == reflect.Struct { var validateStr strings.Builder var refines []string + var selfRef bool name := typeName(t) parts := strings.Split(validate, ",") @@ -446,18 +529,23 @@ func (c *Converter) ConvertType(t reflect.Type, validate string, indent int) str } else { if c.stack[len(c.stack)-1].name == name { c.stack[len(c.stack)-1].selfRef = true - validateStr.WriteString(fmt.Sprintf("z.lazy(() => %s)", schemaName(c.prefix, name))) + if c.zodV3 { + validateStr.WriteString(fmt.Sprintf("z.lazy(() => %s)", schemaName(c.prefix, name))) + } else { + selfRef = true + validateStr.WriteString(schemaName(c.prefix, name)) + } } else { // throws panic if there is a cycle detectCycle(name, c.stack) - data, selfRef := c.convertStructTopLevel(t) - c.addSchema(name, data, selfRef) + data, sRef := c.convertStructTopLevel(t, name) + c.addSchema(name, data, sRef) validateStr.WriteString(schemaName(c.prefix, name)) } } for _, part := range parts { - valName, _, done := c.preprocessValidationTagPart(part, &refines, &validateStr) + valName, _, done := c.preprocessValidationTagPart(part, &refines, &validateStr, t) if done { continue } @@ -477,7 +565,8 @@ func (c *Converter) ConvertType(t reflect.Type, validate string, indent int) str validateStr.WriteString(refine) } - return validateStr.String() + schema := validateStr.String() + return convertResult{text: schema, selfRef: selfRef} } // boolean, number, string, any @@ -490,16 +579,13 @@ func (c *Converter) ConvertType(t reflect.Type, validate string, indent int) str if validate != "" { switch zodType { case "string": - validateStr = c.validateString(validate) - if strings.Contains(validateStr, ".enum(") { - return "z" + validateStr - } + return convertResult{text: c.validateString(validate)} case "number": - validateStr = c.validateNumber(validate) + validateStr = c.validateNumber(validate, t) } } - return fmt.Sprintf("z.%s()%s", zodType, validateStr) + return convertResult{text: fmt.Sprintf("z.%s()%s", zodType, validateStr)} } func (c *Converter) getType(t reflect.Type, indent int) string { @@ -538,12 +624,12 @@ func (c *Converter) getType(t reflect.Type, indent int) string { return zodType } -func (c *Converter) convertField(f reflect.StructField, indent int, optional, nullable bool) (string, bool) { +func (c *Converter) convertNamedField(f reflect.StructField, indent int, optional, nullable bool) string { name := fieldName(f) // fields named `-` are not exported to JSON so don't export zod types if name == "-" { - return "", false + return "" } // because nullability is processed before custom types, this makes sure @@ -560,25 +646,49 @@ func (c *Converter) convertField(f reflect.StructField, indent int, optional, nu nullableCall = ".nullable()" } - t := c.ConvertType(f.Type, f.Tag.Get("validate"), indent) - if !f.Anonymous { + res := c.convertType(f.Type, f.Tag.Get("validate"), indent) + + if res.selfRef && !c.zodV3 { return fmt.Sprintf( - "%s%s: %s%s%s,\n", + "%sget %s() { return %s%s%s; },\n", indentation(indent), name, - t, + res.text, optionalCall, - nullableCall), false - } else { - typeName := typeName(f.Type) - entry, ok := c.outputs[typeName] - if ok && entry.selfRef { - // Since we are spreading shape, we won't be able to support any validation tags on the embedded field - return fmt.Sprintf("%s...%s,\n", indentation(indent), shapeName(c.prefix, typeName)), false - } + nullableCall) + } - return fmt.Sprintf(".merge(%s)", t), true + return fmt.Sprintf( + "%s%s: %s%s%s,\n", + indentation(indent), + name, + res.text, + optionalCall, + nullableCall) +} + +func (c *Converter) convertEmbeddedFieldMerge(f reflect.StructField, indent int) (string, bool) { + t := c.convertType(f.Type, f.Tag.Get("validate"), indent).text + name := typeName(f.Type) + entry, ok := c.outputs[name] + if ok && entry.selfRef { + // Since we are spreading shape, we won't be able to support any validation tags on the embedded field + return fmt.Sprintf("%s...%s,\n", indentation(indent), shapeName(c.prefix, name)), false } + + return fmt.Sprintf(".merge(%s)", t), true +} + +func (c *Converter) convertEmbeddedFieldSpread(f reflect.StructField, indent int) string { + t := c.convertType(f.Type, f.Tag.Get("validate"), indent).text + typeName := typeName(f.Type) + entry, ok := c.outputs[typeName] + if ok && entry.selfRef { + // Since we are spreading shape, we won't be able to support any validation tags on the embedded field + return fmt.Sprintf("%s...%s,\n", indentation(indent), shapeName(c.prefix, typeName)) + } + + return fmt.Sprintf("%s...%s.shape,\n", indentation(indent), t) } func (c *Converter) getTypeField(f reflect.StructField, indent int, optional, nullable bool) (string, bool) { @@ -619,7 +729,7 @@ func (c *Converter) getTypeField(f reflect.StructField, indent int, optional, nu return typeName(f.Type), true } -func (c *Converter) convertSliceAndArray(t reflect.Type, validate string, indent int) string { +func (c *Converter) convertSliceAndArray(t reflect.Type, validate string, indent int) convertResult { var validateStr strings.Builder var refines []string validateCurrent := getValidateCurrent(validate) @@ -628,7 +738,7 @@ func (c *Converter) convertSliceAndArray(t reflect.Type, validate string, indent forParts: for _, part := range parts { - valName, valValue, done := c.preprocessValidationTagPart(part, &refines, &validateStr) + valName, valValue, done := c.preprocessValidationTagPart(part, &refines, &validateStr, t) if done { continue } @@ -639,30 +749,37 @@ forParts: if valValue != "" { switch valName { case "min": + requireIntArg("min", valValue) validateStr.WriteString(fmt.Sprintf(".min(%s)", valValue)) case "max": + requireIntArg("max", valValue) validateStr.WriteString(fmt.Sprintf(".max(%s)", valValue)) case "len": + requireIntArg("len", valValue) validateStr.WriteString(fmt.Sprintf(".length(%s)", valValue)) case "eq": + requireIntArg("eq", valValue) validateStr.WriteString(fmt.Sprintf(".length(%s)", valValue)) case "ne": + requireIntArg("ne", valValue) refines = append(refines, fmt.Sprintf(".refine((val) => val.length !== %s)", valValue)) case "gt": - val, err := strconv.Atoi(valValue) - if err != nil || val < 0 { + val := requireIntArg("gt", valValue) + if val < 0 { panic(fmt.Sprintf("invalid gt value: %s", valValue)) } validateStr.WriteString(fmt.Sprintf(".min(%d)", val+1)) case "gte": + requireIntArg("gte", valValue) validateStr.WriteString(fmt.Sprintf(".min(%s)", valValue)) case "lt": - val, err := strconv.Atoi(valValue) - if err != nil || val <= 0 { + val := requireIntArg("lt", valValue) + if val <= 0 { panic(fmt.Sprintf("invalid lt value: %s", valValue)) } validateStr.WriteString(fmt.Sprintf(".max(%d)", val-1)) case "lte": + requireIntArg("lte", valValue) validateStr.WriteString(fmt.Sprintf(".max(%s)", valValue)) default: @@ -690,9 +807,11 @@ forParts: validateStr.WriteString(refine) } - return fmt.Sprintf( - "%s.array()%s", - c.ConvertType(t.Elem(), getValidateAfterDive(validate), indent), validateStr.String()) + elemResult := c.convertType(t.Elem(), getValidateAfterDive(validate), indent) + return convertResult{ + text: fmt.Sprintf("%s.array()%s", elemResult.text, validateStr.String()), + selfRef: elemResult.selfRef, + } } func (c *Converter) getTypeSliceAndArray(t reflect.Type, indent int) string { @@ -703,7 +822,8 @@ func (c *Converter) getTypeSliceAndArray(t reflect.Type, indent int) string { func (c *Converter) convertKeyType(t reflect.Type, validate string) string { if t.Name() == "Time" { - return "z.coerce.date()" + // JSON serializes time.Time map keys as RFC3339 strings via TextMarshaler. + return "z.string()" } // boolean, number, string, any @@ -716,12 +836,9 @@ func (c *Converter) convertKeyType(t reflect.Type, validate string) string { if validate != "" { switch zodType { case "string": - validateStr = c.validateString(validate) - if strings.Contains(validateStr, ".enum(") { - return "z" + validateStr - } + return c.validateString(validate) case "number": - validateStr = c.validateNumber(validate) + validateStr = c.validateNumber(validate, t) } } @@ -741,7 +858,7 @@ func (c *Converter) convertMap(t reflect.Type, validate string, indent int) stri forParts: for _, part := range parts { - valName, valValue, done := c.preprocessValidationTagPart(part, &refines, &validateStr) + valName, valValue, done := c.preprocessValidationTagPart(part, &refines, &validateStr, t) if done { continue } @@ -749,22 +866,31 @@ forParts: if valValue != "" { switch valName { case "min": + requireIntArg("min", valValue) refines = append(refines, fmt.Sprintf(".refine((val) => Object.keys(val).length >= %s, 'Map too small')", valValue)) case "max": + requireIntArg("max", valValue) refines = append(refines, fmt.Sprintf(".refine((val) => Object.keys(val).length <= %s, 'Map too large')", valValue)) case "len": + requireIntArg("len", valValue) refines = append(refines, fmt.Sprintf(".refine((val) => Object.keys(val).length === %s, 'Map wrong size')", valValue)) case "eq": + requireIntArg("eq", valValue) refines = append(refines, fmt.Sprintf(".refine((val) => Object.keys(val).length === %s, 'Map wrong size')", valValue)) case "ne": + requireIntArg("ne", valValue) refines = append(refines, fmt.Sprintf(".refine((val) => Object.keys(val).length !== %s, 'Map wrong size')", valValue)) case "gt": + requireIntArg("gt", valValue) refines = append(refines, fmt.Sprintf(".refine((val) => Object.keys(val).length > %s, 'Map too small')", valValue)) case "gte": + requireIntArg("gte", valValue) refines = append(refines, fmt.Sprintf(".refine((val) => Object.keys(val).length >= %s, 'Map too small')", valValue)) case "lt": + requireIntArg("lt", valValue) refines = append(refines, fmt.Sprintf(".refine((val) => Object.keys(val).length < %s, 'Map too large')", valValue)) case "lte": + requireIntArg("lte", valValue) refines = append(refines, fmt.Sprintf(".refine((val) => Object.keys(val).length <= %s, 'Map too large')", valValue)) default: @@ -787,8 +913,15 @@ forParts: validateStr.WriteString(refine) } - return fmt.Sprintf(`z.record(%s, %s)%s`, - c.convertKeyType(t.Key(), getValidateKeys(validate)), + keySchema := c.convertKeyType(t.Key(), getValidateKeys(validate)) + recordFn := "z.record" + if !c.zodV3 && isPartialRecordKeySchema(keySchema) { + recordFn = "z.partialRecord" + } + + return fmt.Sprintf(`%s(%s, %s)%s`, + recordFn, + keySchema, c.ConvertType(t.Elem(), getValidateValues(validate), indent), validateStr.String()) } @@ -838,7 +971,11 @@ func getValidateValues(validate string) string { var validateValues string if strings.Contains(validate, "dive,keys") { - removedPrefix := strings.SplitN(validate, ",endkeys", 2)[1] + parts := strings.SplitN(validate, ",endkeys", 2) + if len(parts) < 2 { + panic("malformed validation: 'dive,keys' without matching 'endkeys'") + } + removedPrefix := parts[1] if strings.Contains(removedPrefix, ",dive") { validateValues = strings.SplitN(removedPrefix, ",dive", 2)[0] @@ -847,7 +984,11 @@ func getValidateValues(validate string) string { } validateValues = strings.TrimPrefix(validateValues, ",") } else if strings.Contains(validate, "dive") { - removedPrefix := strings.SplitN(validate, "dive,", 2)[1] + parts := strings.SplitN(validate, "dive,", 2) + if len(parts) < 2 { + return "" + } + removedPrefix := parts[1] if strings.Contains(removedPrefix, ",dive") { validateValues = strings.SplitN(removedPrefix, ",dive", 2)[0] } else { @@ -869,13 +1010,13 @@ func (c *Converter) checkIsIgnored(part string) bool { // not implementing omitempty for numbers and strings // could support unusual cases like `validate:"omitempty,min=3,max=5"` -func (c *Converter) validateNumber(validate string) string { +func (c *Converter) validateNumber(validate string, t reflect.Type) string { var validateStr strings.Builder var refines []string parts := strings.Split(validate, ",") for _, part := range parts { - valName, valValue, done := c.preprocessValidationTagPart(part, &refines, &validateStr) + valName, valValue, done := c.preprocessValidationTagPart(part, &refines, &validateStr, t) if done { continue } @@ -883,22 +1024,31 @@ func (c *Converter) validateNumber(validate string) string { if valValue != "" { switch valName { case "gt": + requireNumericArg("gt", valValue) validateStr.WriteString(fmt.Sprintf(".gt(%s)", valValue)) case "gte", "min": + requireNumericArg(valName, valValue) validateStr.WriteString(fmt.Sprintf(".gte(%s)", valValue)) case "lt": + requireNumericArg("lt", valValue) validateStr.WriteString(fmt.Sprintf(".lt(%s)", valValue)) case "lte", "max": + requireNumericArg(valName, valValue) validateStr.WriteString(fmt.Sprintf(".lte(%s)", valValue)) case "eq", "len": + requireNumericArg(valName, valValue) refines = append(refines, fmt.Sprintf(".refine((val) => val === %s)", valValue)) case "ne": + requireNumericArg("ne", valValue) refines = append(refines, fmt.Sprintf(".refine((val) => val !== %s)", valValue)) case "oneof": vals := strings.Fields(valValue) if len(vals) == 0 { panic(fmt.Sprintf("invalid oneof validation: %s", part)) } + for _, v := range vals { + requireNumericArg("oneof", v) + } refines = append(refines, fmt.Sprintf(".refine((val) => [%s].includes(val))", strings.Join(vals, ", "))) default: @@ -923,183 +1073,387 @@ func (c *Converter) validateNumber(validate string) string { return validateStr.String() } +// Tag classification sets for string validators. +var formatTags = map[string]bool{ + "email": true, "url": true, "http_url": true, + "ipv4": true, "ip4_addr": true, "ipv6": true, "ip6_addr": true, + "base64": true, "datetime": true, "hexadecimal": true, "jwt": true, + "uuid": true, "uuid3": true, "uuid3_rfc4122": true, + "uuid4": true, "uuid4_rfc4122": true, + "uuid5": true, "uuid5_rfc4122": true, + "uuid_rfc4122": true, + "md5": true, "sha256": true, "sha384": true, "sha512": true, +} + +var unionTags = map[string]bool{ + "ip": true, "ip_addr": true, +} + func (c *Converter) validateString(validate string) string { - var validateStr strings.Builder - var refines []string + validators := c.parseStringValidators(validate) + return c.renderStringSchema(validators) +} + +var knownStringTags = map[string]bool{ + "required": true, "email": true, "url": true, "http_url": true, + "ipv4": true, "ip4_addr": true, "ipv6": true, "ip6_addr": true, + "ip": true, "ip_addr": true, + "url_encoded": true, "alpha": true, "alphanum": true, + "alphanumunicode": true, "alphaunicode": true, "ascii": true, + "lowercase": true, "number": true, "numeric": true, "uppercase": true, + "base64": true, "mongodb": true, "datetime": true, "hexadecimal": true, + "json": true, "jwt": true, "latitude": true, "longitude": true, + "uuid": true, "uuid3": true, "uuid3_rfc4122": true, + "uuid4": true, "uuid4_rfc4122": true, + "uuid5": true, "uuid5_rfc4122": true, "uuid_rfc4122": true, + "md4": true, "md5": true, "sha256": true, "sha384": true, "sha512": true, + "contains": true, "endswith": true, "startswith": true, + "eq": true, "ne": true, "len": true, "min": true, "max": true, + "gt": true, "gte": true, "lt": true, "lte": true, +} + +func (c *Converter) parseStringValidators(validate string) []stringValidator { + var validators []stringValidator parts := strings.Split(validate, ",") - for _, part := range parts { - valName, valValue, done := c.preprocessValidationTagPart(part, &refines, &validateStr) - if done { + for _, rawPart := range parts { + valName, valValue, skip := c.parseValidationTagPart(rawPart) + if skip { continue } - if valValue != "" { - switch valName { - case "oneof": - vals := splitParamsRegex.FindAllString(part[6:], -1) - for i := 0; i < len(vals); i++ { - vals[i] = strings.Replace(vals[i], "'", "", -1) - } - if len(vals) == 0 { - panic("oneof= must be followed by a list of values") - } - // const FishEnum = z.enum(["Salmon", "Tuna", "Trout"]); - validateStr.WriteString(fmt.Sprintf(".enum([\"%s\"] as const)", strings.Join(vals, "\", \""))) - case "len": - refines = append(refines, fmt.Sprintf(".refine((val) => [...val].length === %s, 'String must contain %s character(s)')", valValue, valValue)) - case "min": - refines = append(refines, fmt.Sprintf(".refine((val) => [...val].length >= %s, 'String must contain at least %s character(s)')", valValue, valValue)) - case "max": - refines = append(refines, fmt.Sprintf(".refine((val) => [...val].length <= %s, 'String must contain at most %s character(s)')", valValue, valValue)) - case "gt": - val, err := strconv.Atoi(valValue) - if err != nil { - panic("gt= must be followed by a number") - } - refines = append(refines, fmt.Sprintf(".refine((val) => [...val].length > %d, 'String must contain at least %d character(s)')", val, val+1)) - case "gte": - refines = append(refines, fmt.Sprintf(".refine((val) => [...val].length >= %s, 'String must contain at least %s character(s)')", valValue, valValue)) - case "lt": - val, err := strconv.Atoi(valValue) - if err != nil { - panic("lt= must be followed by a number") - } - refines = append(refines, fmt.Sprintf(".refine((val) => [...val].length < %d, 'String must contain at most %d character(s)')", val, val-1)) - case "lte": - refines = append(refines, fmt.Sprintf(".refine((val) => [...val].length <= %s, 'String must contain at most %s character(s)')", valValue, valValue)) - case "contains": - validateStr.WriteString(fmt.Sprintf(".includes(\"%s\")", valValue)) - case "endswith": - validateStr.WriteString(fmt.Sprintf(".endsWith(\"%s\")", valValue)) - case "startswith": - validateStr.WriteString(fmt.Sprintf(".startsWith(\"%s\")", valValue)) - case "eq": - refines = append(refines, fmt.Sprintf(".refine((val) => val === \"%s\")", valValue)) - case "ne": - refines = append(refines, fmt.Sprintf(".refine((val) => val !== \"%s\")", valValue)) + if h, ok := c.customTags[valName]; ok { + // The type parameter is string since this is a string validation context. + // Custom tag handlers may inspect it to vary their output by field type. + v := h(c, reflect.TypeOf(""), valValue, 0) + validators = append(validators, stringValidator{tag: "_custom", arg: v}) + continue + } - default: - panic(fmt.Sprintf("unknown validation: %s", part)) + switch { + case valName == "omitempty": + // skip + case valName == "oneof" && valValue != "": + vals := splitParamsRegex.FindAllString(rawPart[len("oneof="):], -1) + for i := 0; i < len(vals); i++ { + vals[i] = escapeJSString(strings.ReplaceAll(vals[i], "'", "")) } - } else { - switch part { - case "omitempty": - case "required": - validateStr.WriteString(".min(1)") - case "email": - // email is more readable than copying the regex in regexes.go but could be incompatible - // Also there is an open issue https://github.com/go-playground/validator/issues/517 - // https://github.com/puellanivis/pedantic-regexps/blob/master/email.go - // solution is there in the comments but not implemented yet - validateStr.WriteString(".email()") - case "url": - // url is more readable than copying the regex in regexes.go but could be incompatible - validateStr.WriteString(".url()") - case "ipv4": - validateStr.WriteString(".ip({ version: \"v4\" })") - case "ip4_addr": - validateStr.WriteString(".ip({ version: \"v4\" })") - case "ipv6": - validateStr.WriteString(".ip({ version: \"v6\" })") - case "ip6_addr": - validateStr.WriteString(".ip({ version: \"v6\" })") - case "ip": - validateStr.WriteString(".ip()") - case "ip_addr": - validateStr.WriteString(".ip()") - case "http_url": - // url is more readable than copying the regex in regexes.go but could be incompatible - validateStr.WriteString(".url()") - case "url_encoded": - validateStr.WriteString(fmt.Sprintf(".regex(/%s/)", uRLEncodedRegexString)) - case "alpha": - validateStr.WriteString(fmt.Sprintf(".regex(/%s/)", alphaRegexString)) - case "alphanum": - validateStr.WriteString(fmt.Sprintf(".regex(/%s/)", alphaNumericRegexString)) - case "alphanumunicode": - validateStr.WriteString(fmt.Sprintf(".regex(/%s/)", alphaUnicodeNumericRegexString)) - case "alphaunicode": - validateStr.WriteString(fmt.Sprintf(".regex(/%s/)", alphaUnicodeRegexString)) - case "ascii": - validateStr.WriteString(fmt.Sprintf(".regex(/%s/)", aSCIIRegexString)) - case "boolean": - validateStr.WriteString(".enum(['true', 'false'])") - case "lowercase": - refines = append(refines, ".refine((val) => val === val.toLowerCase())") - case "number": - validateStr.WriteString(fmt.Sprintf(".regex(/%s/)", numberRegexString)) - case "numeric": - validateStr.WriteString(fmt.Sprintf(".regex(/%s/)", numericRegexString)) - case "uppercase": - refines = append(refines, ".refine((val) => val === val.toUpperCase())") - case "base64": - validateStr.WriteString(fmt.Sprintf(".regex(/%s/)", base64RegexString)) - case "mongodb": - validateStr.WriteString(fmt.Sprintf(".regex(/%s/)", mongodbRegexString)) - case "datetime": - validateStr.WriteString(".datetime()") - case "hexadecimal": - validateStr.WriteString(fmt.Sprintf(".regex(/%s/)", hexadecimalRegexString)) - case "json": - // TODO: Better error messages with this - // const literalSchema = z.union([z.string(), z.number(), z.boolean(), z.null()]); - //type Literal = z.infer; - //type Json = Literal | { [key: string]: Json } | Json[]; - //const jsonSchema: z.ZodType = z.lazy(() => - // z.union([literalSchema, z.array(jsonSchema), z.record(jsonSchema)]) - //); - // - //jsonSchema.parse(data); - - refines = append(refines, ".refine((val) => { try { JSON.parse(val); return true } catch { return false } })") - case "jwt": - validateStr.WriteString(fmt.Sprintf(".regex(/%s/)", jWTRegexString)) - case "latitude": - validateStr.WriteString(fmt.Sprintf(".regex(/%s/)", latitudeRegexString)) - case "longitude": - validateStr.WriteString(fmt.Sprintf(".regex(/%s/)", longitudeRegexString)) - case "uuid": - validateStr.WriteString(fmt.Sprintf(".regex(/%s/)", uUIDRegexString)) - case "uuid3": - validateStr.WriteString(fmt.Sprintf(".regex(/%s/)", uUID3RegexString)) - case "uuid3_rfc4122": - validateStr.WriteString(fmt.Sprintf(".regex(/%s/)", uUID3RFC4122RegexString)) - case "uuid4": - validateStr.WriteString(fmt.Sprintf(".regex(/%s/)", uUID4RegexString)) - case "uuid4_rfc4122": - validateStr.WriteString(fmt.Sprintf(".regex(/%s/)", uUID4RFC4122RegexString)) - case "uuid5": - validateStr.WriteString(fmt.Sprintf(".regex(/%s/)", uUID5RegexString)) - case "uuid5_rfc4122": - validateStr.WriteString(fmt.Sprintf(".regex(/%s/)", uUID5RFC4122RegexString)) - case "uuid_rfc4122": - validateStr.WriteString(fmt.Sprintf(".regex(/%s/)", uUIDRFC4122RegexString)) - case "md4": - validateStr.WriteString(fmt.Sprintf(".regex(/%s/)", md4RegexString)) - case "md5": - validateStr.WriteString(fmt.Sprintf(".regex(/%s/)", md5RegexString)) - case "sha256": - validateStr.WriteString(fmt.Sprintf(".regex(/%s/)", sha256RegexString)) - case "sha384": - validateStr.WriteString(fmt.Sprintf(".regex(/%s/)", sha384RegexString)) - case "sha512": - validateStr.WriteString(fmt.Sprintf(".regex(/%s/)", sha512RegexString)) + enumText := fmt.Sprintf("z.enum([\"%s\"] as const)", strings.Join(vals, "\", \"")) + validators = append(validators, stringValidator{tag: "oneof", arg: enumText}) + case valName == "boolean": + validators = append(validators, stringValidator{tag: "boolean", arg: `z.enum(["true", "false"] as const)`}) + case knownStringTags[valName]: + validators = append(validators, stringValidator{tag: valName, arg: valValue}) + default: + panic(fmt.Sprintf("unknown validation: %s", rawPart)) + } + } - default: - panic(fmt.Sprintf("unknown validation: %s", part)) + return validators +} + +func (c *Converter) renderStringSchema(validators []stringValidator) string { + // Phase 1: Classify validators + hasFormat := false + hasUnion := false + hasEnum := false + formatCount := 0 + + for _, v := range validators { + if formatTags[v.tag] { + hasFormat = true + formatCount++ + } + if unionTags[v.tag] { + hasUnion = true + } + if v.tag == "oneof" || v.tag == "boolean" { + hasEnum = true + } + } + + // Phase 2: Validate combinations + if hasFormat && hasUnion { + panic("cannot combine format validator with union validator (e.g. email + ip)") + } + if formatCount > 1 { + panic("cannot combine multiple format validators (e.g. email + url)") + } + + // Phase 3: Handle enum — return early, other validators are redundant + if hasEnum { + for _, v := range validators { + if v.tag == "oneof" || v.tag == "boolean" { + return v.arg } } } - for _, refine := range refines { - validateStr.WriteString(refine) + // Phase 4: Render v3 + if c.zodV3 { + var chain strings.Builder + for _, v := range validators { + chain.WriteString(c.renderV3Chain(v)) + } + return "z.string()" + chain.String() } - return validateStr.String() + // Phase 5: Render v4 + + // Case 1: Union (ip/ip_addr) + if hasUnion { + var armChain strings.Builder + for _, v := range validators { + if unionTags[v.tag] { + continue + } + armChain.WriteString(renderChain(v)) + } + ac := armChain.String() + return fmt.Sprintf("z.union([z.string().check(z.ipv4())%s, z.string().check(z.ipv6())%s])", ac, ac) + } + + // Case 2: Format present — use z.string() base with .check() for format + if hasFormat { + var chain strings.Builder + for _, v := range validators { + if formatTags[v.tag] { + chain.WriteString(c.renderV4FormatCheck(v)) + } else { + chain.WriteString(renderChain(v)) + } + } + return "z.string()" + chain.String() + } + + // Case 3: No format/union — plain string + var chain strings.Builder + for _, v := range validators { + chain.WriteString(renderChain(v)) + } + return "z.string()" + chain.String() +} + +// escapeJSString escapes a string so it can be safely interpolated into a +// JavaScript double-quoted string literal. Uses json.Marshal for complete +// handling of quotes, backslashes, newlines, and control characters, then +// strips the outer quotes. +func escapeJSString(s string) string { + b, _ := json.Marshal(s) + // json.Marshal wraps in quotes: "foo" → strip them + return string(b[1 : len(b)-1]) +} + +// requireIntArg validates that arg is a valid integer for the given tag name. +// Returns the parsed value. Panics if arg is not a valid integer. +func requireIntArg(tag, arg string) int { + val, err := strconv.Atoi(arg) + if err != nil { + panic(fmt.Sprintf("%s= requires an integer argument, got: %s", tag, arg)) + } + return val +} + +// requireNumericArg validates that arg is a valid number (integer or float). +// Panics if arg is not a valid number. +func requireNumericArg(tag, arg string) { + if _, err := strconv.ParseFloat(arg, 64); err != nil { + panic(fmt.Sprintf("%s= requires a numeric argument, got: %s", tag, arg)) + } +} + +// regexChainMap maps validator tags to their regex pattern strings. +// Used by renderChain and renderV3Chain to generate .regex() calls. +var regexChainMap = map[string]string{ + "url_encoded": uRLEncodedRegexString, + "alpha": alphaRegexString, + "alphanum": alphaNumericRegexString, + "ascii": aSCIIRegexString, + "number": numberRegexString, + "numeric": numericRegexString, + "mongodb": mongodbRegexString, + "latitude": latitudeRegexString, + "longitude": longitudeRegexString, + "md4": md4RegexString, +} + +func renderRegex(pattern string) string { + return fmt.Sprintf(".regex(/%s/)", pattern) +} + +// unicodeRegexChainMap is like regexChainMap but for patterns needing the /u flag. +var unicodeRegexChainMap = map[string]string{ + "alphanumunicode": alphaUnicodeNumericRegexString, + "alphaunicode": alphaUnicodeRegexString, +} + +func renderUnicodeRegex(pattern string) string { + return fmt.Sprintf(".regex(/%s/u)", pattern) } -func (c *Converter) preprocessValidationTagPart(part string, refines *[]string, validateStr *strings.Builder) (string, string, bool) { +// renderChain is used by both v3 and v4 rendering +func renderChain(v stringValidator) string { + // Regex-based validators + if pattern, ok := regexChainMap[v.tag]; ok { + return renderRegex(pattern) + } + if pattern, ok := unicodeRegexChainMap[v.tag]; ok { + return renderUnicodeRegex(pattern) + } + + switch v.tag { + case "required": + return ".min(1)" + case "contains": + return fmt.Sprintf(`.includes("%s")`, escapeJSString(v.arg)) + case "startswith": + return fmt.Sprintf(`.startsWith("%s")`, escapeJSString(v.arg)) + case "endswith": + return fmt.Sprintf(`.endsWith("%s")`, escapeJSString(v.arg)) + case "eq": + return fmt.Sprintf(`.refine((val) => val === "%s")`, escapeJSString(v.arg)) + case "ne": + return fmt.Sprintf(`.refine((val) => val !== "%s")`, escapeJSString(v.arg)) + case "len": + requireIntArg("len", v.arg) + return fmt.Sprintf(".refine((val) => [...val].length === %s, 'String must contain %s character(s)')", v.arg, v.arg) + case "min": + requireIntArg("min", v.arg) + return fmt.Sprintf(".refine((val) => [...val].length >= %s, 'String must contain at least %s character(s)')", v.arg, v.arg) + case "max": + requireIntArg("max", v.arg) + return fmt.Sprintf(".refine((val) => [...val].length <= %s, 'String must contain at most %s character(s)')", v.arg, v.arg) + case "gt": + val := requireIntArg("gt", v.arg) + return fmt.Sprintf(".refine((val) => [...val].length > %d, 'String must contain at least %d character(s)')", val, val+1) + case "gte": + requireIntArg("gte", v.arg) + return fmt.Sprintf(".refine((val) => [...val].length >= %s, 'String must contain at least %s character(s)')", v.arg, v.arg) + case "lt": + val := requireIntArg("lt", v.arg) + return fmt.Sprintf(".refine((val) => [...val].length < %d, 'String must contain at most %d character(s)')", val, val-1) + case "lte": + requireIntArg("lte", v.arg) + return fmt.Sprintf(".refine((val) => [...val].length <= %s, 'String must contain at most %s character(s)')", v.arg, v.arg) + case "lowercase": + return ".refine((val) => val === val.toLowerCase())" + case "uppercase": + return ".refine((val) => val === val.toUpperCase())" + case "json": + return ".refine((val) => { try { JSON.parse(val); return true } catch { return false } })" + case "_custom": + return v.arg + default: + // Format/union tags (email, url, ip, etc.) are handled by + // renderV3Chain or renderV4FormatCheck, not here. + if !formatTags[v.tag] && !unionTags[v.tag] { + panic(fmt.Sprintf("renderChain: unhandled tag %q", v.tag)) + } + return "" + } +} + +// v3FormatRegexMap maps format validator tags to their v3 regex pattern strings. +// These tags have v4 top-level builders but fall back to regex in v3. +var v3FormatRegexMap = map[string]string{ + "base64": base64RegexString, + "hexadecimal": hexadecimalRegexString, + "jwt": jWTRegexString, + "uuid": uUIDRegexString, + "uuid3": uUID3RegexString, + "uuid3_rfc4122": uUID3RFC4122RegexString, + "uuid4": uUID4RegexString, + "uuid4_rfc4122": uUID4RFC4122RegexString, + "uuid5": uUID5RegexString, + "uuid5_rfc4122": uUID5RFC4122RegexString, + "uuid_rfc4122": uUIDRFC4122RegexString, + "md5": md5RegexString, + "sha256": sha256RegexString, + "sha384": sha384RegexString, + "sha512": sha512RegexString, +} + +func (c *Converter) renderV3Chain(v stringValidator) string { + if s := renderChain(v); s != "" { + return s + } + + // v3 format regex fallbacks (these have v4 top-level builders but use regex in v3) + if pattern, ok := v3FormatRegexMap[v.tag]; ok { + return renderRegex(pattern) + } + + switch v.tag { + case "email": + return ".email()" + case "url": + return ".url()" + case "ip", "ip_addr": + return ".ip()" + case "ipv4", "ip4_addr": + return `.ip({ version: "v4" })` + case "ipv6", "ip6_addr": + return `.ip({ version: "v6" })` + case "http_url": + return ".url()" + case "datetime": + return ".datetime()" + default: + panic(fmt.Sprintf("renderV3Chain: unhandled tag %q", v.tag)) + } +} + +func (c *Converter) renderV4FormatCheck(v stringValidator) string { + switch v.tag { + case "email": + return ".check(z.email())" + case "url": + return ".check(z.url())" + case "http_url": + return ".check(z.httpUrl())" + case "ipv4", "ip4_addr": + return ".check(z.ipv4())" + case "ipv6", "ip6_addr": + return ".check(z.ipv6())" + case "base64": + return ".check(z.base64())" + case "datetime": + return ".check(z.iso.datetime())" + case "hexadecimal": + return ".check(z.hex())" + case "jwt": + return ".check(z.jwt())" + case "uuid": + return ".check(z.uuid())" + case "uuid3", "uuid3_rfc4122": + return `.check(z.uuid({ version: "v3" }))` + case "uuid4", "uuid4_rfc4122": + return `.check(z.uuid({ version: "v4" }))` + case "uuid5", "uuid5_rfc4122": + return `.check(z.uuid({ version: "v5" }))` + case "uuid_rfc4122": + return ".check(z.uuid())" + case "md5": + return `.check(z.hash("md5"))` + case "sha256": + return `.check(z.hash("sha256"))` + case "sha384": + return `.check(z.hash("sha384"))` + case "sha512": + return `.check(z.hash("sha512"))` + default: + panic(fmt.Sprintf("renderV4FormatCheck: unhandled format tag %q", v.tag)) + } +} + +// isPartialRecordKeySchema returns true if the key schema represents a finite +// set of keys (enum), meaning z.partialRecord should be used instead of z.record. +func isPartialRecordKeySchema(schema string) bool { + schema = strings.TrimSpace(schema) + return strings.HasPrefix(schema, "z.enum(") +} + +func (c *Converter) parseValidationTagPart(part string) (string, string, bool) { part = strings.TrimSpace(part) if part == "" { return "", "", true @@ -1123,8 +1477,17 @@ func (c *Converter) preprocessValidationTagPart(part string, refines *[]string, return "", "", true } + return valName, valValue, false +} + +func (c *Converter) preprocessValidationTagPart(part string, refines *[]string, validateStr *strings.Builder, t reflect.Type) (string, string, bool) { + valName, valValue, done := c.parseValidationTagPart(part) + if done { + return "", "", true + } + if h, ok := c.customTags[valName]; ok { - v := h(c, reflect.TypeOf(0), valValue, 0) + v := h(c, t, valValue, 0) if strings.HasPrefix(v, ".refine") { *refines = append(*refines, v) } else { @@ -1252,7 +1615,7 @@ func getTypeNameWithGenerics(name string) string { typeArgs := strings.Split(name[typeArgsIdx+1:len(name)-1], ",") for _, arg := range typeArgs { - sb.WriteString(strings.ToTitle(arg[:1])) // Capitalize first letter + sb.WriteString(strings.ToUpper(arg[:1])) // Capitalize first letter sb.WriteString(arg[1:]) } diff --git a/zod_test.go b/zod_test.go index dea0857..8757818 100644 --- a/zod_test.go +++ b/zod_test.go @@ -8,8 +8,103 @@ import ( "time" "github.com/stretchr/testify/assert" + "github.com/xorcare/golden" ) +// goldenAssert wraps golden.Assert, prepending metadata comments to the file. +// The metadata is used by the docker type-check script to determine which zod +// version to install and whether to include the file in type checking. +// +// All golden files are type-checked by default. +func goldenAssert(t *testing.T, data string, version string) { + t.Helper() + var lines []string + if version != "" { + lines = append(lines, "// @zod-version: "+version) + } + lines = append(lines, "// @typecheck") + header := strings.Join(lines, "\n") + "\n" + golden.Assert(t, []byte(header+data)) +} + +// assertSchema is a golden file test helper for Zod schema output. +// +// When no versions are specified, it asserts that v3 and v4 produce identical +// output and golden-tests that output once. +// +// When one version is specified ("v3" or "v4"), it golden-tests that version's +// output directly without a subtest. +// +// When multiple versions are specified, it creates a subtest per version and +// golden-tests each independently. +func assertSchema(t *testing.T, schema any, versions ...string) { + t.Helper() + + optsFor := func(ver string) []Opt { + if ver == "v3" { + return []Opt{WithZodV3()} + } + return nil + } + + switch len(versions) { + case 0: + v3out := StructToZodSchema(schema, WithZodV3()) + v4out := StructToZodSchema(schema) + assert.Equal(t, v3out, v4out) + goldenAssert(t, v4out, "") + case 1: + goldenAssert(t, StructToZodSchema(schema, optsFor(versions[0])...), versions[0]) + default: + for _, ver := range versions { + t.Run(ver, func(t *testing.T) { + goldenAssert(t, StructToZodSchema(schema, optsFor(ver)...), ver) + }) + } + } +} + +// buildValidatorConverter creates a converter with dynamically-built single-field structs. +// Each entry maps a name to a validate tag. The field type is determined by fieldType. +func buildValidatorConverter(fieldType reflect.Type, validators []struct{ name, tag string }, opts ...Opt) *Converter { + c := NewConverterWithOpts(opts...) + for _, v := range validators { + field := reflect.StructField{ + Name: "Value", + Type: fieldType, + Tag: reflect.StructTag(fmt.Sprintf(`validate:"%s" json:"value"`, v.tag)), + } + st := reflect.StructOf([]reflect.StructField{field}) + c.AddTypeWithName(reflect.New(st).Elem().Interface(), v.name) + } + return c +} + +// assertValidators golden-tests a list of validators. +// With no versions: asserts v3==v4, writes one golden file. +// With versions specified: writes separate golden files per version. +func assertValidators(t *testing.T, fieldType reflect.Type, validators []struct{ name, tag string }, versions ...string) { + t.Helper() + switch len(versions) { + case 0: + v3 := buildValidatorConverter(fieldType, validators, WithZodV3()) + v4 := buildValidatorConverter(fieldType, validators) + assert.Equal(t, v3.Export(), v4.Export()) + goldenAssert(t, v4.Export(), "") + default: + for _, ver := range versions { + t.Run(ver, func(t *testing.T) { + var opts []Opt + if ver == "v3" { + opts = append(opts, WithZodV3()) + } + c := buildValidatorConverter(fieldType, validators, opts...) + goldenAssert(t, c.Export(), ver) + }) + } + } +} + func TestFieldName(t *testing.T) { assert.Equal(t, fieldName(reflect.StructField{Name: "RCONPassword"}), @@ -66,16 +161,7 @@ func TestStructSimple(t *testing.T) { Age int Height float64 } - assert.Equal(t, - `export const UserSchema = z.object({ - Name: z.string(), - Age: z.number(), - Height: z.number(), -}) -export type User = z.infer - -`, - StructToZodSchema(User{})) + assertSchema(t, User{}) } func TestStructSimpleWithOmittedField(t *testing.T) { @@ -85,16 +171,7 @@ func TestStructSimpleWithOmittedField(t *testing.T) { Height float64 NotExported string `json:"-"` } - assert.Equal(t, - `export const UserSchema = z.object({ - Name: z.string(), - Age: z.number(), - Height: z.number(), -}) -export type User = z.infer - -`, - StructToZodSchema(User{})) + assertSchema(t, User{}) } func TestStructSimplePrefix(t *testing.T) { @@ -103,16 +180,10 @@ func TestStructSimplePrefix(t *testing.T) { Age int Height float64 } - assert.Equal(t, - `export const BotUserSchema = z.object({ - Name: z.string(), - Age: z.number(), - Height: z.number(), -}) -export type BotUser = z.infer - -`, - StructToZodSchema(User{}, WithPrefix("Bot"))) + v3out := StructToZodSchema(User{}, WithPrefix("Bot"), WithZodV3()) + v4out := StructToZodSchema(User{}, WithPrefix("Bot")) + assert.Equal(t, v3out, v4out) + goldenAssert(t, v4out, "") } func TestNestedStruct(t *testing.T) { @@ -127,38 +198,14 @@ func TestNestedStruct(t *testing.T) { HasName Tags []string } - assert.Equal(t, - `export const HasIDSchema = z.object({ - ID: z.string(), -}) -export type HasID = z.infer - -export const HasNameSchema = z.object({ - name: z.string(), -}) -export type HasName = z.infer - -export const UserSchema = z.object({ - Tags: z.string().array().nullable(), -}).merge(HasIDSchema).merge(HasNameSchema) -export type User = z.infer - -`, - StructToZodSchema(User{})) + assertSchema(t, User{}, "v3", "v4") } func TestStringArray(t *testing.T) { type User struct { Tags []string } - assert.Equal(t, - `export const UserSchema = z.object({ - Tags: z.string().array().nullable(), -}) -export type User = z.infer - -`, - StructToZodSchema(User{})) + assertSchema(t, User{}) } func TestStringNestedArray(t *testing.T) { @@ -166,14 +213,7 @@ func TestStringNestedArray(t *testing.T) { type User struct { TagPairs []TagPair } - assert.Equal(t, - `export const UserSchema = z.object({ - TagPairs: z.string().array().length(2).array().nullable(), -}) -export type User = z.infer - -`, - StructToZodSchema(User{})) + assertSchema(t, User{}) } func TestStructSlice(t *testing.T) { @@ -182,16 +222,7 @@ func TestStructSlice(t *testing.T) { Name string } } - assert.Equal(t, - `export const UserSchema = z.object({ - Favourites: z.object({ - Name: z.string(), - }).array().nullable(), -}) -export type User = z.infer - -`, - StructToZodSchema(User{})) + assertSchema(t, User{}) } func TestStructSliceOptional(t *testing.T) { @@ -200,16 +231,7 @@ func TestStructSliceOptional(t *testing.T) { Name string } `json:",omitempty"` } - assert.Equal(t, - `export const UserSchema = z.object({ - Favourites: z.object({ - Name: z.string(), - }).array().optional(), -}) -export type User = z.infer - -`, - StructToZodSchema(User{})) + assertSchema(t, User{}) } func TestStructSliceOptionalNullable(t *testing.T) { @@ -218,16 +240,7 @@ func TestStructSliceOptionalNullable(t *testing.T) { Name string } `json:",omitempty"` } - assert.Equal(t, - `export const UserSchema = z.object({ - Favourites: z.object({ - Name: z.string(), - }).array().optional().nullable(), -}) -export type User = z.infer - -`, - StructToZodSchema(User{})) + assertSchema(t, User{}) } func TestStringOptional(t *testing.T) { @@ -235,15 +248,7 @@ func TestStringOptional(t *testing.T) { Name string Nickname string `json:",omitempty"` } - assert.Equal(t, - `export const UserSchema = z.object({ - Name: z.string(), - Nickname: z.string().optional(), -}) -export type User = z.infer - -`, - StructToZodSchema(User{})) + assertSchema(t, User{}) } func TestStringNullable(t *testing.T) { @@ -251,15 +256,7 @@ func TestStringNullable(t *testing.T) { Name string Nickname *string } - assert.Equal(t, - `export const UserSchema = z.object({ - Name: z.string(), - Nickname: z.string().nullable(), -}) -export type User = z.infer - -`, - StructToZodSchema(User{})) + assertSchema(t, User{}) } func TestStringOptionalNotNullable(t *testing.T) { @@ -267,15 +264,7 @@ func TestStringOptionalNotNullable(t *testing.T) { Name string Nickname *string `json:",omitempty"` // nil values are omitted } - assert.Equal(t, - `export const UserSchema = z.object({ - Name: z.string(), - Nickname: z.string().optional(), -}) -export type User = z.infer - -`, - StructToZodSchema(User{})) + assertSchema(t, User{}) } func TestStringOptionalNullable(t *testing.T) { @@ -283,15 +272,16 @@ func TestStringOptionalNullable(t *testing.T) { Name string Nickname **string `json:",omitempty"` // nil values are omitted } - assert.Equal(t, - `export const UserSchema = z.object({ - Name: z.string(), - Nickname: z.string().optional().nullable(), -}) -export type User = z.infer + assertSchema(t, User{}) +} -`, - StructToZodSchema(User{})) +func TestOmitZero(t *testing.T) { + type Payload struct { + Name string + Nickname string `json:",omitzero"` + Email *string `json:",omitzero"` + } + assertSchema(t, Payload{}) } func TestStringArrayNullable(t *testing.T) { @@ -299,15 +289,7 @@ func TestStringArrayNullable(t *testing.T) { Name string Tags []*string } - assert.Equal(t, - `export const UserSchema = z.object({ - Name: z.string(), - Tags: z.string().array().nullable(), -}) -export type User = z.infer - -`, - StructToZodSchema(User{})) + assertSchema(t, User{}) } func TestNullableWithValidations(t *testing.T) { @@ -365,845 +347,326 @@ func TestNullableWithValidations(t *testing.T) { // StringNullable2 string `json:",omitempty" validate:"omitempty,min=2,max=5"` } - assert.Equal(t, - `export const UserSchema = z.object({ - Name: z.string().min(1), - PtrMapOptionalNullable1: z.record(z.string(), z.any()).optional().nullable(), - PtrMapOptionalNullable2: z.record(z.string(), z.any()).refine((val) => Object.keys(val).length >= 2, 'Map too small').refine((val) => Object.keys(val).length <= 5, 'Map too large').optional().nullable(), - PtrMap1: z.record(z.string(), z.any()).refine((val) => Object.keys(val).length >= 2, 'Map too small').refine((val) => Object.keys(val).length <= 5, 'Map too large'), - PtrMap2: z.record(z.string(), z.any()).refine((val) => Object.keys(val).length >= 2, 'Map too small').refine((val) => Object.keys(val).length <= 5, 'Map too large'), - PtrMapNullable: z.record(z.string(), z.any()).refine((val) => Object.keys(val).length >= 2, 'Map too small').refine((val) => Object.keys(val).length <= 5, 'Map too large').nullable(), - MapOptional1: z.record(z.string(), z.any()).optional(), - MapOptional2: z.record(z.string(), z.any()).refine((val) => Object.keys(val).length >= 2, 'Map too small').refine((val) => Object.keys(val).length <= 5, 'Map too large').optional(), - Map1: z.record(z.string(), z.any()).refine((val) => Object.keys(val).length >= 2, 'Map too small').refine((val) => Object.keys(val).length <= 5, 'Map too large'), - Map2: z.record(z.string(), z.any()).refine((val) => Object.keys(val).length >= 2, 'Map too small').refine((val) => Object.keys(val).length <= 5, 'Map too large'), - MapNullable: z.record(z.string(), z.any()).refine((val) => Object.keys(val).length >= 2, 'Map too small').refine((val) => Object.keys(val).length <= 5, 'Map too large').nullable(), - PtrSliceOptionalNullable1: z.string().array().optional().nullable(), - PtrSliceOptionalNullable2: z.string().array().min(2).max(5).optional().nullable(), - PtrSlice1: z.string().array().min(2).max(5), - PtrSlice2: z.string().array().min(2).max(5), - PtrSliceNullable: z.string().array().min(2).max(5).nullable(), - SliceOptional1: z.string().array().optional(), - SliceOptional2: z.string().array().min(2).max(5).optional(), - Slice1: z.string().array().min(2).max(5), - Slice2: z.string().array().min(2).max(5), - SliceNullable: z.string().array().min(2).max(5).nullable(), - PtrIntOptional1: z.number().optional(), - PtrIntOptional2: z.number().gte(2).lte(5).optional(), - PtrInt1: z.number().gte(2).lte(5), - PtrInt2: z.number().gte(2).lte(5), - PtrIntNullable: z.number().gte(2).lte(5).nullable(), - PtrStringOptional1: z.string().optional(), - PtrStringOptional2: z.string().refine((val) => [...val].length >= 2, 'String must contain at least 2 character(s)').refine((val) => [...val].length <= 5, 'String must contain at most 5 character(s)').optional(), - PtrString1: z.string().refine((val) => [...val].length >= 2, 'String must contain at least 2 character(s)').refine((val) => [...val].length <= 5, 'String must contain at most 5 character(s)'), - PtrString2: z.string().refine((val) => [...val].length >= 2, 'String must contain at least 2 character(s)').refine((val) => [...val].length <= 5, 'String must contain at most 5 character(s)'), - PtrStringNullable: z.string().refine((val) => [...val].length >= 2, 'String must contain at least 2 character(s)').refine((val) => [...val].length <= 5, 'String must contain at most 5 character(s)').nullable(), -}) -export type User = z.infer - -`, - StructToZodSchema(User{})) + assertSchema(t, User{}) } func TestStringValidations(t *testing.T) { - type Eq struct { - Name string `validate:"eq=hello"` - } - assert.Equal(t, - `export const EqSchema = z.object({ - Name: z.string().refine((val) => val === "hello"), -}) -export type Eq = z.infer - -`, - StructToZodSchema(Eq{})) - - type Ne struct { - Name string `validate:"ne=hello"` - } - assert.Equal(t, - `export const NeSchema = z.object({ - Name: z.string().refine((val) => val !== "hello"), -}) -export type Ne = z.infer - -`, - StructToZodSchema(Ne{})) - - type OneOf struct { - Name string `validate:"oneof=hello world"` - } - assert.Equal(t, - `export const OneOfSchema = z.object({ - Name: z.enum(["hello", "world"] as const), -}) -export type OneOf = z.infer - -`, - StructToZodSchema(OneOf{})) - - type OneOfSeparated struct { - Name string `validate:"oneof='a b c' 'd e f'"` - } - assert.Equal(t, - `export const OneOfSeparatedSchema = z.object({ - Name: z.enum(["a b c", "d e f"] as const), -}) -export type OneOfSeparated = z.infer - -`, - StructToZodSchema(OneOfSeparated{})) - - // TODO: This test case is not supported yet even for the go-validator package whose logic - // I stole to parse the value after oneof=. - // - // type OneOfEscaped struct { - // Name string `validate:"oneof='a b c' 'd e f' 'g\\' h'"` - // } - // assert.Equal(t, - // `export const OneOfEscapedSchema = z.object({ - // Name: z.string().enum(["a b c", "d e f", "g' h"]), - //}) - //export type OneOfEscaped = z.infer - // - //`, - // StructToZodSchema(OneOfEscaped{})) - - // Same story as above. - // type OneOfEscaped2 struct { - // Name string `validate:"oneof='a b c' 'd e f' 'g\x27 h'"` - // } - // assert.Equal(t, - // `export const OneOfEscapedSchema = z.object({ - // Name: z.string().enum(["a b c", "d e f", "g' h"]), - //}) - //export type OneOfEscaped = z.infer - // - //`, - // StructToZodSchema(OneOfEscaped2{})) - - type Len struct { - Name string `validate:"len=5"` - } - assert.Equal(t, - `export const LenSchema = z.object({ - Name: z.string().refine((val) => [...val].length === 5, 'String must contain 5 character(s)'), -}) -export type Len = z.infer - -`, - StructToZodSchema(Len{})) - - type Min struct { - Name string `validate:"min=5"` - } - assert.Equal(t, - `export const MinSchema = z.object({ - Name: z.string().refine((val) => [...val].length >= 5, 'String must contain at least 5 character(s)'), -}) -export type Min = z.infer - -`, - StructToZodSchema(Min{})) - - type Max struct { - Name string `validate:"max=5"` - } - assert.Equal(t, - `export const MaxSchema = z.object({ - Name: z.string().refine((val) => [...val].length <= 5, 'String must contain at most 5 character(s)'), -}) -export type Max = z.infer - -`, - StructToZodSchema(Max{})) - - type MinMax struct { - Name string `validate:"min=3,max=7"` - } - assert.Equal(t, - `export const MinMaxSchema = z.object({ - Name: z.string().refine((val) => [...val].length >= 3, 'String must contain at least 3 character(s)').refine((val) => [...val].length <= 7, 'String must contain at most 7 character(s)'), -}) -export type MinMax = z.infer - -`, - StructToZodSchema(MinMax{})) - - type Gt struct { - Name string `validate:"gt=5"` - } - assert.Equal(t, - `export const GtSchema = z.object({ - Name: z.string().refine((val) => [...val].length > 5, 'String must contain at least 6 character(s)'), -}) -export type Gt = z.infer - -`, - StructToZodSchema(Gt{})) - - type Gte struct { - Name string `validate:"gte=5"` - } - assert.Equal(t, - `export const GteSchema = z.object({ - Name: z.string().refine((val) => [...val].length >= 5, 'String must contain at least 5 character(s)'), -}) -export type Gte = z.infer - -`, - StructToZodSchema(Gte{})) - - type Lt struct { - Name string `validate:"lt=5"` - } - assert.Equal(t, - `export const LtSchema = z.object({ - Name: z.string().refine((val) => [...val].length < 5, 'String must contain at most 4 character(s)'), -}) -export type Lt = z.infer - -`, - StructToZodSchema(Lt{})) - - type Lte struct { - Name string `validate:"lte=5"` - } - assert.Equal(t, - `export const LteSchema = z.object({ - Name: z.string().refine((val) => [...val].length <= 5, 'String must contain at most 5 character(s)'), -}) -export type Lte = z.infer - -`, - StructToZodSchema(Lte{})) - - type Contains struct { - Name string `validate:"contains=hello"` - } - assert.Equal(t, - `export const ContainsSchema = z.object({ - Name: z.string().includes("hello"), -}) -export type Contains = z.infer - -`, - StructToZodSchema(Contains{})) - - type EndsWith struct { - Name string `validate:"endswith=hello"` - } - assert.Equal(t, - `export const EndsWithSchema = z.object({ - Name: z.string().endsWith("hello"), -}) -export type EndsWith = z.infer - -`, - StructToZodSchema(EndsWith{})) - - type StartsWith struct { - Name string `validate:"startswith=hello"` - } - assert.Equal(t, - `export const StartsWithSchema = z.object({ - Name: z.string().startsWith("hello"), -}) -export type StartsWith = z.infer - -`, - StructToZodSchema(StartsWith{})) - - type Bad struct { - Name string `validate:"bad=hello"` - } - assert.Panics(t, func() { - StructToZodSchema(Bad{}) + assertValidators(t, reflect.TypeOf(""), []struct{ name, tag string }{ + {"eq", "eq=hello"}, + {"ne", "ne=hello"}, + {"oneof", "oneof=hello world"}, + {"oneof_separated", "oneof='a b c' 'd e f'"}, + {"len", "len=5"}, + {"min", "min=5"}, + {"max", "max=5"}, + {"minmax", "min=3,max=7"}, + {"gt", "gt=5"}, + {"gte", "gte=5"}, + {"lt", "lt=5"}, + {"lte", "lte=5"}, + {"contains", "contains=hello"}, + {"endswith", "endswith=hello"}, + {"startswith", "startswith=hello"}, + {"required", "required"}, + {"url_encoded", "url_encoded"}, + {"alpha", "alpha"}, + {"alphanum", "alphanum"}, + {"alphanumunicode", "alphanumunicode"}, + {"alphaunicode", "alphaunicode"}, + {"ascii", "ascii"}, + {"boolean_validator", "boolean"}, + {"lowercase", "lowercase"}, + {"number_validator", "number"}, + {"numeric", "numeric"}, + {"uppercase", "uppercase"}, + {"mongodb", "mongodb"}, + {"json_validator", "json"}, + {"latitude", "latitude"}, + {"longitude", "longitude"}, + {"md4", "md4"}, }) - type Required struct { - Name string `validate:"required"` - } - assert.Equal(t, - `export const RequiredSchema = z.object({ - Name: z.string().min(1), -}) -export type Required = z.infer - -`, - StructToZodSchema(Required{})) - - type Email struct { - Name string `validate:"email"` - } - assert.Equal(t, - `export const EmailSchema = z.object({ - Name: z.string().email(), -}) -export type Email = z.infer - -`, - StructToZodSchema(Email{})) - - type URL struct { - Name string `validate:"url"` - } - assert.Equal(t, - `export const URLSchema = z.object({ - Name: z.string().url(), -}) -export type URL = z.infer - -`, - StructToZodSchema(URL{})) - - type IPv4 struct { - Name string `validate:"ipv4"` - } - assert.Equal(t, - `export const IPv4Schema = z.object({ - Name: z.string().ip({ version: "v4" }), -}) -export type IPv4 = z.infer - -`, - StructToZodSchema(IPv4{})) - - type IPv6 struct { - Name string `validate:"ipv6"` - } - assert.Equal(t, - `export const IPv6Schema = z.object({ - Name: z.string().ip({ version: "v6" }), -}) -export type IPv6 = z.infer - -`, - StructToZodSchema(IPv6{})) - - type IP4Addr struct { - Name string `validate:"ip4_addr"` - } - assert.Equal(t, - `export const IP4AddrSchema = z.object({ - Name: z.string().ip({ version: "v4" }), -}) -export type IP4Addr = z.infer - -`, - StructToZodSchema(IP4Addr{})) - - type IP6Addr struct { - Name string `validate:"ip6_addr"` - } - assert.Equal(t, - `export const IP6AddrSchema = z.object({ - Name: z.string().ip({ version: "v6" }), -}) -export type IP6Addr = z.infer - -`, - StructToZodSchema(IP6Addr{})) - - type IP struct { - Name string `validate:"ip"` - } - assert.Equal(t, - `export const IPSchema = z.object({ - Name: z.string().ip(), -}) -export type IP = z.infer - -`, - StructToZodSchema(IP{})) - - type IPAddr struct { - Name string `validate:"ip_addr"` - } - assert.Equal(t, - `export const IPAddrSchema = z.object({ - Name: z.string().ip(), -}) -export type IPAddr = z.infer - -`, - StructToZodSchema(IPAddr{})) - - type HttpURL struct { - Name string `validate:"http_url"` - } - assert.Equal(t, - `export const HttpURLSchema = z.object({ - Name: z.string().url(), -}) -export type HttpURL = z.infer - -`, - StructToZodSchema(HttpURL{})) - - type URLEncoded struct { - Name string `validate:"url_encoded"` - } - assert.Equal(t, - fmt.Sprintf(`export const URLEncodedSchema = z.object({ - Name: z.string().regex(/%s/), -}) -export type URLEncoded = z.infer - -`, uRLEncodedRegexString), - StructToZodSchema(URLEncoded{})) - - type Alpha struct { - Name string `validate:"alpha"` - } - assert.Equal(t, - fmt.Sprintf(`export const AlphaSchema = z.object({ - Name: z.string().regex(/%s/), -}) -export type Alpha = z.infer - -`, alphaRegexString), - StructToZodSchema(Alpha{})) - - type AlphaNum struct { - Name string `validate:"alphanum"` - } - assert.Equal(t, - fmt.Sprintf(`export const AlphaNumSchema = z.object({ - Name: z.string().regex(/%s/), -}) -export type AlphaNum = z.infer - -`, alphaNumericRegexString), - StructToZodSchema(AlphaNum{})) - - type AlphaNumUnicode struct { - Name string `validate:"alphanumunicode"` - } - assert.Equal(t, - fmt.Sprintf(`export const AlphaNumUnicodeSchema = z.object({ - Name: z.string().regex(/%s/), -}) -export type AlphaNumUnicode = z.infer - -`, alphaUnicodeNumericRegexString), - StructToZodSchema(AlphaNumUnicode{})) - - type AlphaUnicode struct { - Name string `validate:"alphaunicode"` - } - assert.Equal(t, - fmt.Sprintf(`export const AlphaUnicodeSchema = z.object({ - Name: z.string().regex(/%s/), -}) -export type AlphaUnicode = z.infer - -`, alphaUnicodeRegexString), - StructToZodSchema(AlphaUnicode{})) - - type ASCII struct { - Name string `validate:"ascii"` - } - assert.Equal(t, - fmt.Sprintf(`export const ASCIISchema = z.object({ - Name: z.string().regex(/%s/), -}) -export type ASCII = z.infer - -`, aSCIIRegexString), - StructToZodSchema(ASCII{})) - - type Boolean struct { - Name string `validate:"boolean"` - } - assert.Equal(t, - `export const BooleanSchema = z.object({ - Name: z.enum(['true', 'false']), -}) -export type Boolean = z.infer - -`, - StructToZodSchema(Boolean{})) - - type Lowercase struct { - Name string `validate:"lowercase"` - } - assert.Equal(t, - `export const LowercaseSchema = z.object({ - Name: z.string().refine((val) => val === val.toLowerCase()), -}) -export type Lowercase = z.infer - -`, - StructToZodSchema(Lowercase{})) - - type Number struct { - Name string `validate:"number"` - } - assert.Equal(t, - fmt.Sprintf(`export const NumberSchema = z.object({ - Name: z.string().regex(/%s/), -}) -export type Number = z.infer - -`, numberRegexString), - StructToZodSchema(Number{})) - - type Numeric struct { - Name string `validate:"numeric"` - } - assert.Equal(t, - fmt.Sprintf(`export const NumericSchema = z.object({ - Name: z.string().regex(/%s/), -}) -export type Numeric = z.infer - -`, numericRegexString), - StructToZodSchema(Numeric{})) - - type Uppercase struct { - Name string `validate:"uppercase"` - } - assert.Equal(t, - `export const UppercaseSchema = z.object({ - Name: z.string().refine((val) => val === val.toUpperCase()), -}) -export type Uppercase = z.infer - -`, - StructToZodSchema(Uppercase{})) - - type Base64 struct { - Name string `validate:"base64"` - } - assert.Equal(t, - fmt.Sprintf(`export const Base64Schema = z.object({ - Name: z.string().regex(/%s/), -}) -export type Base64 = z.infer - -`, base64RegexString), - StructToZodSchema(Base64{})) + t.Run("bad tag panics", func(t *testing.T) { + type Bad struct { + Name string `validate:"bad=hello"` + } + assert.Panics(t, func() { StructToZodSchema(Bad{}) }) + }) - type mongodb struct { - Name string `validate:"mongodb"` - } - assert.Equal(t, - fmt.Sprintf(`export const mongodbSchema = z.object({ - Name: z.string().regex(/%s/), -}) -export type mongodb = z.infer + t.Run("unknown tag panics", func(t *testing.T) { + type Bad2 struct { + Name string `validate:"bad2"` + } + assert.Panics(t, func() { StructToZodSchema(Bad2{}) }) + }) -`, mongodbRegexString), - StructToZodSchema(mongodb{})) + t.Run("gt with non-integer panics", func(t *testing.T) { + type Bad struct { + Name string `validate:"gt=abc"` + } + assert.Panics(t, func() { StructToZodSchema(Bad{}) }) + }) - type datetime struct { - Name string `validate:"datetime"` - } - assert.Equal(t, - `export const datetimeSchema = z.object({ - Name: z.string().datetime(), -}) -export type datetime = z.infer + t.Run("lt with non-integer panics", func(t *testing.T) { + type Bad struct { + Name string `validate:"lt=abc"` + } + assert.Panics(t, func() { StructToZodSchema(Bad{}) }) + }) -`, - StructToZodSchema(datetime{})) + t.Run("escapeJSString escapes quotes and backslashes", func(t *testing.T) { + assert.Equal(t, `foo\"bar`, escapeJSString(`foo"bar`)) + assert.Equal(t, `foo\\bar`, escapeJSString(`foo\bar`)) + assert.Equal(t, `a\"b\\c`, escapeJSString(`a"b\c`)) + assert.Equal(t, `no change`, escapeJSString(`no change`)) + }) - type Hexadecimal struct { - Name string `validate:"hexadecimal"` - } - assert.Equal(t, - fmt.Sprintf(`export const HexadecimalSchema = z.object({ - Name: z.string().regex(/%s/), -}) -export type Hexadecimal = z.infer + t.Run("special chars in tag values are escaped in output", func(t *testing.T) { + // Go struct tag syntax can't contain raw quotes, but reflect.StructOf can. + // This tests that the generated JS output correctly escapes them. + c := NewConverterWithOpts() -`, hexadecimalRegexString), - StructToZodSchema(Hexadecimal{})) + contains := reflect.StructOf([]reflect.StructField{{ + Name: "Value", Type: reflect.TypeOf(""), + Tag: reflect.StructTag(`validate:"contains=foo\"bar" json:"value"`), + }}) + c.AddTypeWithName(reflect.New(contains).Elem().Interface(), "ContainsQuote") - type json struct { - Name string `validate:"json"` - } - assert.Equal(t, - `export const jsonSchema = z.object({ - Name: z.string().refine((val) => { try { JSON.parse(val); return true } catch { return false } }), -}) -export type json = z.infer + eq := reflect.StructOf([]reflect.StructField{{ + Name: "Value", Type: reflect.TypeOf(""), + Tag: reflect.StructTag(`validate:"eq=a\\b" json:"value"`), + }}) + c.AddTypeWithName(reflect.New(eq).Elem().Interface(), "EqBackslash") -`, - StructToZodSchema(json{})) + goldenAssert(t, c.Export(), "") + }) - type Latitude struct { - Name string `validate:"latitude"` - } - assert.Equal(t, - fmt.Sprintf(`export const LatitudeSchema = z.object({ - Name: z.string().regex(/%s/), -}) -export type Latitude = z.infer + t.Run("enum ignores other validators", func(t *testing.T) { + c := NewConverterWithOpts() + c.AddTypeWithName(struct { + V string `validate:"required,oneof=a b" json:"v"` + }{}, "RequiredOneof") + c.AddTypeWithName(struct { + V string `validate:"oneof=a b,contains=x" json:"v"` + }{}, "OneofContains") + c.AddTypeWithName(struct { + V string `validate:"oneof=a b,startswith=a" json:"v"` + }{}, "OneofStartswith") + c.AddTypeWithName(struct { + V string `validate:"oneof=a b,endswith=z" json:"v"` + }{}, "OneofEndswith") + c.AddTypeWithName(struct { + V string `validate:"oneof='127.0.0.1' '::1',ip" json:"v"` + }{}, "OneofIp") + goldenAssert(t, c.Export(), "") + }) -`, latitudeRegexString), - StructToZodSchema(Latitude{})) + t.Run("string tag order is preserved around v4 format helpers", func(t *testing.T) { + type Payload struct { + TrimmedThenEmail string `validate:"trim,email"` + EmailThenTrimmed string `validate:"email,trim"` + } - type Longitude struct { - Name string `validate:"longitude"` - } - assert.Equal(t, - fmt.Sprintf(`export const LongitudeSchema = z.object({ - Name: z.string().regex(/%s/), -}) -export type Longitude = z.infer + customTagHandlers := map[string]CustomFn{ + "trim": func(c *Converter, t reflect.Type, validate string, i int) string { + return ".trim()" + }, + } -`, longitudeRegexString), - StructToZodSchema(Longitude{})) + for _, ver := range []string{"v3", "v4"} { + t.Run(ver, func(t *testing.T) { + opts := []Opt{WithCustomTags(customTagHandlers)} + if ver == "v3" { + opts = append(opts, WithZodV3()) + } + goldenAssert(t, NewConverterWithOpts(opts...).Convert(Payload{}), ver) + }) + } + }) +} - type UUID struct { - Name string `validate:"uuid"` +func TestOneofRequired(t *testing.T) { + type Payload struct { + Status string `json:"status" validate:"required,oneof=active inactive"` + // Would generate the same schema as the above. This doesn't mirror go validator exactly as it allows empty values. + // For now let's assume that empty strings are not valid enum values, but we can revisit if there's demand for that. + StatusImplicitRequired string `json:"statusImplicitRequired" validate:"oneof=active inactive"` + Channel *string `json:"channel,omitempty" validate:"omitempty,oneof=email sms"` } - assert.Equal(t, - fmt.Sprintf(`export const UUIDSchema = z.object({ - Name: z.string().regex(/%s/), -}) -export type UUID = z.infer -`, uUIDRegexString), - StructToZodSchema(UUID{})) - - type UUID3 struct { - Name string `validate:"uuid3"` - } - assert.Equal(t, - fmt.Sprintf(`export const UUID3Schema = z.object({ - Name: z.string().regex(/%s/), -}) -export type UUID3 = z.infer + assertSchema(t, Payload{}) +} -`, uUID3RegexString), - StructToZodSchema(UUID3{})) +func TestZodV4Defaults(t *testing.T) { + t.Run("embedded structs use shape spreads", func(t *testing.T) { + type HasID struct { + ID string + } + type HasName struct { + Name string `json:"name"` + } + type User struct { + HasID + HasName + Tags []string + } - type UUID3RFC4122 struct { - Name string `validate:"uuid3_rfc4122"` - } - assert.Equal(t, - fmt.Sprintf(`export const UUID3RFC4122Schema = z.object({ - Name: z.string().regex(/%s/), -}) -export type UUID3RFC4122 = z.infer + assertSchema(t, User{}, "v4") + }) -`, uUID3RFC4122RegexString), - StructToZodSchema(UUID3RFC4122{})) + t.Run("string formats use zod v4 builders", func(t *testing.T) { + type Payload struct { + Email string `validate:"email"` + Link string `validate:"http_url"` + Base64 string `validate:"base64"` + ID string `validate:"uuid4"` + Checksum string `validate:"md5"` + } - type UUID4 struct { - Name string `validate:"uuid4"` - } - assert.Equal(t, - fmt.Sprintf(`export const UUID4Schema = z.object({ - Name: z.string().regex(/%s/), -}) -export type UUID4 = z.infer + assertSchema(t, Payload{}, "v4") + }) -`, uUID4RegexString), - StructToZodSchema(UUID4{})) + t.Run("custom tag before required base64 preserves min(1)", func(t *testing.T) { + type Payload struct { + Data string `validate:"trim,required,base64"` + Hex string `validate:"trim,required,hexadecimal"` + } - type UUID4RFC4122 struct { - Name string `validate:"uuid4_rfc4122"` - } - assert.Equal(t, - fmt.Sprintf(`export const UUID4RFC4122Schema = z.object({ - Name: z.string().regex(/%s/), -}) -export type UUID4RFC4122 = z.infer + customTagHandlers := map[string]CustomFn{ + "trim": func(c *Converter, t reflect.Type, validate string, i int) string { + return ".trim()" + }, + } -`, uUID4RFC4122RegexString), - StructToZodSchema(UUID4RFC4122{})) + goldenAssert(t, NewConverterWithOpts(WithCustomTags(customTagHandlers)).Convert(Payload{}), "v4") + }) - type UUID5 struct { - Name string `validate:"uuid5"` - } - assert.Equal(t, - fmt.Sprintf(`export const UUID5Schema = z.object({ - Name: z.string().regex(/%s/), -}) -export type UUID5 = z.infer + t.Run("ip unions inherit generic string constraints", func(t *testing.T) { + type Payload struct { + Address string `validate:"ip,required,max=45"` + } -`, uUID5RegexString), - StructToZodSchema(UUID5{})) + assertSchema(t, Payload{}, "v4") + }) - type UUID5RFC4122 struct { - Name string `validate:"uuid5_rfc4122"` - } - assert.Equal(t, - fmt.Sprintf(`export const UUID5RFC4122Schema = z.object({ - Name: z.string().regex(/%s/), -}) -export type UUID5RFC4122 = z.infer + t.Run("format combined with union panics", func(t *testing.T) { + type Payload struct { + Address string `validate:"email,ip"` + } -`, uUID5RFC4122RegexString), - StructToZodSchema(UUID5RFC4122{})) + assert.Panics(t, func() { StructToZodSchema(Payload{}) }) + }) - type UUIDRFC4122 struct { - Name string `validate:"uuid_rfc4122"` - } - assert.Equal(t, - fmt.Sprintf(`export const UUIDRFC4122Schema = z.object({ - Name: z.string().regex(/%s/), -}) -export type UUIDRFC4122 = z.infer + t.Run("multiple formats panics", func(t *testing.T) { + type Payload struct { + Value string `validate:"email,url"` + } -`, uUIDRFC4122RegexString), - StructToZodSchema(UUIDRFC4122{})) + assert.Panics(t, func() { StructToZodSchema(Payload{}) }) + }) - type MD4 struct { - Name string `validate:"md4"` - } - assert.Equal(t, - fmt.Sprintf(`export const MD4Schema = z.object({ - Name: z.string().regex(/%s/), -}) -export type MD4 = z.infer + t.Run("optional format with nullable pointer", func(t *testing.T) { + type Payload struct { + Email *string `validate:"omitempty,email" json:"email"` + } -`, md4RegexString), - StructToZodSchema(MD4{})) + assertSchema(t, Payload{}, "v3", "v4") + }) - type MD5 struct { - Name string `validate:"md5"` - } - assert.Equal(t, - fmt.Sprintf(`export const MD5Schema = z.object({ - Name: z.string().regex(/%s/), -}) -export type MD5 = z.infer + t.Run("named field shadows embedded field", func(t *testing.T) { + type Base struct { + ID string `json:"id"` + Name string `json:"name"` + } -`, md5RegexString), - StructToZodSchema(MD5{})) + type Child struct { + Base + ID int `json:"id"` // shadows Base.ID, keeps Base.Name + } - type SHA256 struct { - Name string `validate:"sha256"` - } - assert.Equal(t, - fmt.Sprintf(`export const SHA256Schema = z.object({ - Name: z.string().regex(/%s/), -}) -export type SHA256 = z.infer + assertSchema(t, Child{}, "v3", "v4") + }) -`, sha256RegexString), - StructToZodSchema(SHA256{})) + t.Run("recursive embedded shapes preserve encounter order for duplicate keys", func(t *testing.T) { + type Base struct { + ID string `json:"id"` + } - type SHA384 struct { - Name string `validate:"sha384"` - } - assert.Equal(t, - fmt.Sprintf(`export const SHA384Schema = z.object({ - Name: z.string().regex(/%s/), -}) -export type SHA384 = z.infer + type Node struct { + Base + ID int `json:"id"` + Next *Node `json:"next"` + } -`, sha384RegexString), - StructToZodSchema(SHA384{})) + goldenAssert(t, StructToZodSchema(Node{}), "v4") + }) - type SHA512 struct { - Name string `validate:"sha512"` - } - assert.Equal(t, - fmt.Sprintf(`export const SHA512Schema = z.object({ - Name: z.string().regex(/%s/), -}) -export type SHA512 = z.infer + t.Run("recursive embedded shapes keep named fields after spreads to override embedded fields", func(t *testing.T) { + type TreeNode struct { + Value string + CreatedAt time.Time + Children *[]TreeNode + UpdatedAt string + } -`, sha512RegexString), - StructToZodSchema(SHA512{})) + type Tree struct { + TreeNode + UpdatedAt time.Time + } - type Bad2 struct { - Name string `validate:"bad2"` - } - assert.Panics(t, func() { - StructToZodSchema(Bad2{}) + assertSchema(t, Tree{}, "v4") }) } func TestNumberValidations(t *testing.T) { - type User1 struct { - Age int `validate:"gte=18,lte=60"` - } - assert.Equal(t, - `export const User1Schema = z.object({ - Age: z.number().gte(18).lte(60), -}) -export type User1 = z.infer - -`, StructToZodSchema(User1{})) - - type User2 struct { - Age int `validate:"gt=18,lt=60"` - } - assert.Equal(t, - `export const User2Schema = z.object({ - Age: z.number().gt(18).lt(60), -}) -export type User2 = z.infer - -`, StructToZodSchema(User2{})) - - type User3 struct { - Age int `validate:"eq=18"` - } - assert.Equal(t, - `export const User3Schema = z.object({ - Age: z.number().refine((val) => val === 18), -}) -export type User3 = z.infer - -`, StructToZodSchema(User3{})) - - type User4 struct { - Age int `validate:"ne=18"` - } - assert.Equal(t, - `export const User4Schema = z.object({ - Age: z.number().refine((val) => val !== 18), -}) -export type User4 = z.infer - -`, StructToZodSchema(User4{})) - - type User5 struct { - Age int `validate:"oneof=18 19 20"` - } - assert.Equal(t, - `export const User5Schema = z.object({ - Age: z.number().refine((val) => [18, 19, 20].includes(val)), -}) -export type User5 = z.infer - -`, StructToZodSchema(User5{})) - - type User6 struct { - Age int `validate:"min=18,max=60"` - } - assert.Equal(t, - `export const User6Schema = z.object({ - Age: z.number().gte(18).lte(60), -}) -export type User6 = z.infer - -`, StructToZodSchema(User6{})) + assertValidators(t, reflect.TypeOf(0), []struct{ name, tag string }{ + {"gte_lte", "gte=18,lte=60"}, + {"gt_lt", "gt=18,lt=60"}, + {"eq", "eq=18"}, + {"ne", "ne=18"}, + {"oneof", "oneof=18 19 20"}, + {"min_max", "min=18,max=60"}, + {"len", "len=18"}, + }) - type User7 struct { - Age int `validate:"len=18"` - } - assert.Equal(t, - `export const User7Schema = z.object({ - Age: z.number().refine((val) => val === 18), -}) -export type User7 = z.infer + t.Run("bad tag panics", func(t *testing.T) { + type Bad struct { + Age int `validate:"bad=18"` + } + assert.Panics(t, func() { StructToZodSchema(Bad{}) }) + }) -`, StructToZodSchema(User7{})) + t.Run("non-numeric arg panics", func(t *testing.T) { + tags := []string{"gt", "gte", "lt", "lte", "min", "max", "eq", "ne", "len"} + for _, tag := range tags { + t.Run(tag, func(t *testing.T) { + assert.Panics(t, func() { + st := reflect.StructOf([]reflect.StructField{{ + Name: "V", + Type: reflect.TypeOf(0), + Tag: reflect.StructTag(fmt.Sprintf(`validate:"%s=abc" json:"v"`, tag)), + }}) + StructToZodSchema(reflect.New(st).Elem().Interface()) + }) + }) + } + t.Run("oneof", func(t *testing.T) { + assert.Panics(t, func() { + st := reflect.StructOf([]reflect.StructField{{ + Name: "V", + Type: reflect.TypeOf(0), + Tag: reflect.StructTag(`validate:"oneof=1 abc 3" json:"v"`), + }}) + StructToZodSchema(reflect.New(st).Elem().Interface()) + }) + }) + }) - type User8 struct { - Age int `validate:"bad=18"` - } - assert.Panics(t, func() { - StructToZodSchema(User8{}) + t.Run("float args are accepted", func(t *testing.T) { + type S struct { + V float64 `validate:"gt=1.5,lt=9.9"` + } + assert.NotPanics(t, func() { StructToZodSchema(S{}) }) }) } @@ -1212,15 +675,7 @@ func TestInterfaceAny(t *testing.T) { Name string Metadata interface{} } - assert.Equal(t, - `export const UserSchema = z.object({ - Name: z.string(), - Metadata: z.any(), -}) -export type User = z.infer - -`, - StructToZodSchema(User{})) + assertSchema(t, User{}) } func TestInterfacePointerAny(t *testing.T) { @@ -1228,261 +683,96 @@ func TestInterfacePointerAny(t *testing.T) { Name string Metadata *interface{} } - assert.Equal(t, - `export const UserSchema = z.object({ - Name: z.string(), - Metadata: z.any(), -}) -export type User = z.infer - -`, - StructToZodSchema(User{})) + assertSchema(t, User{}) } func TestInterfaceEmptyAny(t *testing.T) { type User struct { Name string - Metadata interface{} `json:",omitempty"` - } - assert.Equal(t, - `export const UserSchema = z.object({ - Name: z.string(), - Metadata: z.any(), -}) -export type User = z.infer - -`, - StructToZodSchema(User{})) -} - -func TestInterfacePointerEmptyAny(t *testing.T) { - type User struct { - Name string - Metadata *interface{} `json:",omitempty"` - } - assert.Equal(t, - `export const UserSchema = z.object({ - Name: z.string(), - Metadata: z.any(), -}) -export type User = z.infer - -`, - StructToZodSchema(User{})) -} - -func TestMapStringToString(t *testing.T) { - type User struct { - Name string - Metadata map[string]string - } - assert.Equal(t, - `export const UserSchema = z.object({ - Name: z.string(), - Metadata: z.record(z.string(), z.string()).nullable(), -}) -export type User = z.infer - -`, - StructToZodSchema(User{})) -} - -func TestMapStringToInterface(t *testing.T) { - type User struct { - Name string - Metadata map[string]interface{} - } - assert.Equal(t, - `export const UserSchema = z.object({ - Name: z.string(), - Metadata: z.record(z.string(), z.any()).nullable(), -}) -export type User = z.infer - -`, - StructToZodSchema(User{})) -} - -func TestMapWithStruct(t *testing.T) { - type PostWithMetaData struct { - Title string - } - type User struct { - MapWithStruct map[string]PostWithMetaData - } - assert.Equal(t, - `export const PostWithMetaDataSchema = z.object({ - Title: z.string(), -}) -export type PostWithMetaData = z.infer - -export const UserSchema = z.object({ - MapWithStruct: z.record(z.string(), PostWithMetaDataSchema).nullable(), -}) -export type User = z.infer - -`, StructToZodSchema(User{})) -} - -func TestMapWithValidations(t *testing.T) { - type Required struct { - Map map[string]string `validate:"required"` - } - assert.Equal(t, - `export const RequiredSchema = z.object({ - Map: z.record(z.string(), z.string()), -}) -export type Required = z.infer - -`, StructToZodSchema(Required{})) - - type Min struct { - Map map[string]string `validate:"min=1"` - } - assert.Equal(t, - `export const MinSchema = z.object({ - Map: z.record(z.string(), z.string()).refine((val) => Object.keys(val).length >= 1, 'Map too small'), -}) -export type Min = z.infer - -`, StructToZodSchema(Min{})) - - type Max struct { - Map map[string]string `validate:"max=1"` - } - assert.Equal(t, - `export const MaxSchema = z.object({ - Map: z.record(z.string(), z.string()).refine((val) => Object.keys(val).length <= 1, 'Map too large'), -}) -export type Max = z.infer - -`, StructToZodSchema(Max{})) - - type Len struct { - Map map[string]string `validate:"len=1"` - } - assert.Equal(t, - `export const LenSchema = z.object({ - Map: z.record(z.string(), z.string()).refine((val) => Object.keys(val).length === 1, 'Map wrong size'), -}) -export type Len = z.infer - -`, StructToZodSchema(Len{})) - - type MinMax struct { - Map map[string]string `validate:"min=1,max=2"` - } - assert.Equal(t, - `export const MinMaxSchema = z.object({ - Map: z.record(z.string(), z.string()).refine((val) => Object.keys(val).length >= 1, 'Map too small').refine((val) => Object.keys(val).length <= 2, 'Map too large'), -}) -export type MinMax = z.infer - -`, StructToZodSchema(MinMax{})) - - type Eq struct { - Map map[string]string `validate:"eq=1"` - } - assert.Equal(t, - `export const EqSchema = z.object({ - Map: z.record(z.string(), z.string()).refine((val) => Object.keys(val).length === 1, 'Map wrong size'), -}) -export type Eq = z.infer - -`, StructToZodSchema(Eq{})) - - type Ne struct { - Map map[string]string `validate:"ne=1"` - } - assert.Equal(t, - `export const NeSchema = z.object({ - Map: z.record(z.string(), z.string()).refine((val) => Object.keys(val).length !== 1, 'Map wrong size'), -}) -export type Ne = z.infer - -`, StructToZodSchema(Ne{})) - - type Gt struct { - Map map[string]string `validate:"gt=1"` + Metadata interface{} `json:",omitempty"` } - assert.Equal(t, - `export const GtSchema = z.object({ - Map: z.record(z.string(), z.string()).refine((val) => Object.keys(val).length > 1, 'Map too small'), -}) -export type Gt = z.infer - -`, StructToZodSchema(Gt{})) + assertSchema(t, User{}) +} - type Gte struct { - Map map[string]string `validate:"gte=1"` +func TestInterfacePointerEmptyAny(t *testing.T) { + type User struct { + Name string + Metadata *interface{} `json:",omitempty"` } - assert.Equal(t, - `export const GteSchema = z.object({ - Map: z.record(z.string(), z.string()).refine((val) => Object.keys(val).length >= 1, 'Map too small'), -}) -export type Gte = z.infer - -`, StructToZodSchema(Gte{})) + assertSchema(t, User{}) +} - type Lt struct { - Map map[string]string `validate:"lt=1"` +func TestMapStringToString(t *testing.T) { + type User struct { + Name string + Metadata map[string]string } - assert.Equal(t, - `export const LtSchema = z.object({ - Map: z.record(z.string(), z.string()).refine((val) => Object.keys(val).length < 1, 'Map too large'), -}) -export type Lt = z.infer - -`, StructToZodSchema(Lt{})) + assertSchema(t, User{}) +} - type Lte struct { - Map map[string]string `validate:"lte=1"` +func TestMapStringToInterface(t *testing.T) { + type User struct { + Name string + Metadata map[string]interface{} } - assert.Equal(t, - `export const LteSchema = z.object({ - Map: z.record(z.string(), z.string()).refine((val) => Object.keys(val).length <= 1, 'Map too large'), -}) -export type Lte = z.infer - -`, StructToZodSchema(Lte{})) + assertSchema(t, User{}) +} - type Bad struct { - Map map[string]string `validate:"bad=1"` +func TestMapWithStruct(t *testing.T) { + type PostWithMetaData struct { + Title string } - assert.Panics(t, func() { StructToZodSchema(Bad{}) }) - - type Dive1 struct { - Map map[string]string `validate:"dive,min=2"` + type User struct { + MapWithStruct map[string]PostWithMetaData } - assert.Equal(t, - `export const Dive1Schema = z.object({ - Map: z.record(z.string(), z.string().refine((val) => [...val].length >= 2, 'String must contain at least 2 character(s)')).nullable(), -}) -export type Dive1 = z.infer - -`, StructToZodSchema(Dive1{})) + assertSchema(t, User{}) +} - type Dive2 struct { - Map []map[string]string `validate:"required,dive,min=2,dive,min=3"` - } - assert.Equal(t, - `export const Dive2Schema = z.object({ - Map: z.record(z.string(), z.string().refine((val) => [...val].length >= 3, 'String must contain at least 3 character(s)')).refine((val) => Object.keys(val).length >= 2, 'Map too small').array(), -}) -export type Dive2 = z.infer +func TestMapWithValidations(t *testing.T) { + assertValidators(t, reflect.TypeOf(map[string]string{}), []struct{ name, tag string }{ + {"required", "required"}, + {"min", "min=1"}, + {"max", "max=1"}, + {"len", "len=1"}, + {"minmax", "min=1,max=2"}, + {"eq", "eq=1"}, + {"ne", "ne=1"}, + {"gt", "gt=1"}, + {"gte", "gte=1"}, + {"lt", "lt=1"}, + {"lte", "lte=1"}, + {"dive1", "dive,min=2"}, + }) -`, StructToZodSchema(Dive2{})) + t.Run("dive_nested", func(t *testing.T) { + assertValidators(t, reflect.TypeOf([]map[string]string{}), []struct{ name, tag string }{ + {"dive2", "required,dive,min=2,dive,min=3"}, + {"dive3", "required,dive,min=2,dive,keys,min=3,endkeys,max=4"}, + }) + }) - type Dive3 struct { - Map []map[string]string `validate:"required,dive,min=2,dive,keys,min=3,endkeys,max=4"` - } - assert.Equal(t, - `export const Dive3Schema = z.object({ - Map: z.record(z.string().refine((val) => [...val].length >= 3, 'String must contain at least 3 character(s)'), z.string().refine((val) => [...val].length <= 4, 'String must contain at most 4 character(s)')).refine((val) => Object.keys(val).length >= 2, 'Map too small').array(), -}) -export type Dive3 = z.infer + t.Run("bad tag panics", func(t *testing.T) { + type Bad struct { + Map map[string]string `validate:"bad=1"` + } + assert.Panics(t, func() { StructToZodSchema(Bad{}) }) + }) -`, StructToZodSchema(Dive3{})) + t.Run("non-integer args panic", func(t *testing.T) { + tags := []string{"min", "max", "len", "eq", "ne", "gt", "gte", "lt", "lte"} + for _, tag := range tags { + t.Run(tag, func(t *testing.T) { + assert.Panics(t, func() { + st := reflect.StructOf([]reflect.StructField{{ + Name: "M", + Type: reflect.TypeOf(map[string]string{}), + Tag: reflect.StructTag(fmt.Sprintf(`validate:"%s=abc" json:"m"`, tag)), + }}) + StructToZodSchema(reflect.New(st).Elem().Interface()) + }) + }) + } + }) } func TestMapWithNonStringKey(t *testing.T) { @@ -1491,42 +781,35 @@ func TestMapWithNonStringKey(t *testing.T) { Metadata map[int]string } - assert.Equal(t, - `export const Map1Schema = z.object({ - Name: z.string(), - Metadata: z.record(z.coerce.number(), z.string()).nullable(), -}) -export type Map1 = z.infer - -`, StructToZodSchema(Map1{})) - type Map2 struct { Name string Metadata map[time.Time]string } - assert.Equal(t, - `export const Map2Schema = z.object({ - Name: z.string(), - Metadata: z.record(z.coerce.date(), z.string()).nullable(), -}) -export type Map2 = z.infer - -`, StructToZodSchema(Map2{})) - type Map3 struct { Name string Metadata map[float64]string } - assert.Equal(t, - `export const Map3Schema = z.object({ - Name: z.string(), - Metadata: z.record(z.coerce.number(), z.string()).nullable(), -}) -export type Map3 = z.infer + t.Run("int_key", func(t *testing.T) { + assertSchema(t, Map1{}) + }) + + t.Run("time_key", func(t *testing.T) { + assertSchema(t, Map2{}) + }) -`, StructToZodSchema(Map3{})) + t.Run("float_key", func(t *testing.T) { + assertSchema(t, Map3{}) + }) +} + +func TestMapWithEnumKey(t *testing.T) { + type Payload struct { + Metadata map[string]string `validate:"dive,keys,oneof=draft published,endkeys"` + } + + assertSchema(t, Payload{}, "v3", "v4") } func TestGetValidateKeys(t *testing.T) { @@ -1560,6 +843,14 @@ func TestGetValidateValues(t *testing.T) { assert.Equal(t, "min=3", getValidateValues("min=2,dive,min=3")) assert.Equal(t, "min=3,max=4", getValidateValues("dive,min=3,max=4,dive,min=4,max=5")) assert.Equal(t, "max=4", getValidateValues("min=2,dive,keys,min=3,endkeys,max=4")) + + t.Run("dive keys without endkeys panics", func(t *testing.T) { + assert.Panics(t, func() { getValidateValues("dive,keys,min=3") }) + }) + + t.Run("bare dive returns empty", func(t *testing.T) { + assert.Equal(t, "", getValidateValues("dive")) + }) } func TestGetValidateCurrent(t *testing.T) { @@ -1568,6 +859,120 @@ func TestGetValidateCurrent(t *testing.T) { assert.Equal(t, "min=2,max=3", getValidateCurrent("min=2,max=3,dive,min=2,dive,min=3,max=4")) } +func TestStructTime(t *testing.T) { + type User struct { + Name string + When time.Time + } + assertSchema(t, User{}) +} + +func TestTimeWithRequired(t *testing.T) { + type User struct { + When time.Time `validate:"required"` + } + assertSchema(t, User{}) +} + +func TestDuration(t *testing.T) { + type User struct { + HowLong time.Duration + } + assertSchema(t, User{}) +} + +// Wrapper mimics a generic optional type like 4d63.com/optional.Optional[T]. +// The custom handler resolves the inner type via ConvertType(t.Elem(), ...). +type Wrapper[T any] struct{ Value T } + +func TestCustomTypes(t *testing.T) { + t.Run("custom type mapped to string", func(t *testing.T) { + type Decimal struct { + Value int + Exponent int + } + type User struct { + Name string + Money Decimal + } + + customTypes := map[string]CustomFn{ + "github.com/hypersequent/zen.Decimal": func(c *Converter, t reflect.Type, validate string, i int) string { + return "z.string()" + }, + } + + v3c := NewConverterWithOpts(WithCustomTypes(customTypes), WithZodV3()) + v4c := NewConverterWithOpts(WithCustomTypes(customTypes)) + v3out := v3c.Convert(User{}) + v4out := v4c.Convert(User{}) + assert.Equal(t, v3out, v4out) + goldenAssert(t, v4out, "") + }) + + t.Run("custom type resolves inner generic type", func(t *testing.T) { + type Profile struct { + Bio string + } + type User struct { + MaybeName Wrapper[string] + MaybeAge Wrapper[int] + MaybeHeight Wrapper[float64] + MaybeProfile Wrapper[Profile] + } + + customTypes := map[string]CustomFn{ + "github.com/hypersequent/zen.Wrapper": func(c *Converter, t reflect.Type, validate string, i int) string { + return fmt.Sprintf("%s.optional().nullish()", c.ConvertType(t.Field(0).Type, validate, i)) + }, + } + + v3c := NewConverterWithOpts(WithCustomTypes(customTypes), WithZodV3()) + v4c := NewConverterWithOpts(WithCustomTypes(customTypes)) + v3out := v3c.Convert(User{}) + v4out := v4c.Convert(User{}) + assert.Equal(t, v3out, v4out) + goldenAssert(t, v4out, "") + }) + + t.Run("custom type with nullable control", func(t *testing.T) { + type User struct { + Name string + Email *Wrapper[string] + } + + customTypes := map[string]CustomFn{ + "github.com/hypersequent/zen.Wrapper": func(c *Converter, t reflect.Type, validate string, i int) string { + return fmt.Sprintf("%s.optional()", c.ConvertType(t.Field(0).Type, validate, i)) + }, + } + + v3c := NewConverterWithOpts(WithCustomTypes(customTypes), WithZodV3()) + v4c := NewConverterWithOpts(WithCustomTypes(customTypes)) + v3out := v3c.Convert(User{}) + v4out := v4c.Convert(User{}) + assert.Equal(t, v3out, v4out) + goldenAssert(t, v4out, "") + }) +} + +func TestWithIgnoreTags(t *testing.T) { + type User struct { + Name string `validate:"required,customtag=value"` + } + + t.Run("panics on unknown tag", func(t *testing.T) { + assert.Panics(t, func() { StructToZodSchema(User{}) }) + }) + + t.Run("ignores specified tag", func(t *testing.T) { + assert.NotPanics(t, func() { + StructToZodSchema(User{}, WithIgnoreTags("customtag")) + }) + goldenAssert(t, StructToZodSchema(User{}, WithIgnoreTags("customtag")), "") + }) +} + func TestEverything(t *testing.T) { // The order matters PostWithMetaData needs to be declared after post otherwise it will raise a // `Block-scoped variable 'Post' used before its declaration.` typescript error. @@ -1607,49 +1012,7 @@ func TestEverything(t *testing.T) { MapWithStruct map[string]PostWithMetaData } - assert.Equal(t, - `export const PostSchema = z.object({ - Title: z.string(), -}) -export type Post = z.infer - -export const PostWithMetaDataSchema = z.object({ - Title: z.string(), - Post: PostSchema, -}) -export type PostWithMetaData = z.infer - -export const UserSchema = z.object({ - Name: z.string(), - Nickname: z.string().nullable(), - Age: z.number(), - Height: z.number(), - OldPostWithMetaData: PostWithMetaDataSchema, - Tags: z.string().array().nullable(), - TagsOptional: z.string().array().optional(), - TagsOptionalNullable: z.string().array().optional().nullable(), - Favourites: z.object({ - Name: z.string(), - }).array().nullable(), - Posts: PostSchema.array().nullable(), - Post: PostSchema, - PostOptional: PostSchema.optional(), - PostOptionalNullable: PostSchema.optional().nullable(), - Metadata: z.record(z.string(), z.string()).nullable(), - MetadataOptional: z.record(z.string(), z.string()).optional(), - MetadataOptionalNullable: z.record(z.string(), z.string()).optional().nullable(), - ExtendedProps: z.any(), - ExtendedPropsOptional: z.any(), - ExtendedPropsNullable: z.any(), - ExtendedPropsOptionalNullable: z.any(), - ExtendedPropsVeryIndirect: z.any(), - NewPostWithMetaData: PostWithMetaDataSchema, - VeryNewPost: PostSchema, - MapWithStruct: z.record(z.string(), PostWithMetaDataSchema).nullable(), -}) -export type User = z.infer - -`, StructToZodSchema(User{})) + assertSchema(t, User{}) } func TestEverythingWithValidations(t *testing.T) { @@ -1691,74 +1054,23 @@ func TestEverythingWithValidations(t *testing.T) { VeryNewPost Post MapWithStruct map[string]PostWithMetaData } - assert.Equal(t, - `export const PostSchema = z.object({ - Title: z.string().min(1), -}) -export type Post = z.infer - -export const PostWithMetaDataSchema = z.object({ - Title: z.string().min(1), - Post: PostSchema, -}) -export type PostWithMetaData = z.infer - -export const UserSchema = z.object({ - Name: z.string().min(1), - Nickname: z.string().nullable(), - Age: z.number().gte(18).refine((val) => val !== 0), - Height: z.number().gte(1.5).refine((val) => val !== 0), - OldPostWithMetaData: PostWithMetaDataSchema, - Tags: z.string().array().min(1), - TagsOptional: z.string().array().optional(), - TagsOptionalNullable: z.string().array().optional().nullable(), - Favourites: z.object({ - Name: z.string().min(1), - }).array().nullable(), - Posts: PostSchema.array(), - Post: PostSchema, - PostOptional: PostSchema.optional(), - PostOptionalNullable: PostSchema.optional().nullable(), - Metadata: z.record(z.string(), z.string()).nullable(), - MetadataLength: z.record(z.string(), z.string()).refine((val) => Object.keys(val).length >= 1, 'Map too small').refine((val) => Object.keys(val).length <= 10, 'Map too large'), - MetadataOptional: z.record(z.string(), z.string()).optional(), - MetadataOptionalNullable: z.record(z.string(), z.string()).optional().nullable(), - ExtendedProps: z.any(), - ExtendedPropsOptional: z.any(), - ExtendedPropsNullable: z.any(), - ExtendedPropsOptionalNullable: z.any(), - ExtendedPropsVeryIndirect: z.any(), - NewPostWithMetaData: PostWithMetaDataSchema, - VeryNewPost: PostSchema, - MapWithStruct: z.record(z.string(), PostWithMetaDataSchema).nullable(), -}) -export type User = z.infer - -`, StructToZodSchema(User{})) + assertSchema(t, User{}) } func TestConvertArray(t *testing.T) { - type Array struct { - Arr [10]string - } - assert.Equal(t, - `export const ArraySchema = z.object({ - Arr: z.string().array().length(10), -}) -export type Array = z.infer - -`, StructToZodSchema(Array{})) - - type MultiArray struct { - Arr [10][20][30]string - } - assert.Equal(t, - `export const MultiArraySchema = z.object({ - Arr: z.string().array().length(30).array().length(20).array().length(10), -}) -export type MultiArray = z.infer + t.Run("single", func(t *testing.T) { + type Array struct { + Arr [10]string + } + assertSchema(t, Array{}) + }) -`, StructToZodSchema(MultiArray{})) + t.Run("multi", func(t *testing.T) { + type MultiArray struct { + Arr [10][20][30]string + } + assertSchema(t, MultiArray{}) + }) } func TestConvertSlice(t *testing.T) { @@ -1775,231 +1087,71 @@ func TestConvertSlice(t *testing.T) { type Whim struct { Wham *Foo } - c := NewConverterWithOpts() + types := []interface{}{ Zip{}, Whim{}, } - assert.Equal(t, - `export const FooSchema = z.object({ - Bar: z.string(), - Baz: z.string(), - Quz: z.string(), -}) -export type Foo = z.infer - -export const ZipSchema = z.object({ - Zap: FooSchema.nullable(), -}) -export type Zip = z.infer -export const WhimSchema = z.object({ - Wham: FooSchema.nullable(), -}) -export type Whim = z.infer - -`, c.ConvertSlice(types)) + v3c := NewConverterWithOpts(WithZodV3()) + v4c := NewConverterWithOpts() + v3out := v3c.ConvertSlice(types) + v4out := v4c.ConvertSlice(types) + assert.Equal(t, v3out, v4out) + goldenAssert(t, v4out, "") } func TestConvertSliceWithValidations(t *testing.T) { - type Required struct { - Slice []string `validate:"required"` - } - assert.Equal(t, - `export const RequiredSchema = z.object({ - Slice: z.string().array(), -}) -export type Required = z.infer - -`, StructToZodSchema(Required{})) - - type Min struct { - Slice []string `validate:"min=1"` - } - assert.Equal(t, `export const MinSchema = z.object({ - Slice: z.string().array().min(1), -}) -export type Min = z.infer - -`, StructToZodSchema(Min{})) - - type Max struct { - Slice []string `validate:"max=1"` - } - assert.Equal(t, `export const MaxSchema = z.object({ - Slice: z.string().array().max(1), -}) -export type Max = z.infer - -`, StructToZodSchema(Max{})) - - type Len struct { - Slice []string `validate:"len=1"` - } - assert.Equal(t, `export const LenSchema = z.object({ - Slice: z.string().array().length(1), -}) -export type Len = z.infer - -`, StructToZodSchema(Len{})) - - type Eq struct { - Slice []string `validate:"eq=1"` - } - assert.Equal(t, `export const EqSchema = z.object({ - Slice: z.string().array().length(1), -}) -export type Eq = z.infer - -`, StructToZodSchema(Eq{})) - - type Gt struct { - Slice []string `validate:"gt=1"` - } - assert.Equal(t, `export const GtSchema = z.object({ - Slice: z.string().array().min(2), -}) -export type Gt = z.infer - -`, StructToZodSchema(Gt{})) - - type Gte struct { - Slice []string `validate:"gte=1"` - } - assert.Equal(t, `export const GteSchema = z.object({ - Slice: z.string().array().min(1), -}) -export type Gte = z.infer - -`, StructToZodSchema(Gte{})) - - type Lt struct { - Slice []string `validate:"lt=1"` - } - assert.Equal(t, `export const LtSchema = z.object({ - Slice: z.string().array().max(0), -}) -export type Lt = z.infer - -`, StructToZodSchema(Lt{})) - - type Lte struct { - Slice []string `validate:"lte=1"` - } - assert.Equal(t, `export const LteSchema = z.object({ - Slice: z.string().array().max(1), -}) -export type Lte = z.infer - -`, StructToZodSchema(Lte{})) - - type Ne struct { - Slice []string `validate:"ne=0"` - } - assert.Equal(t, `export const NeSchema = z.object({ - Slice: z.string().array().refine((val) => val.length !== 0), -}) -export type Ne = z.infer - -`, StructToZodSchema(Ne{})) - - assert.Panics(t, func() { - type Bad struct { - Slice []string `validate:"oneof=a b c"` - } - StructToZodSchema(Bad{}) + assertValidators(t, reflect.TypeOf([]string{}), []struct{ name, tag string }{ + {"required", "required"}, + {"min", "min=1"}, + {"max", "max=1"}, + {"len", "len=1"}, + {"eq", "eq=1"}, + {"gt", "gt=1"}, + {"gte", "gte=1"}, + {"lt", "lt=1"}, + {"lte", "lte=1"}, + {"ne", "ne=0"}, }) - type Dive1 struct { - Slice [][]string `validate:"dive,required"` - } - assert.Equal(t, `export const Dive1Schema = z.object({ - Slice: z.string().array().array().nullable(), -}) -export type Dive1 = z.infer - -`, StructToZodSchema(Dive1{})) - - type Dive2 struct { - Slice [][]string `validate:"required,dive,min=1"` - } - assert.Equal(t, `export const Dive2Schema = z.object({ - Slice: z.string().array().min(1).array(), -}) -export type Dive2 = z.infer - -`, StructToZodSchema(Dive2{})) -} - -func TestStructTime(t *testing.T) { - type User struct { - Name string - When time.Time - } - assert.Equal(t, - `export const UserSchema = z.object({ - Name: z.string(), - When: z.coerce.date(), -}) -export type User = z.infer - -`, - StructToZodSchema(User{})) -} - -func TestTimeWithRequired(t *testing.T) { - type User struct { - When time.Time `validate:"required"` - } - assert.Equal(t, - `export const UserSchema = z.object({ - When: z.coerce.date().refine((val) => val.getTime() !== new Date('0001-01-01T00:00:00Z').getTime() && val.getTime() !== new Date(0).getTime(), 'Invalid date'), -}) -export type User = z.infer - -`, - StructToZodSchema(User{})) -} - -func TestDuration(t *testing.T) { - type User struct { - HowLong time.Duration - } - assert.Equal(t, - `export const UserSchema = z.object({ - HowLong: z.number(), -}) -export type User = z.infer - -`, - StructToZodSchema(User{})) -} - -func TestCustom(t *testing.T) { - c := NewConverter(map[string]CustomFn{ - "github.com/hypersequent/zen.Decimal": func(c *Converter, t reflect.Type, validate string, i int) string { - return "z.string()" - }, + t.Run("dive_nested", func(t *testing.T) { + assertValidators(t, reflect.TypeOf([][]string{}), []struct{ name, tag string }{ + {"dive1", "dive,required"}, + {"dive2", "required,dive,min=1"}, + }) }) - type Decimal struct { - Value int - Exponent int - } + t.Run("dive_oneof", func(t *testing.T) { + assertValidators(t, reflect.TypeOf([]string{}), []struct{ name, tag string }{ + {"dive_oneof", "dive,oneof=a b c"}, + }) + }) - type User struct { - Name string - Money Decimal - } - assert.Equal(t, - `export const UserSchema = z.object({ - Name: z.string(), - Money: z.string(), -}) -export type User = z.infer + t.Run("oneof without dive panics", func(t *testing.T) { + assert.Panics(t, func() { + type Bad struct { + Slice []string `validate:"oneof=a b c"` + } + StructToZodSchema(Bad{}) + }) + }) -`, - c.Convert(User{})) + t.Run("non-integer args panic", func(t *testing.T) { + tags := []string{"min", "max", "len", "eq", "ne", "gt", "gte", "lt", "lte"} + for _, tag := range tags { + t.Run(tag, func(t *testing.T) { + assert.Panics(t, func() { + st := reflect.StructOf([]reflect.StructField{{ + Name: "V", + Type: reflect.TypeOf([]string{}), + Tag: reflect.StructTag(fmt.Sprintf(`validate:"%s=abc" json:"v"`, tag)), + }}) + StructToZodSchema(reflect.New(st).Elem().Interface()) + }) + }) + } + }) } func TestRecursive1(t *testing.T) { @@ -2012,25 +1164,7 @@ func TestRecursive1(t *testing.T) { Children []*NestedItem `json:"children"` } - assert.Equal(t, `export type NestedItem = { - id: number, - title: string, - pos: number, - parent_id: number, - project_id: number, - children: NestedItem[] | null, -} -const NestedItemSchemaShape = { - id: z.number(), - title: z.string(), - pos: z.number(), - parent_id: z.number(), - project_id: z.number(), - children: z.lazy(() => NestedItemSchema).array().nullable(), -} -export const NestedItemSchema: z.ZodType = z.object(NestedItemSchemaShape) - -`, StructToZodSchema(NestedItem{})) + assertSchema(t, NestedItem{}, "v3", "v4") } func TestRecursive2(t *testing.T) { @@ -2043,22 +1177,7 @@ func TestRecursive2(t *testing.T) { Child *Node `json:"child"` } - assert.Equal(t, `export type Node = { - value: number, - next: Node | null, -} -const NodeSchemaShape = { - value: z.number(), - next: z.lazy(() => NodeSchema).nullable(), -} -export const NodeSchema: z.ZodType = z.object(NodeSchemaShape) - -export const ParentSchema = z.object({ - child: NodeSchema.nullable(), -}) -export type Parent = z.infer - -`, StructToZodSchema(Parent{})) + assertSchema(t, Parent{}, "v3", "v4") } type TestCyclicA struct { @@ -2091,24 +1210,16 @@ func TestGenerics(t *testing.T) { c.AddType(StringIntPair{}) c.AddType(GenericPair[int, bool]{}) c.AddType(PairMap[string, int, bool]{}) - assert.Equal(t, `export const StringIntPairSchema = z.object({ - First: z.string(), - Second: z.number(), -}) -export type StringIntPair = z.infer -export const GenericPairIntBoolSchema = z.object({ - First: z.number(), - Second: z.boolean(), -}) -export type GenericPairIntBool = z.infer + v3c := NewConverterWithOpts(WithZodV3()) + v3c.AddType(StringIntPair{}) + v3c.AddType(GenericPair[int, bool]{}) + v3c.AddType(PairMap[string, int, bool]{}) -export const PairMapStringIntBoolSchema = z.object({ - items: z.record(z.string(), GenericPairIntBoolSchema).nullable(), -}) -export type PairMapStringIntBool = z.infer - -`, c.Export()) + v3out := v3c.Export() + v4out := c.Export() + assert.Equal(t, v3out, v4out) + goldenAssert(t, v4out, "") } func TestSliceFields(t *testing.T) { @@ -2122,18 +1233,7 @@ func TestSliceFields(t *testing.T) { JSONMinOmitEmpty []int `json:",omitempty" validate:"min=1,omitempty"` } - assert.Equal(t, `export const TestSliceFieldsStructSchema = z.object({ - NoValidate: z.number().array().nullable(), - Required: z.number().array(), - Min: z.number().array().min(1), - OmitEmpty: z.number().array().nullable(), - JSONOmitEmpty: z.number().array().optional(), - MinOmitEmpty: z.number().array().min(1).nullable(), - JSONMinOmitEmpty: z.number().array().min(1).optional(), -}) -export type TestSliceFieldsStruct = z.infer - -`, StructToZodSchema(TestSliceFieldsStruct{})) + assertSchema(t, TestSliceFieldsStruct{}) } func TestCustomTag(t *testing.T) { @@ -2167,22 +1267,47 @@ func TestCustomTag(t *testing.T) { }, } - assert.Equal(t, `export const SortParamsSchema = z.object({ - order: z.enum(["asc", "desc"] as const).optional(), - field: z.string().optional(), -}) -export type SortParams = z.infer + t.Run("v3", func(t *testing.T) { + goldenAssert(t, NewConverterWithOpts(WithCustomTags(customTagHandlers), WithZodV3()).Convert(Request{}), "v3") + }) + t.Run("v4", func(t *testing.T) { + goldenAssert(t, NewConverterWithOpts(WithCustomTags(customTagHandlers)).Convert(Request{}), "v4") + }) +} + +func TestCustomTagReceivesCorrectType(t *testing.T) { + // A "nonzero" custom tag that emits different validation depending on the + // field type: strings check for non-empty, numbers check for non-zero, + // time.Time checks for non-zero date. + handler := map[string]CustomFn{ + "nonzero": func(c *Converter, t reflect.Type, validate string, i int) string { + switch t.Kind() { + case reflect.String: + return `.refine((val) => val !== "", "must not be empty")` + case reflect.Int, reflect.Float64: + return ".refine((val) => val !== 0, \"must not be zero\")" + case reflect.Struct: + if t.Name() == "Time" { + return ".refine((val) => val.getTime() !== 0, \"must not be zero time\")" + } + return ".refine((val) => true)" + default: + return ".refine((val) => true)" + } + }, + } + + type Payload struct { + Name string `json:"name" validate:"nonzero"` + Age int `json:"age" validate:"nonzero"` + When time.Time `json:"when" validate:"nonzero"` + } -export const RequestSchema = z.object({ - PaginationParams: z.object({ - start: z.number().gt(0).optional(), - 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'])})) -export type Request = z.infer + output := NewConverterWithOpts(WithCustomTags(handler)).Convert(Payload{}) -`, NewConverterWithOpts(WithCustomTags(customTagHandlers)).Convert(Request{})) + assert.Contains(t, output, `val !== ""`, "string field should get string-specific check") + assert.Contains(t, output, `val !== 0, "must not be zero"`, "number field should get number-specific check") + assert.Contains(t, output, `val.getTime() !== 0`, "time field should get time-specific check") } func TestRecursiveEmbeddedStruct(t *testing.T) { @@ -2213,54 +1338,26 @@ func TestRecursiveEmbeddedStruct(t *testing.T) { ItemE } - c := NewConverterWithOpts() - c.AddType(ItemA{}) - c.AddType(ItemB{}) - c.AddType(ItemC{}) - c.AddType(ItemD{}) - c.AddType(ItemE{}) - c.AddType(ItemF{}) - - assert.Equal(t, `export type ItemA = { - Name: string, - Children: ItemA[] | null, -} -const ItemASchemaShape = { - Name: z.string(), - Children: z.lazy(() => ItemASchema).array().nullable(), -} -export const ItemASchema: z.ZodType = z.object(ItemASchemaShape) - -export const ItemBSchema = z.object({ - ...ItemASchemaShape, -}) -export type ItemB = z.infer - -export const ItemCSchema = z.object({ -}).merge(ItemBSchema) -export type ItemC = z.infer - -export const ItemDSchema = z.object({ - ItemA: ItemASchema, -}) -export type ItemD = z.infer - -export type ItemE = ItemA & ItemD & { - Children: ItemE[] | null, -} -const ItemESchemaShape = { - ...ItemASchemaShape, - ...ItemDSchema.shape, - Children: z.lazy(() => ItemESchema).array().nullable(), -} -export const ItemESchema: z.ZodType = z.object(ItemESchemaShape) - -export const ItemFSchema = z.object({ - ...ItemESchemaShape, -}) -export type ItemF = z.infer - -`, c.Export()) + t.Run("v3", func(t *testing.T) { + c := NewConverterWithOpts(WithZodV3()) + c.AddType(ItemA{}) + c.AddType(ItemB{}) + c.AddType(ItemC{}) + c.AddType(ItemD{}) + c.AddType(ItemE{}) + c.AddType(ItemF{}) + goldenAssert(t, c.Export(), "v3") + }) + t.Run("v4", func(t *testing.T) { + c := NewConverterWithOpts() + c.AddType(ItemA{}) + c.AddType(ItemB{}) + c.AddType(ItemC{}) + c.AddType(ItemD{}) + c.AddType(ItemE{}) + c.AddType(ItemF{}) + goldenAssert(t, c.Export(), "v4") + }) } func TestRecursiveEmbeddedWithPointersAndDates(t *testing.T) { @@ -2276,25 +1373,7 @@ func TestRecursiveEmbeddedWithPointersAndDates(t *testing.T) { UpdatedAt time.Time } - assert.Equal(t, `export type TreeNode = { - Value: string, - CreatedAt: Date, - Children: TreeNode[] | null, -} -const TreeNodeSchemaShape = { - Value: z.string(), - CreatedAt: z.coerce.date(), - Children: z.lazy(() => TreeNodeSchema).array().nullable(), -} -export const TreeNodeSchema: z.ZodType = z.object(TreeNodeSchemaShape) - -export const TreeSchema = z.object({ - ...TreeNodeSchemaShape, - UpdatedAt: z.coerce.date(), -}) -export type Tree = z.infer - -`, StructToZodSchema(Tree{})) + assertSchema(t, Tree{}, "v3", "v4") }) t.Run("embedded struct with pointer to self and date", func(t *testing.T) { @@ -2309,24 +1388,45 @@ export type Tree = z.infer Title string } - assert.Equal(t, `export type Comment = { - Text: string, - Timestamp: Date, - Reply: Comment | null, -} -const CommentSchemaShape = { - Text: z.string(), - Timestamp: z.coerce.date(), - Reply: z.lazy(() => CommentSchema).nullable(), + assertSchema(t, Article{}, "v3", "v4") + }) } -export const CommentSchema: z.ZodType = z.object(CommentSchemaShape) -export const ArticleSchema = z.object({ - ...CommentSchemaShape, - Title: z.string(), -}) -export type Article = z.infer +func TestFormatValidators(t *testing.T) { + allFormats := []string{ + "email", "url", "http_url", + "ipv4", "ip4_addr", "ipv6", "ip6_addr", + "base64", "datetime", "hexadecimal", "jwt", + "uuid", "uuid3", "uuid3_rfc4122", + "uuid4", "uuid4_rfc4122", + "uuid5", "uuid5_rfc4122", + "uuid_rfc4122", + "md5", "sha256", "sha384", "sha512", + } + + unionFormats := []string{"ip", "ip_addr"} + + toValidators := func(tags []string, prefix string) []struct{ name, tag string } { + out := make([]struct{ name, tag string }, len(tags)) + for i, tag := range tags { + out[i] = struct{ name, tag string }{tag, prefix + tag} + } + return out + } + + t.Run("format only", func(t *testing.T) { + assertValidators(t, reflect.TypeOf(""), toValidators(allFormats, ""), "v3", "v4") + }) + + t.Run("format with required", func(t *testing.T) { + assertValidators(t, reflect.TypeOf(""), toValidators(allFormats, "required,"), "v3", "v4") + }) + + t.Run("union only", func(t *testing.T) { + assertValidators(t, reflect.TypeOf(""), toValidators(unionFormats, ""), "v3", "v4") + }) -`, StructToZodSchema(Article{})) + t.Run("union with required", func(t *testing.T) { + assertValidators(t, reflect.TypeOf(""), toValidators(unionFormats, "required,"), "v3", "v4") }) }