fix: preserve ZodObject ergonomics for schema codegen#2036
Conversation
There was a problem hiding this comment.
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 oldz.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). TracksangleDepthfor<>, treats=>as one token, and thedepth === 0 && angleDepth === 0guard at line 1085 correctly rejects=inside type annotations likez.ZodType<FactoryOptions<DefaultValue = unknown>>and inside default-arg parens. Template literal types ride on the existingskipQuotedOrRegexLiteralbacktick 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).visitingis per-traversal (freshnew Set()at line 643),namesis the persistent accumulator. Mutual cycleA→[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),ipoints one-past the closing)of.and(...)and the fallbackcontent.substring(recordStart, i)round-trips the original text. - Changeset and wire impact. Four exported schemas migrate from
ZodIntersectiontoZodObject+.catchall():MediaBuyFeaturesSchema,GetBrandIdentitySuccessSchema.fonts,ForecastPointSchema.metrics,GetAdCPCapabilitiesResponseSchema.signals.features. Inferred TS types are equivalent (intersection commutes), runtime validation is equivalent, declared.shapeaccess is now restored.MediaBuyFeaturesSchemacorrectly drops out of the shape-check exemption list at test/lib/zod-schemas.test.js:645. - Test export wiring.
postProcessRecordIntersectionsadded to__test__at scripts/generate-zod-from-ts.ts:1728 matches the newpostProcessRecordIntersectionshelper 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 currentandContent.trimStart().startsWith('z.object(')admits anything that begins withz.object(— including future codegen output likez.object({...}).strict()orz.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 literalz.object({...}).passthrough()shape (or fall through to the else-branch), and add a regression test that asserts a strict-mode upstream is preserved. patchvsminoron the changeset. This intentionally reshapes the runtime class of four exported schemas. Adopters usinginstanceof z.ZodIntersectionor._def.typeName === 'ZodIntersection'on these schemas will break. Inferred TS types and wire validation are unchanged, sopatchis defensible — but it's the borderline call. Worth deciding the rule for this category once and writing it down (probably inCLAUDE.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 intest/generate-zod-object-intersections.test.js.
Minor nits (non-blocking)
- Use
normalizeSchemaExpressionat scripts/generate-zod-from-ts.ts:464.andContent.trimStart().startsWith('z.object(')is a raw substring sniff; the rest of the file routes throughnormalizeSchemaExpressionfor 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.
There was a problem hiding this comment.
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-466—catchall(valueSchema)interpolation usessplitTopLevelCommaList, 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-1095—findSchemaAssignmentStartcorrectly 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-1259—isOpaqueRecordMarkerExpressionrecursion is cycle-safe. Named-schema resolution at:1255is gated byvisiting.has(named)at:1249; union-arm recursion at:1242threads the samevisitingset.scripts/generate-zod-from-ts.ts:604-647—collectRedundantRecordUnionSchemaNamesmutual recursion is deterministic. Map iteration is insertion-ordered;findSchemaExportExpressionspopulates in textual file order; the "add-to-names-as-soon-as-classified" shortcut at:638is safe because reachability is via recursion before the outer-loop turn.src/lib/types/schemas.generated.ts:2141-2174—GetBrandIdentitySuccessSchema.fontsrewrites correctly: declaredprimary/secondaryvalidate the typed union, unknown keys validate against the same union via catchall.src/lib/types/schemas.generated.ts:3441-3446—MediaBuyFeaturesSchemanow exposes.extend(). Test-allowlist update attest/lib/zod-schemas.test.js:645reflects this correctly (and the type-test atsrc/type-tests/zod-schema-ergonomics.type-test.ts:51-54proves it at compile time).__test__export ofpostProcessRecordIntersectionsatscripts/generate-zod-from-ts.ts:1727— only consumers are the production pipeline at:1638and 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,:7545all 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 decrementangleDepthon 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.
Summary
Fixes the remaining Zod codegen DX hardening items from #1979.
=inside type annotations.catchall(...), preserving both JavaScript ergonomics and additional-property validationImpact
JavaScript and TypeScript adopters can keep using
.shape,.extend(),.pick(), and.omit()on object-shaped generated schemas. For typed record intersections such asMediaBuyFeaturesSchema, extra keys still validate against the original record value type.Validation
node --test test/generate-zod-object-intersections.test.jsnpm run typecheckgit diff --checkFixes #1979.