Skip to content

fix: preserve ZodObject ergonomics for schema codegen#2036

Merged
bokelley merged 1 commit into
mainfrom
issue-1979
May 27, 2026
Merged

fix: preserve ZodObject ergonomics for schema codegen#2036
bokelley merged 1 commit into
mainfrom
issue-1979

Conversation

@bokelley
Copy link
Copy Markdown
Contributor

Summary

Fixes the remaining Zod codegen DX hardening items from #1979.

  • recursively recognizes nested record-only marker unions so object-shaped schemas keep ZodObject helpers
  • replaces the shape-fragile schema export regex with a scanner that tolerates = inside type annotations
  • rewrites typed string-record/object intersections as object schemas with .catchall(...), preserving both JavaScript ergonomics and additional-property validation
  • regenerates affected Zod schemas and adds runtime/type-level regression coverage

Impact

JavaScript and TypeScript adopters can keep using .shape, .extend(), .pick(), and .omit() on object-shaped generated schemas. For typed record intersections such as MediaBuyFeaturesSchema, extra keys still validate against the original record value type.

Validation

  • node --test test/generate-zod-object-intersections.test.js
  • npm run typecheck
  • git diff --check
  • pre-push validation: typecheck + library build

Fixes #1979.

Copy link
Copy Markdown

@aao-ipr-bot aao-ipr-bot Bot left a comment

Choose a reason for hiding this comment

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

LGTM. Follow-ups noted below. The scanner replaces the shape-fragile regex with a proper tokenizer, the .and(z.object()).catchall() rewrite preserves wire semantics while restoring ZodObject ergonomics, and the cycle-protected union recursion finally closes the one-level TODO. Right shape.

Things I checked

  • Semantic equivalence of the catchall rewrite. Zod v4 treats .catchall(z.boolean()) as the dominant unknown-key strategy; the trailing .passthrough() on the inner object becomes a no-op, and the new chain accepts exactly the set of values the old z.record(z.string(), z.boolean()).and(z.object(...).passthrough()) did. Wire shape unchanged.
  • Scanner robustness in findSchemaAssignmentStart (scripts/generate-zod-from-ts.ts:1064-1095). Tracks angleDepth for <>, treats => as one token, and the depth === 0 && angleDepth === 0 guard at line 1085 correctly rejects = inside type annotations like z.ZodType<FactoryOptions<DefaultValue = unknown>> and inside default-arg parens. Template literal types ride on the existing skipQuotedOrRegexLiteral backtick branch (line 842). The targeted test at test/generate-zod-object-intersections.test.js:243-260 exercises the failure mode.
  • Cycle protection in collectRedundantRecordUnionSchemaNames (scripts/generate-zod-from-ts.ts:604-647). visiting is per-traversal (fresh new Set() at line 643), names is the persistent accumulator. Mutual cycle A→[B], B→[A] terminates with neither added — visiting hits A while resolving B, returns false, both rolled back. No false positives.
  • Else-branch losslessness at scripts/generate-zod-from-ts.ts:467-469. When a typed string record is followed by .and(NOT-z.object), i points one-past the closing ) of .and(...) and the fallback content.substring(recordStart, i) round-trips the original text.
  • Changeset and wire impact. Four exported schemas migrate from ZodIntersection to ZodObject + .catchall(): MediaBuyFeaturesSchema, GetBrandIdentitySuccessSchema.fonts, ForecastPointSchema.metrics, GetAdCPCapabilitiesResponseSchema.signals.features. Inferred TS types are equivalent (intersection commutes), runtime validation is equivalent, declared .shape access is now restored. MediaBuyFeaturesSchema correctly drops out of the shape-check exemption list at test/lib/zod-schemas.test.js:645.
  • Test export wiring. postProcessRecordIntersections added to __test__ at scripts/generate-zod-from-ts.ts:1728 matches the new postProcessRecordIntersections helper at line 293.

Follow-ups (non-blocking — file as issues)

  • Tighten the .catchall(...) rewrite guard at scripts/generate-zod-from-ts.ts:464-466. The current andContent.trimStart().startsWith('z.object(') admits anything that begins with z.object( — including future codegen output like z.object({...}).strict() or z.object({...}).passthrough().refine(...). With a .strict() upstream, the appended .catchall(z.boolean()) would silently override the strict mode and quietly widen validation. No current path produces this, but the assumption is load-bearing. Tighten to require the literal z.object({...}).passthrough() shape (or fall through to the else-branch), and add a regression test that asserts a strict-mode upstream is preserved.
  • patch vs minor on the changeset. This intentionally reshapes the runtime class of four exported schemas. Adopters using instanceof z.ZodIntersection or ._def.typeName === 'ZodIntersection' on these schemas will break. Inferred TS types and wire validation are unchanged, so patch is defensible — but it's the borderline call. Worth deciding the rule for this category once and writing it down (probably in CLAUDE.md § "Version Bump Guidelines"). Not blocking this one.
  • Test coverage for the else-branch fallback at scripts/generate-zod-from-ts.ts:467-469. The new typed-record tests cover the happy path; an off-by-one in the lossless fallback (typed record followed by .and(SomeNamedSchema)) would not be caught by any test in test/generate-zod-object-intersections.test.js.

Minor nits (non-blocking)

  1. Use normalizeSchemaExpression at scripts/generate-zod-from-ts.ts:464. andContent.trimStart().startsWith('z.object(') is a raw substring sniff; the rest of the file routes through normalizeSchemaExpression for whitespace/comment robustness. Matches conventions and folds into the guard-tightening above.

Approving on the strength of the scanner-vs-regex swap plus the recursive cycle-protected union collapse — both close real correctness gaps and not just DX papercuts.

Copy link
Copy Markdown

@aao-ipr-bot aao-ipr-bot Bot left a comment

Choose a reason for hiding this comment

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

LGTM. Right shape — Record<string,V> & {typed_fields} was always object-shaped on the wire; rewriting it as z.object({...}).catchall(V) matches the type and restores ZodObject ergonomics without weakening the additional-property constraint.

Things I checked

  • scripts/generate-zod-from-ts.ts:464-466catchall(valueSchema) interpolation uses splitTopLevelCommaList, which is depth- and string-literal-aware. Unions, z.lazy(...), refined schemas, and record-of-record value schemas survive intact.
  • scripts/generate-zod-from-ts.ts:1064-1095findSchemaAssignmentStart correctly handles default type-parameter values (<T = X>), conditional types, and object-literal defaults inside generics. Replaces the shape-fragile /(?::[^=]+)? = / regex that was tripping on = inside type annotations.
  • scripts/generate-zod-from-ts.ts:1230-1259isOpaqueRecordMarkerExpression recursion is cycle-safe. Named-schema resolution at :1255 is gated by visiting.has(named) at :1249; union-arm recursion at :1242 threads the same visiting set.
  • scripts/generate-zod-from-ts.ts:604-647collectRedundantRecordUnionSchemaNames mutual recursion is deterministic. Map iteration is insertion-ordered; findSchemaExportExpressions populates in textual file order; the "add-to-names-as-soon-as-classified" shortcut at :638 is safe because reachability is via recursion before the outer-loop turn.
  • src/lib/types/schemas.generated.ts:2141-2174GetBrandIdentitySuccessSchema.fonts rewrites correctly: declared primary/secondary validate the typed union, unknown keys validate against the same union via catchall.
  • src/lib/types/schemas.generated.ts:3441-3446MediaBuyFeaturesSchema now exposes .extend(). Test-allowlist update at test/lib/zod-schemas.test.js:645 reflects this correctly (and the type-test at src/type-tests/zod-schema-ergonomics.type-test.ts:51-54 proves it at compile time).
  • __test__ export of postProcessRecordIntersections at scripts/generate-zod-from-ts.ts:1727 — only consumers are the production pipeline at :1638 and the new test harness. No drift.
  • Patch changeset is the right type — this is regenerated codegen output. Adopters who consumed MediaBuyFeaturesSchema-and-friends as ZodIntersection now get ZodObject + catchall. Behavior is preserved; ergonomics are additive.

Follow-ups (non-blocking — file as issues)

  • Redundant .passthrough() before .catchall() in emitted output. src/lib/types/schemas.generated.ts:2164, :3445, :5529, :7545 all emit .passthrough().catchall(...). .catchall() already governs unknown-key behavior; the .passthrough() is dead weight. Harmless, but a leaner pass would strip .passthrough() immediately preceding .catchall() for cleaner generated output.
  • Latent scanner edge case at scripts/generate-zod-from-ts.ts:1084 — a default-typed function-type parameter like <T = (x: number) => string> would prematurely decrement angleDepth on the > after the skipped =>. ts-to-zod doesn't emit this shape today, so unreachable in practice. Worth a short comment near the => handling if future inputs expand.

Safe to merge.

@bokelley bokelley merged commit 8502e9f into main May 27, 2026
16 checks passed
@bokelley bokelley deleted the issue-1979 branch May 27, 2026 02:15
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.

Codegen: hardening follow-ups for Zod ergonomics passes (post #1972/#1975)

1 participant