Skip to content

Migrate to Zod 4 and make Zod 3 opt-in#22

Open
ramilamparo wants to merge 36 commits intomainfrom
dev/ram/zod-v4-migration
Open

Migrate to Zod 4 and make Zod 3 opt-in#22
ramilamparo wants to merge 36 commits intomainfrom
dev/ram/zod-v4-migration

Conversation

@ramilamparo
Copy link
Copy Markdown

@ramilamparo ramilamparo commented Apr 1, 2026

Summary

Migrate zen to support Zod v4 as the default output, with v3 available via WithZodV3(). Includes a refactor of string schema generation and a new test infrastructure.

Zod v4 output changes

Format validators use .check() pattern

Format validators use z.string().check(z.email()) instead of z.email() as the schema base. This preserves the z.string() base so that transforms like .trim(), .min(), etc. chain correctly in any position. For example, validate:"trim,email" produces z.string().trim().check(z.email()) which trims before validating. See colinhacks/zod#4642

IP union

ip/ip_addr tags produce z.union([z.string().check(z.ipv4()), z.string().check(z.ipv6())]) with constraints distributed to each arm.

Self-referential getter pattern

get field() { return Schema; } instead of z.lazy(() => Schema).

Embedded struct spreads

...Schema.shape instead of .merge(Schema). Spreads are placed before named fields so that Go's field shadowing semantics are preserved (last key wins in JS).

Partial records

z.partialRecord() for enum-keyed maps.

Invalid combinations panic

  • email,ip (format + union) and email,url (multiple formats) panic instead of producing broken output
  • Enum (oneof/boolean) combined with non-enum validators (e.g. oneof=a b,contains=x) panics — oneof fully constrains the value
  • dive,keys without matching endkeys panics with a clear message
  • Non-numeric arguments to numeric validators (gt=abc) panic via requireIntArg/requireNumericArg

String schema generation refactor

The original validateString built Zod output directly using a strings.Builder — mixing parsing and rendering in one pass with no intermediate representation. This made v3/v4 branching difficult and led to edge cases with tag ordering.

  • parseStringValidators — pure parser producing []stringValidator via knownStringTags map lookup
  • renderStringSchema — version-aware renderer that classifies validators, validates combinations, and renders the complete schema in one pass
  • renderChain — shared rendering for version-independent validators
  • renderV3Chain — v3-specific format chains (.email(), .ip(), .regex())
  • renderV4FormatCheck — v4 format check chains (.check(z.email()), .check(z.uuid()), etc.)
  • Regex patterns consolidated into maps with renderRegex() helpers

Bug fixes (pre-existing)

  • preprocessValidationTagPart now passes actual reflect.Type to custom tag handlers instead of always reflect.TypeOf("")
  • strings.ToTitle (deprecated) replaced with strings.ToUpper
  • getValidateValues guards against missing endkeys and bare dive

Security

  • escapeJSString() escapes \ and " in generated JS string literals
  • Numeric arguments validated for len/min/max/gt/gte/lt/lte

New public API

  • WithZodV3() Opt — opt into Zod v3 output
  • AddTypeWithName(input, name) — register anonymous structs with custom names

Test infrastructure

  • Golden file testing via github.com/xorcare/goldenmake test-update to regenerate
  • Docker TypeScript testing (make docker-test) — typechecks all golden files with tsc against zod@3 and zod@4, then runs vitest runtime tests against both versions
  • Table-driven tests with assertValidators + buildValidatorConverter using dynamic structs

Test plan

  • All Go golden file tests pass
  • TypeScript typecheck passes for v3 and v4
  • 170+ runtime tests pass for both zod versions
  • Invalid combinations panic
  • Integration test with tms/packages/schemas consumer

🤖 Generated with Claude Code

@ramilamparo ramilamparo marked this pull request as draft April 1, 2026 13:49
Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces Zod v4 schema generation as the default output format, featuring object shape spreads for embedded structs and specialized builders for string formats, while providing a legacy v3 compatibility mode and an updated golden-file-based testing suite. Feedback from the review highlights critical runtime and compatibility issues: the implementation of self-referential types using get accessors will likely trigger ReferenceError or TypeError during schema construction, and several emitted helpers—such as z.email(), z.uuid(), and z.partialRecord—are not standard Zod APIs, which would cause failures for users of the official library.

claude[bot]

This comment was marked as outdated.

@github-actions github-actions bot deleted a comment from claude bot Apr 2, 2026
String schema generation:
- Replace chunk-based approach with semantic validators (stringValidator)
  that separate parsing from rendering
- Remove stringSchemaParts struct — renderStringSchema returns string directly
- Add renderV3Chain, renderV4Chain, renderV4FormatBase for version-specific output
- Skip redundant .min(1) from required when format validators are present
  (they already reject empty strings), except base64/hex in v4 which accept empty
- Panic on impossible combinations: format+union (email,ip) and multiple formats (email,url)
- Add AddTypeWithName for registering anonymous structs with custom names

Tests:
- Add buildValidatorConverter and assertValidators shared helpers for
  table-driven golden file tests with dynamic structs
- Consolidate TestStringValidations from 30+ subtests into table-driven test (2 golden files)
- Consolidate TestNumberValidations, TestMapWithValidations,
  TestConvertSliceWithValidations into table-driven tests
- Consolidate TestFormatValidators: all 25 format + 2 union tags tested
  bare and with required modifier (8 golden files)
- Add test for dive,oneof on slices
- Add tests for format+union panic and multiple formats panic
- Remove 24 redundant individual format tests from TestStringValidations
- Remove 4 redundant tests from TestZodV4Defaults
- Golden files reduced from 171 to 75

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@claude

This comment has been minimized.

claude[bot]

This comment was marked as outdated.

@github-actions github-actions bot deleted a comment from claude bot Apr 2, 2026
claude[bot]

This comment was marked as outdated.

@github-actions github-actions bot deleted a comment from claude bot Apr 2, 2026
ramilamparo and others added 2 commits April 3, 2026 13:09
Security:
- Add escapeJSString() to escape backslashes and double quotes in
  generated JS string literals (contains, startswith, endswith, eq, ne,
  oneof). Prevents broken output from struct tag values with special chars.

Refactors:
- Replace lastFieldSelfRef side-channel flag with convertResult struct
  that explicitly returns {text, selfRef} from convertType(). Public
  ConvertType API unchanged via thin wrapper.
- Extract renderChain() with shared cases from renderV3Chain/renderV4Chain,
  eliminating ~80 lines of duplication.
- Simplify parseStringValidators: replace ~90 lines of switch cases with
  knownStringTags map lookup.
- Extract regex rendering into maps (regexChainMap, unicodeRegexChainMap,
  v3FormatRegexMap) with renderRegex()/renderUnicodeRegex() helpers.

Bug fixes:
- Fix v4 embedded field ordering: spreads now come before named fields
  so named fields override embedded ones (last key wins in JS), matching
  Go's shadowing semantics. Previously spreads came after, causing
  embedded fields to override named fields incorrectly.
- Panic on non-integer gt=/lt= arguments instead of silently using 0.

Tests:
- Add field shadowing test (named field overrides embedded field, v3+v4)
- Add escapeJSString unit tests
- Add gt/lt non-integer panic tests

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
claude[bot]

This comment was marked as outdated.

@github-actions github-actions bot deleted a comment from claude bot Apr 3, 2026
- Use version-aware dispatch in enum branch (renderV3Chain for v3,
  renderChain for v4) instead of always calling renderV3Chain.
  Defensive change — no current behavioral difference.
- Replace magic number rawPart[6:] with rawPart[len("oneof="):]
- Use strings.ReplaceAll instead of strings.Replace with count -1
- Panic in renderV4FormatBase default case instead of returning ""
  to catch future omissions when adding format tags
- Validate numeric arguments for len/min/max/gte/lte string validators
  using requireIntArg helper (same treatment as gt/lt)
- Remove renderV4Chain wrapper — inline renderChain at all call sites

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
claude[bot]

This comment was marked as outdated.

@github-actions github-actions bot deleted a comment from claude bot Apr 3, 2026
ramilamparo and others added 2 commits April 3, 2026 13:49
- Remove unreachable len(vals) == 0 panic in oneof handler — the case
  guard already requires valValue != "" and FindAllString always matches
- Separate escapeJSString and requireIntArg doc comments with blank line

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
claude[bot]

This comment was marked as outdated.

@github-actions github-actions bot deleted a comment from claude bot Apr 3, 2026
- Use json.Marshal in escapeJSString for complete JS string escaping
  (newlines, control characters, not just quotes and backslashes)
- Pass reflect.TypeOf("") instead of reflect.TypeOf(0) to custom tag
  handlers in string validation context
- Replace string concatenation with strings.Builder in renderStringSchema

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@github-actions github-actions bot deleted a comment from claude bot Apr 3, 2026
claude[bot]

This comment was marked as outdated.

@github-actions github-actions bot deleted a comment from claude bot Apr 3, 2026
claude[bot]

This comment was marked as outdated.

@github-actions github-actions bot deleted a comment from claude bot Apr 3, 2026
claude[bot]

This comment was marked as outdated.

@github-actions github-actions bot deleted a comment from claude bot Apr 3, 2026
@ramilamparo ramilamparo requested review from hi-rai and satvik007 April 6, 2026 10:12
claude[bot]

This comment was marked as outdated.

@github-actions github-actions bot deleted a comment from claude bot Apr 6, 2026
@ramilamparo ramilamparo marked this pull request as draft April 6, 2026 10:44
Format validators (email, url, uuid, etc.) now use z.string().check(z.email())
instead of z.email() as the schema base. This fixes ordering bugs where
z.email().trim() would validate before trimming, rejecting valid spaced input.
With .check(), the z.string() base is preserved so transforms like .trim()
chain correctly in any position.

The required tag now always emits .min(1) in both v3 and v4, including when
combined with format or union validators. Previously it was skipped because
format validators reject empty strings, but explicit .min(1) is more correct
and consistent.

IP unions updated to z.union([z.string().check(z.ipv4()), ...]) for
consistency with the .check() approach.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
claude[bot]

This comment was marked as outdated.

@github-actions github-actions bot deleted a comment from claude bot Apr 6, 2026
claude[bot]

This comment was marked as outdated.

@github-actions github-actions bot deleted a comment from claude bot Apr 6, 2026
@ramilamparo ramilamparo marked this pull request as ready for review April 6, 2026 12:33
claude[bot]

This comment was marked as outdated.

@github-actions github-actions bot deleted a comment from claude bot Apr 6, 2026
@github-actions github-actions bot deleted a comment from claude bot Apr 6, 2026
Copy link
Copy Markdown

@claude claude bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review Summary

The latest changes (1816be7.check() pattern + always emit .min(1) for required) are a major improvement that significantly simplifies the codebase:

What improved:

  • .check() pattern — Format validators now use z.string().check(z.email()) instead of top-level z.email() builders. This addresses the earlier Gemini review concern about non-standard Zod APIs and eliminates the complex hasTransformBefore / formatIdx / v4AcceptsEmpty ordering logic entirely.
  • required always emits .min(1) — Removes all skipRequired / keepRequired branching for format/union validators, making behavior consistent and predictable across all string schemas.
  • IP unions use z.string().check()z.union([z.string().check(z.ipv4()), z.string().check(z.ipv6())]) properly keeps the z.string() base, ensuring transforms chain correctly.
  • Simplified goldenAssert — Removed the goldenMeta/goldenOpt indirection in favor of a direct (t, data, version) signature.
  • resolveGolden in test infra — Tests now reference directories (e.g., "TestNestedStruct") instead of version-specific files, with automatic version resolution. Cleaner test case definitions.

Architecture highlights:

  • Clean v3/v4 split with WithZodV3() opt-in — minimal branching in core logic
  • parseStringValidators / renderStringSchema separation is solid
  • Golden file + docker type-check + vitest runtime tests provide strong end-to-end confidence
  • Security: All string interpolation paths properly escaped via escapeJSString + json.Marshal, numeric args validated via requireIntArg/requireNumericArg

No new issues found. Previous review feedback has been well addressed across all rounds. The code is clean, well-tested, and ready to merge.

@github-actions github-actions bot deleted a comment from claude bot Apr 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants