Skip to content

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

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

Migrate to Zod 4 and make Zod 3 opt-in#22
ramilamparo wants to merge 31 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

z.string().check(z.email()) instead of deprecated z.string().email(). Handles transform ordering correctly (e.g., z.string().trim().check(z.email()) trims before validating). See colinhacks/zod#4642

IP union

ip/ip_addr tags produce z.union([z.ipv4(), 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.

Redundant .min(1) removed

Format validators already reject empty strings, so required tag's .min(1) is omitted. Exception: base64/hexadecimal in v4 which accept empty strings.

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())
  • renderV4FormatBase — v4 format builders (z.email(), 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>
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 marked this pull request as ready for review April 3, 2026 12:04
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
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
claude[bot]

This comment was marked as outdated.

@github-actions github-actions bot deleted a comment from claude bot Apr 3, 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

Well-structured migration after many review rounds. The major issues from prior reviews (JS string escaping via json.Marshal, convertResult struct, shared renderChain, requireIntArg/requireNumericArg validation, renderV4FormatBase panic, getValidateValues guards, WithIgnoreTags tests, boolean enum fix) have all been addressed.

Architecture highlights:

  • Clean v3/v4 split with WithZodV3() opt-in
  • parseStringValidators / renderStringSchema separation is a solid improvement over the original single-pass approach
  • Golden file + docker type-check + vitest runtime tests provide strong end-to-end confidence
  • assertSchema helper elegantly handles version-specific vs shared golden files

Remaining items (all low):

  1. Potential panic in getTypeNameWithGenerics on empty generic type arg
  2. Empty if-branch in getValidateCurrent could be more explicit

No high or critical issues found. The security posture is good — string interpolation paths are properly escaped, numeric arguments are validated, and invalid tag combinations panic with clear messages.

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
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Low: Potential panic on empty generic type argument

arg[:1] will panic with index out of range if arg is an empty string. This can happen if a generic type name has malformed syntax like Type[,T], where splitting on comma produces an empty element.

Suggested change
sb.WriteString(strings.ToUpper(arg[:1])) // Capitalize first letter
for _, arg := range typeArgs {
if len(arg) == 0 {
continue
}
sb.WriteString(strings.ToUpper(arg[:1])) // Capitalize first letter
sb.WriteString(arg[1:])
}

return schema as { safeParse: (input: unknown) => any };
}

describe(`Golden file runtime tests (zod@${currentZodVersion})`, () => {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Praise: Excellent test infrastructure

The golden file + docker type-check + vitest runtime test combination is a really solid approach. Dynamic schema import with version-aware skipping ensures both zod v3 and v4 outputs are validated end-to-end. The cases.ts table-driven tests complement the golden file snapshots well.

@github-actions github-actions bot deleted a comment from claude bot Apr 3, 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