Skip to content

Add z.fromJSONSchema(), z.looseRecord(), z.xor()#5534

Merged
colinhacks merged 10 commits intomainfrom
from-json-schema
Dec 15, 2025
Merged

Add z.fromJSONSchema(), z.looseRecord(), z.xor()#5534
colinhacks merged 10 commits intomainfrom
from-json-schema

Conversation

@colinhacks
Copy link
Owner

  • Initial version of z.fromJSONSchema
  • Add z.fromJSONSchema

@colinhacks
Copy link
Owner Author

cc @pullfrog

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR adds three major features to Zod: z.fromJSONSchema() for converting JSON Schema to Zod schemas, z.looseRecord() for records that pass through non-matching keys, and exclusive unions (via z.union([...], { exclusive: true })) which require exactly one schema to match. The PR also refactors the toJSONSchema export to support the new exclusive union feature.

Key Changes:

  • Implemented fromJSONSchema() to convert JSON Schema (drafts 4, 7, 2020-12, and OpenAPI 3.0) to Zod schemas
  • Added looseRecord() that passes through keys not matching the key pattern instead of erroring
  • Added exclusive union support via { exclusive: true } option in z.union()

Reviewed changes

Copilot reviewed 16 out of 16 changed files in this pull request and generated 14 comments.

Show a summary per file
File Description
play.ts Demo/test code showing intersection and record usage examples
packages/zod/src/v4/mini/schemas.ts Adds looseRecord function to mini API
packages/zod/src/v4/mini/external.ts Updates toJSONSchema export path
packages/zod/src/v4/core/to-json-schema.ts Updates union handling to use oneOf for exclusive unions
packages/zod/src/v4/core/schemas.ts Implements exclusive union logic with new mode parameter for records
packages/zod/src/v4/core/index.ts Removes toJSONSchema export from core index
packages/zod/src/v4/core/errors.ts Adds error types for exclusive unions (multiple match vs no match)
packages/zod/src/v4/classic/tests/union.test.ts Comprehensive tests for exclusive union behavior
packages/zod/src/v4/classic/tests/record.test.ts Tests for looseRecord functionality
packages/zod/src/v4/classic/tests/from-json-schema.test.ts Extensive tests for fromJSONSchema conversion
packages/zod/src/v4/classic/schemas.ts Adds looseRecord function to classic API
packages/zod/src/v4/classic/from-json-schema.ts Full implementation of JSON Schema to Zod converter
packages/zod/src/v4/classic/external.ts Exports toJSONSchema and fromJSONSchema
packages/treeshake/zod-string.ts Adds trailing newline
packages/treeshake/zod-locale.ts New treeshake test for locale imports
packages/docs/content/api.mdx Documents looseRecord feature

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

play.ts Outdated

const arg: Record<`sadf_${string}`, string> = {
sadf_asdf: "asdf",
aweqewr: "asdf",
Copy link

Copilot AI Dec 14, 2025

Choose a reason for hiding this comment

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

The property aweqewr does not match the required pattern sadf_${string} according to the type annotation on line 29. TypeScript should flag this as an error. This appears to be test/demo code showing incorrect usage rather than correct usage.

Suggested change
aweqewr: "asdf",
// aweqewr: "asdf", // Removed: does not match pattern

Copilot uses AI. Check for mistakes.
Comment on lines 185 to 191
const schema = z.union([z.string(), z.number()], { exclusive: true });
expect(schema.parse("hello")).toBe("hello");
expect(schema.parse(42)).toBe(42);
});

test("exclusive union - zero matches fails", () => {
const schema = z.union([z.string(), z.number()], { exclusive: true });
Copy link

Copilot AI Dec 14, 2025

Choose a reason for hiding this comment

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

The PR title mentions z.xor() but only z.union([...], { exclusive: true }) is implemented. Consider either adding a dedicated z.xor() function for better discoverability, or updating the PR title to accurately reflect that exclusive unions are accessed via the union options parameter.

Suggested change
const schema = z.union([z.string(), z.number()], { exclusive: true });
expect(schema.parse("hello")).toBe("hello");
expect(schema.parse(42)).toBe(42);
});
test("exclusive union - zero matches fails", () => {
const schema = z.union([z.string(), z.number()], { exclusive: true });
const schema = z.xor([z.string(), z.number()]);
expect(schema.parse("hello")).toBe("hello");
expect(schema.parse(42)).toBe(42);
});
test("exclusive union - zero matches fails", () => {
const schema = z.xor([z.string(), z.number()]);

Copilot uses AI. Check for mistakes.
Comment on lines 472 to 487
if (schema.anyOf && Array.isArray(schema.anyOf)) {
const options = schema.anyOf.map((s) => convertSchema(s, ctx));
if (hasExplicitType) {
options.unshift(baseSchema);
}
baseSchema = z.union(options as [ZodType, ZodType, ...ZodType[]]);
}

// Handle oneOf - exclusive union (exactly one must match)
if (schema.oneOf && Array.isArray(schema.oneOf)) {
const options = schema.oneOf.map((s) => convertSchema(s, ctx));
if (hasExplicitType) {
options.unshift(baseSchema);
}
baseSchema = z.union(options as [ZodType, ZodType, ...ZodType[]], { exclusive: true });
}
Copy link

Copilot AI Dec 14, 2025

Choose a reason for hiding this comment

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

When a schema has both anyOf and oneOf keywords, the current implementation processes both sequentially (lines 472-487). This means the oneOf processing would overwrite the anyOf result. According to JSON Schema specification, having both keywords is allowed and both should be satisfied. This should either be handled properly (e.g., by intersecting the results) or throw an error indicating that combining these keywords is not supported.

Copilot uses AI. Check for mistakes.
Comment on lines +154 to +156
// Expand type array into anyOf union
const typeSchemas = type.map((t) => {
const typeSchema: JSONSchema.JSONSchema = { ...schema, type: t };
Copy link

Copilot AI Dec 14, 2025

Choose a reason for hiding this comment

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

When expanding a type array into a union at line 156, the entire schema (including all constraints like minLength, maxLength, etc.) is spread into each type variant. This could lead to incorrect schemas if constraints are type-specific. For example, { type: ["string", "number"], minLength: 5 } would incorrectly apply minLength to the number variant. Consider only copying type-specific constraints to their respective variants.

Suggested change
// Expand type array into anyOf union
const typeSchemas = type.map((t) => {
const typeSchema: JSONSchema.JSONSchema = { ...schema, type: t };
// Expand type array into anyOf union, filtering constraints to type-specific ones
const typeSchemas = type.map((t) => {
const typeSchema: JSONSchema.JSONSchema = filterSchemaForType(schema, t);

Copilot uses AI. Check for mistakes.
Comment on lines +303 to +351
if (schema.patternProperties) {
// patternProperties: keys matching pattern must satisfy corresponding schema
// Use loose records so non-matching keys pass through
const patternProps = schema.patternProperties;
const patternKeys = Object.keys(patternProps);
const looseRecords: ZodType[] = [];

for (const pattern of patternKeys) {
const patternValue = convertSchema(patternProps[pattern] as JSONSchema.JSONSchema, ctx);
const keySchema = z.string().regex(new RegExp(pattern));
looseRecords.push(z.looseRecord(keySchema, patternValue));
}

// Build intersection: object schema + all pattern property records
const schemasToIntersect: ZodType[] = [];
if (Object.keys(shape).length > 0) {
// Use passthrough so patternProperties can validate additional keys
schemasToIntersect.push(z.object(shape).passthrough());
}
schemasToIntersect.push(...looseRecords);

if (schemasToIntersect.length === 0) {
zodSchema = z.object({}).passthrough();
} else if (schemasToIntersect.length === 1) {
zodSchema = schemasToIntersect[0]!;
} else {
// Chain intersections: (A & B) & C & D ...
let result = z.intersection(schemasToIntersect[0]!, schemasToIntersect[1]!);
for (let i = 2; i < schemasToIntersect.length; i++) {
result = z.intersection(result, schemasToIntersect[i]!);
}
zodSchema = result;
}
break;
}

// Handle additionalProperties
// In JSON Schema, additionalProperties defaults to true (allow any extra properties)
// In Zod, objects strip unknown keys by default, so we need to handle this explicitly
const objectSchema = z.object(shape);
if (schema.additionalProperties === false) {
// Strict mode - no extra properties allowed
zodSchema = objectSchema.strict();
} else if (typeof schema.additionalProperties === "object") {
// Extra properties must match the specified schema
zodSchema = objectSchema.catchall(convertSchema(schema.additionalProperties as JSONSchema.JSONSchema, ctx));
} else {
// additionalProperties is true or undefined - allow any extra properties (passthrough)
zodSchema = objectSchema.passthrough();
Copy link

Copilot AI Dec 14, 2025

Choose a reason for hiding this comment

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

When patternProperties is present, the code breaks out early (line 336), bypassing the additionalProperties handling logic. This means that if both patternProperties and additionalProperties are specified in a JSON Schema, the additionalProperties behavior will be ignored. Both should be handled together - additionalProperties controls keys that don't match any pattern in patternProperties.

Suggested change
if (schema.patternProperties) {
// patternProperties: keys matching pattern must satisfy corresponding schema
// Use loose records so non-matching keys pass through
const patternProps = schema.patternProperties;
const patternKeys = Object.keys(patternProps);
const looseRecords: ZodType[] = [];
for (const pattern of patternKeys) {
const patternValue = convertSchema(patternProps[pattern] as JSONSchema.JSONSchema, ctx);
const keySchema = z.string().regex(new RegExp(pattern));
looseRecords.push(z.looseRecord(keySchema, patternValue));
}
// Build intersection: object schema + all pattern property records
const schemasToIntersect: ZodType[] = [];
if (Object.keys(shape).length > 0) {
// Use passthrough so patternProperties can validate additional keys
schemasToIntersect.push(z.object(shape).passthrough());
}
schemasToIntersect.push(...looseRecords);
if (schemasToIntersect.length === 0) {
zodSchema = z.object({}).passthrough();
} else if (schemasToIntersect.length === 1) {
zodSchema = schemasToIntersect[0]!;
} else {
// Chain intersections: (A & B) & C & D ...
let result = z.intersection(schemasToIntersect[0]!, schemasToIntersect[1]!);
for (let i = 2; i < schemasToIntersect.length; i++) {
result = z.intersection(result, schemasToIntersect[i]!);
}
zodSchema = result;
}
break;
}
// Handle additionalProperties
// In JSON Schema, additionalProperties defaults to true (allow any extra properties)
// In Zod, objects strip unknown keys by default, so we need to handle this explicitly
const objectSchema = z.object(shape);
if (schema.additionalProperties === false) {
// Strict mode - no extra properties allowed
zodSchema = objectSchema.strict();
} else if (typeof schema.additionalProperties === "object") {
// Extra properties must match the specified schema
zodSchema = objectSchema.catchall(convertSchema(schema.additionalProperties as JSONSchema.JSONSchema, ctx));
} else {
// additionalProperties is true or undefined - allow any extra properties (passthrough)
zodSchema = objectSchema.passthrough();
// Handle patternProperties and additionalProperties together
let hasPatternProperties = !!schema.patternProperties;
let patternProps = schema.patternProperties || {};
let patternKeys = Object.keys(patternProps);
let looseRecords: ZodType[] = [];
if (hasPatternProperties) {
for (const pattern of patternKeys) {
const patternValue = convertSchema(patternProps[pattern] as JSONSchema.JSONSchema, ctx);
const keySchema = z.string().regex(new RegExp(pattern));
looseRecords.push(z.looseRecord(keySchema, patternValue));
}
}
// Build intersection: object schema + all pattern property records
const schemasToIntersect: ZodType[] = [];
if (Object.keys(shape).length > 0) {
// Use passthrough so patternProperties can validate additional keys
schemasToIntersect.push(z.object(shape).passthrough());
}
schemasToIntersect.push(...looseRecords);
let baseSchema: ZodType;
if (schemasToIntersect.length === 0) {
baseSchema = z.object({}).passthrough();
} else if (schemasToIntersect.length === 1) {
baseSchema = schemasToIntersect[0]!;
} else {
// Chain intersections: (A & B) & C & D ...
let result = z.intersection(schemasToIntersect[0]!, schemasToIntersect[1]!);
for (let i = 2; i < schemasToIntersect.length; i++) {
result = z.intersection(result, schemasToIntersect[i]!);
}
baseSchema = result;
}
// Handle additionalProperties
// In JSON Schema, additionalProperties defaults to true (allow any extra properties)
// In Zod, objects strip unknown keys by default, so we need to handle this explicitly
if (schema.additionalProperties === false) {
// Strict mode - no extra properties allowed
zodSchema = baseSchema.strict();
} else if (typeof schema.additionalProperties === "object") {
// Extra properties must match the specified schema
zodSchema = baseSchema.catchall(convertSchema(schema.additionalProperties as JSONSchema.JSONSchema, ctx));
} else {
// additionalProperties is true or undefined - allow any extra properties (passthrough)
zodSchema = baseSchema.passthrough();

Copilot uses AI. Check for mistakes.
play.ts Outdated
Comment on lines 25 to 26
const laxOutput = laxCombinedSchema.parse(laxInput);

Copy link

Copilot AI Dec 14, 2025

Choose a reason for hiding this comment

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

Unused variable laxOutput.

Suggested change
const laxOutput = laxCombinedSchema.parse(laxInput);

Copilot uses AI. Check for mistakes.
play.ts Outdated
Comment on lines 29 to 32
const arg: Record<`sadf_${string}`, string> = {
sadf_asdf: "asdf",
aweqewr: "asdf",
};
Copy link

Copilot AI Dec 14, 2025

Choose a reason for hiding this comment

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

Unused variable arg.

Suggested change
const arg: Record<`sadf_${string}`, string> = {
sadf_asdf: "asdf",
aweqewr: "asdf",
};

Copilot uses AI. Check for mistakes.
Copy link
Contributor

@pullfrog pullfrog bot left a comment

Choose a reason for hiding this comment

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

This PR successfully implements fromJSONSchema() for converting JSON Schema to Zod and looseRecord() for partial key validation. All tests pass and the build succeeds. However, there are several concerns that should be addressed.

Critical issues:

  • PR title mentions z.xor() but it's not implemented separately (xor is z.union([...], { exclusive: true }))
  • Unrelated files included (play.ts, zod-locale.ts, zod-string.ts)
  • Type safety compromised with excessive as any casts
  • Potential bugs in tuple validation and patternProperties handling

Pullfrog  | Fix all ➔Fix 👍s ➔pullfrog.com𝕏


function convertBaseSchema(schema: JSONSchema.JSONSchema, ctx: ConversionContext): ZodType {
// Handle unsupported features
// Note: oneOf is handled same as anyOf in convertSchema
Copy link
Contributor

Choose a reason for hiding this comment

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

Misleading comment: says 'oneOf is handled same as anyOf' but oneOf uses { exclusive: true } (lines 481-487) making it XOR-like (exactly one match), while anyOf allows multiple matches. Clarify this distinction.

for (const pattern of patternKeys) {
const patternValue = convertSchema(patternProps[pattern] as JSONSchema.JSONSchema, ctx);
const keySchema = z.string().regex(new RegExp(pattern));
looseRecords.push(z.looseRecord(keySchema, patternValue));
Copy link
Contributor

Choose a reason for hiding this comment

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

Bug: When multiple patternProperties patterns match the same key, JSON Schema requires applying all matching schemas (intersection). Current implementation may only validate against one pattern. Need to handle overlapping patterns correctly.

}
// Apply minItems/maxItems constraints to tuples
if (typeof schema.minItems === "number" || typeof schema.maxItems === "number") {
zodSchema = (zodSchema as any).refine(
Copy link
Contributor

Choose a reason for hiding this comment

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

Using .refine() for tuple minItems/maxItems produces poor error messages. Zod arrays have .min()/.max() but tuples don't. Consider documenting this limitation or finding a better approach. Same issue at line 409.


// Apply metadata
if (schema.description) {
zodSchema = (zodSchema as any).describe(schema.description);
Copy link
Contributor

Choose a reason for hiding this comment

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

Type safety issue: (zodSchema as any).describe() and (zodSchema as any).default() lose type checking. Consider a helper function with proper types or type guards. Same at line 455.

@pullfrog
Copy link
Contributor

pullfrog bot commented Dec 14, 2025

Review complete! I've analyzed the implementation of fromJSONSchema() and looseRecord().

Summary:

  • ✅ All tests pass (84 tests)
  • ✅ Build succeeds
  • ✅ Core functionality works correctly
  • ⚠️ 14 issues identified (see review comments)

Main concerns:

  1. PR title mentions z.xor() but it's not implemented (it's actually z.union([...], { exclusive: true }))
  2. Several unrelated files included (play.ts, zod-locale.ts, zod-string.ts)
  3. Type safety issues with as any casts
  4. Potential bug with overlapping patternProperties
  5. Poor error messages for tuple length validation

The implementation is solid overall, but needs cleanup and bug fixes before merging.

Pullfrog  | Triggered by Pullfrog | Using Claude CodeView workflow runpullfrog.com𝕏

Copy link
Contributor

@pullfrog pullfrog bot left a comment

Choose a reason for hiding this comment

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

Strong implementation with good test coverage. Several critical issues need attention: type array handling, infinite recursion risk, and composition logic. Also note: PR title mentions z.xor() but it's not implemented.

Pullfrog  | Fix all ➔Fix 👍s ➔pullfrog.com𝕏

if (Array.isArray(type)) {
// Expand type array into anyOf union
const typeSchemas = type.map((t) => {
const typeSchema: JSONSchema.JSONSchema = { ...schema, type: t };
Copy link
Contributor

Choose a reason for hiding this comment

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

Critical - Potential infinite recursion: Spreading schema includes the original type array. If type: ['string', 'number'], this recursively calls convertBaseSchema with the same type array, causing infinite recursion. Fix: const { type: _, ...rest } = schema; const typeSchema = { ...rest, type: t };

}

function convertBaseSchema(schema: JSONSchema.JSONSchema, ctx: ConversionContext): ZodType {
// Handle unsupported features
Copy link
Contributor

Choose a reason for hiding this comment

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

Misleading comment: Says 'oneOf is handled same as anyOf' but line 486 shows oneOf uses { exclusive: true } for exactly-one-match semantics (different from anyOf). Update to: 'oneOf is handled as exclusive union in convertSchema'

const options = schema.anyOf.map((s) => convertSchema(s, ctx));
if (hasExplicitType) {
options.unshift(baseSchema);
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Logic issue: When a schema has explicit type AND anyOf, you unshift baseSchema into options. But if anyOf contains the same type, you get duplicate/conflicting constraints. Consider using intersection instead of union when hasExplicitType is true.

if (schema.allOf && Array.isArray(schema.allOf)) {
let result = hasExplicitType ? baseSchema : convertSchema(schema.allOf[0]!, ctx);
const startIdx = hasExplicitType ? 0 : 1;
for (let i = startIdx; i < schema.allOf.length; i++) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Missing edge case: allOf with zero elements will crash at schema.allOf[0]! when hasExplicitType is false. Add guard: if (!schema.allOf.length) return baseSchema;

if (schemasToIntersect.length === 0) {
zodSchema = z.object({}).passthrough();
} else if (schemasToIntersect.length === 1) {
zodSchema = schemasToIntersect[0]!;
Copy link
Contributor

Choose a reason for hiding this comment

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

Intersection chaining (A & B) & C can fail if schemas conflict. This same pattern repeats at line 500. Consider documenting the limitation or using a more robust composition strategy.

// { id?: string; name?: string; email?: string }
```

### Loose records
Copy link
Contributor

Choose a reason for hiding this comment

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

Clarify when to use: Add note distinguishing from .passthrough(): 'Unlike passthrough (allows any extras), looseRecord only validates pattern-matching keys. Use passthrough for fixed properties + extras; looseRecord for pattern-based validation.'

@@ -0,0 +1,5 @@
import * as z from "zod";
Copy link
Contributor

Choose a reason for hiding this comment

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

Unrelated file: This file is unrelated to fromJSONSchema/looseRecord/xor mentioned in PR title/description. Should be in separate commit or explained in PR body.


const schema = z.string().min(5);
console.log(schema.parse("hi"));

Copy link
Contributor

Choose a reason for hiding this comment

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

Unrelated change: Whitespace-only change. Remove or explain why included in this PR.

@colinhacks
Copy link
Owner Author

cc @coderabbitai review this

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Dec 14, 2025

@colinhacks: I'll review the changes for you!

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Dec 14, 2025

Note

Other AI code review bot(s) detected

CodeRabbit has detected other AI code review bot(s) in this pull request and will avoid duplicating their findings in the review comments. This may lead to a less comprehensive review.

Walkthrough

This pull request introduces several new features and refinements to Zod. A new fromJSONSchema function enables converting JSON Schema definitions to Zod types with support for references, composite keywords, and constraints. A new looseRecord API allows record validation to pass through non-matching keys unchanged. Union handling gains an exclusive option for stricter disjunction validation. Export paths are reorganized for toJSONSchema and fromJSONSchema to use dedicated modules. Invalid union error types are refactored into a discriminated union distinguishing no-match from multiple-match scenarios. Record definitions now support a mode field controlling key validation behavior. Comprehensive documentation and test suites cover the new functionality.

Pre-merge checks

❌ Failed checks (2 warnings)
Check name Status Explanation Resolution
Description check ⚠️ Warning The description only mentions z.fromJSONSchema and omits z.looseRecord and exclusive union changes, making it incomplete relative to the changeset. Expand the description to include z.looseRecord() and the exclusive union feature (xor/inclusive flag) for clarity.
Docstring Coverage ⚠️ Warning Docstring coverage is 9.09% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (1 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the three main features added: fromJSONSchema conversion function, looseRecord variant, and exclusive union (xor) support.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

♻️ Duplicate comments (13)
packages/treeshake/zod-string.ts (1)

5-5: Unrelated change: Remove from PR.

This whitespace-only change doesn't relate to the PR's stated objectives (fromJSONSchema, looseRecord, xor). Keep the PR focused.

packages/treeshake/zod-locale.ts (1)

1-5: Unrelated file: Remove from PR.

This locale configuration file doesn't relate to the PR's stated objectives (fromJSONSchema, looseRecord, xor). If you need to test locale behavior, use play.ts for quick experiments or add proper test cases.

Based on learnings, use play.ts for quick experimentation; use proper tests for all permanent test cases.

packages/docs/content/api.mdx (4)

1852-1852: Clarify distinction from .passthrough().

Add a note explaining when to use looseRecord vs .passthrough(): "Unlike passthrough (which allows any extras), looseRecord only validates pattern-matching keys. Use passthrough for fixed properties + extras; looseRecord for pattern-based validation."


1864-1864: Unclear example: Show the error is thrown.

The comment says "❌ value must be string" but doesn't demonstrate that an error is actually thrown. Consider using expect(() => schema.parse({ S_name: 123 })).toThrow() style for clarity.


1869-1869: Add context: WHY useful with intersections.

The docs say loose records are "particularly useful when combined with intersections" but don't explain why. Add: "This allows modeling multiple pattern properties from JSON Schema, where different patterns validate different key types."


1870-1870: Fix invalid syntax: z.intersection() only accepts 2 arguments.

The code z.intersection(A, B, C) won't work. Should be z.intersection(z.intersection(A, B), C) or use the .and() method.

play.ts (1)

5-32: Unrelated file: Remove playground code from PR.

The play.ts file is for quick experimentation and shouldn't be included in pull requests. Remove this file or revert these changes.

Also note: Line 31 has a property aweqewr that doesn't match the required pattern sadf_${string} according to the type annotation on line 29. TypeScript should flag this as an error.

Based on learnings, use play.ts for quick experimentation; use proper tests for all permanent test cases.

packages/zod/src/v4/classic/tests/union.test.ts (1)

183-298: Comprehensive test coverage for exclusive unions.

The test suite thoroughly covers all scenarios for exclusive unions: exactly one match, zero matches, multiple matches, overlapping/non-overlapping schemas, and async parsing. Well done!

Note: The PR title mentions z.xor() but the implementation uses z.union([...], { exclusive: true }). Consider either adding a dedicated z.xor() convenience function for better discoverability, or updating the PR title to accurately reflect the implementation approach.

packages/zod/src/v4/classic/tests/from-json-schema.test.ts (1)

200-207: Consider adding explicit exclusive matching verification.

The "oneOf schema (exclusive union)" test validates that single matches work, but doesn't explicitly test that multiple matches fail (the defining characteristic of oneOf vs anyOf). While line 228-247 tests this behavior in a more complex scenario, the basic oneOf test would benefit from a case that demonstrates exclusive matching.

Based on learnings, past review feedback suggested verifying that oneOf fails when multiple schemas match.

Consider adding a test case like:

test("oneOf schema (exclusive union)", () => {
  const schema = fromJSONSchema({
    oneOf: [{ type: "string" }, { type: "number" }],
  });
  expect(schema.parse("hello")).toBe("hello");
  expect(schema.parse(42)).toBe(42);
  expect(() => schema.parse(true)).toThrow();
  
  // Test exclusive matching: schema that could match multiple branches
  const ambiguousSchema = fromJSONSchema({
    oneOf: [
      { type: "string", minLength: 3 },
      { type: "string", maxLength: 10 }
    ]
  });
  // Should pass: only matches first (length >= 3, no maxLength constraint violated)
  expect(ambiguousSchema.parse("hi!")).toBe("hi!");
  // Should fail: matches both (3 <= length <= 10)
  expect(() => ambiguousSchema.parse("hello")).toThrow();
});
packages/zod/src/v4/classic/schemas.ts (1)

1446-1458: Add JSDoc documentation for the new public API.

The looseRecord function is missing JSDoc documentation. Consider adding comprehensive documentation that explains:

  • What loose mode does and how it differs from the default record behavior
  • When developers should use looseRecord vs record
  • Parameter descriptions
  • A usage example

Based on learnings, this aligns with past review feedback about missing documentation.

Example:

+/**
+ * Creates a loose record schema that validates matching keys but passes through non-matching keys unchanged.
+ * Unlike `record()`, which validates all keys, `looseRecord()` only applies validation to keys that match
+ * the keyType pattern, allowing other keys to pass through without validation.
+ * 
+ * @param keyType - Schema for validating record keys (e.g., z.string(), z.enum(['a', 'b']))
+ * @param valueType - Schema for validating record values
+ * @param params - Optional parameters for error messages
+ * @returns A ZodRecord schema with loose mode enabled
+ * 
+ * @example
+ * const schema = z.looseRecord(z.string(), z.number());
+ * schema.parse({ a: 1, b: 2, other: 'string' }); // passes: non-matching keys allowed
+ */
 export function looseRecord<Key extends core.$ZodRecordKey, Value extends core.SomeType>(
packages/zod/src/v4/classic/from-json-schema.ts (3)

48-58: OpenAPI 3.0 ref resolution might not work.

The ref resolution only handles #/$defs/... or #/definitions/... paths. OpenAPI 3.0 typically uses #/components/schemas/... for refs. If someone passes an OpenAPI 3.0 schema with refs, this will throw "Reference not found".

Consider adding OpenAPI 3.0 support:

  const defsKey = ctx.version === "draft-2020-12" ? "$defs" : "definitions";

  if (path[0] === defsKey) {
    const key = path[1];
    if (!key || !ctx.defs[key]) {
      throw new Error(`Reference not found: ${ref}`);
    }
    return ctx.defs[key]!;
  }

+  // OpenAPI 3.0: #/components/schemas/...
+  if (ctx.version === "openapi-3.0" && path[0] === "components" && path[1] === "schemas") {
+    const key = path[2];
+    if (!key || !ctx.defs[key]) {
+      throw new Error(`Reference not found: ${ref}`);
+    }
+    return ctx.defs[key]!;
+  }

  throw new Error(`Reference not found: ${ref}`);

You'd also need to update line 494 to pull defs from schema.components?.schemas for OpenAPI 3.0.


301-336: patternProperties breaks out early, skipping additionalProperties.

When patternProperties is present, the code breaks at line 335, which means the additionalProperties handling below (lines 338-351) never runs. In JSON Schema, both keywords can coexist - additionalProperties controls keys that don't match any pattern OR any explicit property.

The additionalProperties logic should be integrated into the patternProperties block. This is a semantic mismatch with JSON Schema behavior.

        if (schemasToIntersect.length === 0) {
          zodSchema = z.object({}).passthrough();
        } else if (schemasToIntersect.length === 1) {
          zodSchema = schemasToIntersect[0]!;
        } else {
          // Chain intersections: (A & B) & C & D ...
          let result = z.intersection(schemasToIntersect[0]!, schemasToIntersect[1]!);
          for (let i = 2; i < schemasToIntersect.length; i++) {
            result = z.intersection(result, schemasToIntersect[i]!);
          }
          zodSchema = result;
        }
+       // Note: additionalProperties handling is complex with patternProperties
+       // For now, patternProperties mode uses passthrough behavior
        break;

At minimum, add a TODO or throw an error when both are specified to avoid silent incorrect behavior.


447-458: Both anyOf and oneOf can appear together - current code handles sequentially.

If a schema has both anyOf AND oneOf, the code processes them one after another. The second one wraps/intersects with the result of the first. This might not match JSON Schema semantics where both constraints should independently apply to the input.

This is a pretty edge-case scenario, but worth a comment or throwing an error for unsupported combinations.

+  // Note: Having both anyOf and oneOf in the same schema is unusual.
+  // We process them sequentially, which may not match strict JSON Schema semantics.
   // Handle anyOf - wrap base schema with union
   if (schema.anyOf && Array.isArray(schema.anyOf)) {
🧹 Nitpick comments (1)
packages/zod/src/v4/classic/from-json-schema.ts (1)

3-3: Unused imports.

ZodNumber and ZodString are imported but only used as type annotations. Since they're only used in type positions, you could change to import type for cleaner tree-shaking, though this isn't blocking.

-import type { ZodNumber, ZodString, ZodType } from "./schemas.js";
+import type { ZodType } from "./schemas.js";
+import type { ZodNumber, ZodString } from "./schemas.js";

Or just keep them as-is since they're type-only anyway.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between d632df3 and d89bdc1.

📒 Files selected for processing (16)
  • packages/docs/content/api.mdx (1 hunks)
  • packages/treeshake/zod-locale.ts (1 hunks)
  • packages/treeshake/zod-string.ts (1 hunks)
  • packages/zod/src/v4/classic/external.ts (1 hunks)
  • packages/zod/src/v4/classic/from-json-schema.ts (1 hunks)
  • packages/zod/src/v4/classic/schemas.ts (1 hunks)
  • packages/zod/src/v4/classic/tests/from-json-schema.test.ts (1 hunks)
  • packages/zod/src/v4/classic/tests/record.test.ts (1 hunks)
  • packages/zod/src/v4/classic/tests/union.test.ts (1 hunks)
  • packages/zod/src/v4/core/errors.ts (1 hunks)
  • packages/zod/src/v4/core/index.ts (0 hunks)
  • packages/zod/src/v4/core/schemas.ts (6 hunks)
  • packages/zod/src/v4/core/to-json-schema.ts (1 hunks)
  • packages/zod/src/v4/mini/external.ts (1 hunks)
  • packages/zod/src/v4/mini/schemas.ts (1 hunks)
  • play.ts (1 hunks)
💤 Files with no reviewable changes (1)
  • packages/zod/src/v4/core/index.ts
🧰 Additional context used
📓 Path-based instructions (4)
**/*.{js,mjs,cjs,ts,mts,cts}

📄 CodeRabbit inference engine (AGENTS.md)

Use ES modules throughout ("type": "module")

Files:

  • packages/treeshake/zod-locale.ts
  • packages/zod/src/v4/mini/external.ts
  • packages/zod/src/v4/classic/tests/union.test.ts
  • packages/zod/src/v4/classic/from-json-schema.ts
  • packages/treeshake/zod-string.ts
  • packages/zod/src/v4/classic/external.ts
  • packages/zod/src/v4/classic/tests/from-json-schema.test.ts
  • packages/zod/src/v4/classic/schemas.ts
  • packages/zod/src/v4/core/to-json-schema.ts
  • packages/zod/src/v4/classic/tests/record.test.ts
  • packages/zod/src/v4/core/schemas.ts
  • packages/zod/src/v4/core/errors.ts
  • packages/zod/src/v4/mini/schemas.ts
  • play.ts
**/*.{ts,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

**/*.{ts,tsx}: Don't skip tests due to type issues - fix the types instead in TypeScript files
Use util.defineLazy() for computed properties to avoid circular dependencies in TypeScript

Files:

  • packages/treeshake/zod-locale.ts
  • packages/zod/src/v4/mini/external.ts
  • packages/zod/src/v4/classic/tests/union.test.ts
  • packages/zod/src/v4/classic/from-json-schema.ts
  • packages/treeshake/zod-string.ts
  • packages/zod/src/v4/classic/external.ts
  • packages/zod/src/v4/classic/tests/from-json-schema.test.ts
  • packages/zod/src/v4/classic/schemas.ts
  • packages/zod/src/v4/core/to-json-schema.ts
  • packages/zod/src/v4/classic/tests/record.test.ts
  • packages/zod/src/v4/core/schemas.ts
  • packages/zod/src/v4/core/errors.ts
  • packages/zod/src/v4/mini/schemas.ts
  • play.ts
**/*.{js,mjs,cjs,ts,mts,cts,tsx,jsx}

📄 CodeRabbit inference engine (AGENTS.md)

**/*.{js,mjs,cjs,ts,mts,cts,tsx,jsx}: No log statements (console.log, debugger) in tests or production code
Parameter reassignment is allowed for performance optimization in TypeScript/JavaScript code

Files:

  • packages/treeshake/zod-locale.ts
  • packages/zod/src/v4/mini/external.ts
  • packages/zod/src/v4/classic/tests/union.test.ts
  • packages/zod/src/v4/classic/from-json-schema.ts
  • packages/treeshake/zod-string.ts
  • packages/zod/src/v4/classic/external.ts
  • packages/zod/src/v4/classic/tests/from-json-schema.test.ts
  • packages/zod/src/v4/classic/schemas.ts
  • packages/zod/src/v4/core/to-json-schema.ts
  • packages/zod/src/v4/classic/tests/record.test.ts
  • packages/zod/src/v4/core/schemas.ts
  • packages/zod/src/v4/core/errors.ts
  • packages/zod/src/v4/mini/schemas.ts
  • play.ts
**/*.test.{ts,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

All tests must be written in TypeScript; never use JavaScript for tests

Files:

  • packages/zod/src/v4/classic/tests/union.test.ts
  • packages/zod/src/v4/classic/tests/from-json-schema.test.ts
  • packages/zod/src/v4/classic/tests/record.test.ts
🧠 Learnings (2)
📚 Learning: 2025-11-25T20:47:30.768Z
Learnt from: CR
Repo: colinhacks/zod PR: 0
File: AGENTS.md:0-0
Timestamp: 2025-11-25T20:47:30.768Z
Learning: Use `play.ts` for quick experimentation; use proper tests for all permanent test cases

Applied to files:

  • packages/treeshake/zod-locale.ts
  • play.ts
📚 Learning: 2025-11-25T20:47:30.768Z
Learnt from: CR
Repo: colinhacks/zod PR: 0
File: AGENTS.md:0-0
Timestamp: 2025-11-25T20:47:30.768Z
Learning: Applies to **/*.{ts,tsx} : Don't skip tests due to type issues - fix the types instead in TypeScript files

Applied to files:

  • packages/zod/src/v4/classic/tests/union.test.ts
  • play.ts
🧬 Code graph analysis (4)
packages/treeshake/zod-locale.ts (1)
packages/zod/src/index.ts (1)
  • z (3-3)
packages/zod/src/v4/classic/tests/from-json-schema.test.ts (2)
packages/zod/src/v4/classic/from-json-schema.ts (1)
  • fromJSONSchema (487-505)
packages/zod/src/v4/classic/external.ts (1)
  • fromJSONSchema (33-33)
packages/zod/src/v4/core/schemas.ts (2)
packages/zod/src/v3/helpers/parseUtil.ts (1)
  • ParseContext (53-64)
packages/zod/src/v4/core/core.ts (1)
  • input (107-107)
play.ts (3)
packages/zod/src/v4/core/core.ts (3)
  • input (107-107)
  • output (108-108)
  • output (110-110)
packages/zod/src/v3/types.ts (2)
  • input (52-52)
  • output (53-53)
packages/zod/src/v4/classic/compat.ts (2)
  • output (8-8)
  • output (10-10)
🪛 ast-grep (0.40.0)
packages/zod/src/v4/classic/from-json-schema.ts

[warning] 241-241: Regular expression constructed from variable input detected. This can lead to Regular Expression Denial of Service (ReDoS) attacks if the variable contains malicious patterns. Use libraries like 'recheck' to validate regex safety or use static patterns.
Context: new RegExp(schema.pattern)
Note: [CWE-1333] Inefficient Regular Expression Complexity [REFERENCES]
- https://owasp.org/www-community/attacks/Regular_expression_Denial_of_Service_-_ReDoS
- https://cwe.mitre.org/data/definitions/1333.html

(regexp-from-variable)


[warning] 310-310: Regular expression constructed from variable input detected. This can lead to Regular Expression Denial of Service (ReDoS) attacks if the variable contains malicious patterns. Use libraries like 'recheck' to validate regex safety or use static patterns.
Context: new RegExp(pattern)
Note: [CWE-1333] Inefficient Regular Expression Complexity [REFERENCES]
- https://owasp.org/www-community/attacks/Regular_expression_Denial_of_Service_-_ReDoS
- https://cwe.mitre.org/data/definitions/1333.html

(regexp-from-variable)

🔇 Additional comments (16)
packages/zod/src/v4/mini/external.ts (1)

24-24: LGTM: Clean export reorganization.

Moving toJSONSchema to a dedicated module export improves modularity and keeps the codebase organized.

packages/zod/src/v4/classic/tests/record.test.ts (1)

490-525: Excellent test coverage for looseRecord.

Both tests thoroughly validate the new looseRecord functionality:

  1. Ensures matching keys are validated while non-matching keys pass through
  2. Tests complex intersections with multiple loose record patterns

The tests cover all the important edge cases and demonstrate the practical use of the feature.

packages/zod/src/v4/core/to-json-schema.ts (1)

333-341: LGTM: Correct JSON Schema generation for exclusive unions.

The updated logic correctly emits oneOf when either:

  • The union is explicitly exclusive (def.exclusive), or
  • The union has a discriminator (which implies mutual exclusivity)

This aligns perfectly with the new exclusive union feature and follows JSON Schema semantics where oneOf enforces exactly one match.

packages/zod/src/v4/classic/external.ts (1)

32-33: LGTM! Clean export reorganization.

The re-export changes look good. Moving toJSONSchema to a dedicated module and adding the new fromJSONSchema export follows good separation of concerns and makes the API surface clearer.

packages/zod/src/v4/classic/tests/from-json-schema.test.ts (2)

24-35: Excellent pattern validation coverage!

The pattern and patternProperties tests are well-designed. Particularly appreciate:

  • Lines 24-35 explicitly verify that JSON Schema patterns are not implicitly anchored (important distinction from some regex implementations)
  • Lines 326-387 cover single, multiple, and overlapping pattern properties
  • Good comments explaining expected behavior (e.g., line 340-341)

Also applies to: 326-387


1-537: Comprehensive test coverage - nice work!

The test suite provides excellent coverage of JSON Schema features:

  • ✅ All primitive types with constraints
  • ✅ Complex types (objects, arrays, tuples)
  • ✅ Composition keywords (anyOf, allOf, oneOf)
  • ✅ References (local, circular with lazy)
  • ✅ Pattern properties (single, multiple, overlapping)
  • ✅ Error cases for unsupported features
  • ✅ Version detection across multiple JSON Schema drafts
  • ✅ OpenAPI 3.0 nullable support

The tests are well-structured with descriptive names and cover both happy paths and error scenarios.

packages/zod/src/v4/core/schemas.ts (5)

2020-2020: Nice addition of the exclusive flag.

Clean addition to the union definition. This aligns well with JSON Schema's oneOf semantics where exactly one schema must match.


2068-2101: Exclusive union handler looks solid.

The logic correctly handles the three cases:

  • Single match → success
  • No matches → standard invalid_union error with all errors
  • Multiple matches → exclusive union failure with inclusive: false

One small thing - when there are multiple matches, the errors array is empty. This is intentional for the type system, but users debugging might appreciate knowing which schemas matched. Not blocking, just something to consider for future DX improvements.


2152-2163: Short-circuit suppression for exclusive mode is correct.

Good call on only short-circuiting when NOT exclusive. For exclusive unions, we need to validate all options to ensure exactly one matches.


2578-2579: JSDoc added as requested in past reviews - nice.

The documentation clarifies the behavior difference between strict and loose modes.


2707-2721: Loose mode pass-through is well-tested—looks good.

The tests comprehensively cover loose mode behavior: non-matching keys pass through unvalidated while matching keys still get validated. This is the intended design for JSON Schema patternProperties support, and it's working correctly. The behavior is documented and tested thoroughly, so no action needed here.

packages/zod/src/v4/classic/from-json-schema.ts (3)

152-165: Potential infinite recursion when expanding type arrays.

When type is an array like ["string", "number"], you spread the entire schema including the type array itself into each variant. If convertBaseSchema is called recursively with this structure, it could loop infinitely.

Looking more closely... actually you're setting type: t which overwrites the array with a single string. So this should be fine. The spread happens first, then type: t overwrites.

Actually wait - the concern from past reviews is valid. When you do { ...schema, type: t }, the type property gets the single string t, not the array. So this is safe. My bad, disregard!


461-472: Empty allOf array edge case.

Nice handling of the empty allOf case at line 462-463. Returns the base schema or z.any() depending on whether there's an explicit type.


487-505: Clean entry point.

The main fromJSONSchema function is well-structured - handles boolean schemas upfront, detects version, initializes context, and delegates to convertSchema.

packages/zod/src/v4/core/errors.ts (2)

65-81: Nice refactor to discriminated union for invalid_union issues.

Breaking this into NoMatch and MultipleMatch variants with inclusive as the discriminant is clean. TypeScript can now narrow properly:

  • inclusive?: true (or undefined) → no matches, errors contains per-option issues
  • inclusive: false → multiple matches (exclusive union failure), errors is empty

This pairs well with the handleExclusiveUnionResults function in schemas.ts.


270-273: Error formatting handles empty errors array correctly.

The check issue.errors.length at line 272 will be falsy for the MultipleMatch variant (where errors: []), so it won't try to recursively process an empty array.

Comment on lines +240 to +242
if (schema.pattern) {
// JSON Schema patterns are not implicitly anchored (match anywhere in string)
stringSchema = stringSchema.regex(new RegExp(schema.pattern));
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

# First, let's examine the file and surrounding context
cat -n packages/zod/src/v4/classic/from-json-schema.ts | sed -n '230,260p'

Repository: colinhacks/zod

Length of output: 1292


🏁 Script executed:

# Search for any existing regex validation or safety measures in the codebase
rg -i "redog|safe-regex|regex.*timeout|pattern.*validat" --type ts --type js

Repository: colinhacks/zod

Length of output: 468


🏁 Script executed:

# Check if there are any comments or documentation about schema trust assumptions
rg -B5 -A5 "schema\.pattern" packages/zod/src/v4/classic/from-json-schema.ts

Repository: colinhacks/zod

Length of output: 1159


🏁 Script executed:

# Look for how schemas are typically loaded/used in tests or examples
fd -e ts -e js | xargs rg -l "from-json-schema|fromJsonSchema" | head -10

Repository: colinhacks/zod

Length of output: 156


🌐 Web query:

ReDoS attacks JavaScript RegExp prevention best practices safe-regex

💡 Result:

Below are concise best practices to prevent JavaScript ReDoS (Regular Expression Denial of Service), plus detection/mitigation tools.

Summary best practices

  • Avoid running untrusted/user-supplied regexes; prefer literal text search or restricted patterns. [1][2]
  • Limit input size and enforce sane length/complexity limits before matching. [1][4]
  • Avoid vulnerable constructs: nested or adjacent quantifiers (e.g. (a+)+), ambiguous alternation (e.g. (a|aa)+), and backtracking‑heavy patterns. Rewrite to be deterministic when possible. [1][2]
  • Prefer safe engines that guarantee linear-time matching (RE2 or bindings) when you must accept complex/untrusted patterns. Use node-re2 / re2-wasm etc. as a drop‑in safe alternative. [4][7]
  • If you must use backtracking engine (native RegExp), add runtime protections: execute regex work in a worker/isolated process and enforce timeouts or input-size cutoffs so one bad input can’t hang the main thread. [1][3]
  • Use atomic/possessive techniques when supported (note: JavaScript lacks native atomic groups/possessive quantifiers; there are workarounds but they’re complex). Prefer engine change if you need these features for safety. [6][8]

Detection and testing tools

  • safe-regex / safe-regex2 — quick heuristic checker (may have false positives/negatives). Good for CI pre-checks. [2][5]
  • redos-detector / vuln-regex-detector — stronger analyzers that attempt to detect exploitable patterns and produce attack strings. Use these for deeper analysis. [3][1]
  • Static review / unit tests — include long “evil” test strings (generated or from detectors) in CI to verify performance. [3]

Practical mitigation recipe

  1. Static-check all regexes at authoring/CI time (safe-regex / redos-detector). Flag anything unsafe. [2][3]
  2. Reject or sandbox any user-supplied regexes; if allowed, run them with an engine like RE2 or in an isolated worker with time/cpu limits. [4][3]
  3. Enforce input length limits + deploy runtime timeouts for regex operations that might run on large inputs. [1][3]
  4. When possible, refactor fragile regexes to deterministic alternatives (explicit parsing, finite-state logic, or RE2-compatible patterns). [6][7]

References (examples)

  • OWASP: ReDoS / overly permissive regex guidance. [1]
  • safe-regex / safe-regex2 (heuristic checker). [2][5]
  • redos-detector / vuln-regex-detector (stronger detectors, attack-string generation). [3][1]
  • RE2 (node-re2 / re2-wasm) — linear-time regex engine recommended to eliminate ReDoS risk. [4][7]

If you want, I can:

  • scan a codebase or a list of regexes with redos-detector and report any unsafe patterns; or
  • show concrete rewrites of a specific vulnerable regex you paste here.

🏁 Script executed:

# Check documentation and comments about schema sources
rg -B10 "export.*fromJsonSchema|export.*from-json-schema" packages/zod/src/v4/classic/ -A5

Repository: colinhacks/zod

Length of output: 1039


🏁 Script executed:

# Look at test cases to understand typical usage patterns
cat -n packages/zod/src/v4/classic/tests/from-json-schema.test.ts | head -100

Repository: colinhacks/zod

Length of output: 3953


🏁 Script executed:

# Check for any README or docs mentioning security or trusted schemas
fd -e md -e txt | xargs rg -i "json schema|pattern|regex|trusted|security" 2>/dev/null | grep -i "from.*json\|pattern\|untrusted" | head -20

Repository: colinhacks/zod

Length of output: 1683


🏁 Script executed:

# Search for how zod handles user-supplied regexes elsewhere
rg -B3 -A3 "\.regex\(" packages/zod/src/ --type ts | head -80

Repository: colinhacks/zod

Length of output: 6189


🏁 Script executed:

# Check the function signature and documentation
cat -n packages/zod/src/v4/classic/from-json-schema.ts | head -50

Repository: colinhacks/zod

Length of output: 1932


🏁 Script executed:

# Look for any JSDoc or comments about schema input expectations
rg -B20 "export.*function.*fromJSONSchema|export.*const.*fromJSONSchema" packages/zod/src/v4/classic/from-json-schema.ts | head -40

Repository: colinhacks/zod

Length of output: 630


Document that fromJSONSchema expects trusted schema sources to mitigate ReDoS risk.

Constructing RegExp from untrusted schema.pattern values (line 242) and pattern properties (used in patternProperties handling) can enable ReDoS attacks. Since JSON Schema conversion typically happens at build/startup time with trusted schemas, document this assumption clearly. If untrusted schemas must be supported, consider adding pattern complexity validation (e.g., safe-regex package) or wrapping regex execution with timeouts.

🧰 Tools
🪛 ast-grep (0.40.0)

[warning] 241-241: Regular expression constructed from variable input detected. This can lead to Regular Expression Denial of Service (ReDoS) attacks if the variable contains malicious patterns. Use libraries like 'recheck' to validate regex safety or use static patterns.
Context: new RegExp(schema.pattern)
Note: [CWE-1333] Inefficient Regular Expression Complexity [REFERENCES]
- https://owasp.org/www-community/attacks/Regular_expression_Denial_of_Service_-_ReDoS
- https://cwe.mitre.org/data/definitions/1333.html

(regexp-from-variable)

🤖 Prompt for AI Agents
In packages/zod/src/v4/classic/from-json-schema.ts around lines 240 to 242, the
code constructs RegExp instances directly from schema.pattern (and likewise from
patternProperties elsewhere), which is unsafe for untrusted input; update the
codebase and docs to state clearly that fromJSONSchema expects trusted schema
sources (e.g., build/startup-time schemas) and will not sanitize malicious
patterns, and if you need to support untrusted schemas either validate pattern
complexity with a library like safe-regex (or similar) before constructing the
RegExp, or implement a guarded execution strategy (e.g., timeouts/worker
isolation) when running regexes; also add a short note to the function JSDoc and
README and add a comment near the RegExp construction mentioning the ReDoS risk
and the chosen mitigation.

Comment on lines +1089 to +1101
export function looseRecord<Key extends core.$ZodRecordKey, Value extends SomeType>(
keyType: Key,
valueType: Value,
params?: string | core.$ZodRecordParams
): ZodMiniRecord<Key, Value> {
return new ZodMiniRecord({
type: "record",
keyType,
valueType: valueType as any as core.$ZodType,
mode: "loose",
...util.normalizeParams(params),
}) as any;
}
Copy link
Contributor

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major

Add JSDoc documentation to match the classic API.

The looseRecord function in the mini API is missing JSDoc documentation. For API consistency, this should have the same level of documentation as the classic version. Consider adding comprehensive JSDoc that explains the loose mode behavior and when to use this function.

Refer to the documentation suggestion for the classic version in packages/zod/src/v4/classic/schemas.ts for consistency.

🤖 Prompt for AI Agents
In packages/zod/src/v4/mini/schemas.ts around lines 1089 to 1101, add JSDoc to
the looseRecord export to match the classic API: document the function
signature, generic Key/Value parameters, explain "loose" mode semantics (keys
coerce/allow unknown extra keys or similar behavior used by classic version),
describe params argument (string or $ZodRecordParams) and returned ZodMiniRecord
type, and include short usage examples and notes about when to use looseRecord
versus strict records; mirror wording and format from
packages/zod/src/v4/classic/schemas.ts for consistency.

@colinhacks
Copy link
Owner Author

cc @pullfrog review with a focus on the XOR stuff

Copy link
Contributor

@pullfrog pullfrog bot left a comment

Choose a reason for hiding this comment

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

Focused review on XOR implementation - looks solid overall with good test coverage. Found a few minor considerations around code clarity and edge cases.

Pullfrog  | Fix all ➔Fix 👍s ➔pullfrog.com𝕏

for (const option of def.options) {
const result = option._zod.run(
{
value: payload.value,
Copy link
Contributor

Choose a reason for hiding this comment

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

Observation: XOR evaluates all options - Unlike regular unions which early-return on first success (line 2111 in original), XOR must evaluate all options to detect multiple matches. Consider adding a comment here explaining why we can't early-return, to make the intentional difference clear.

final.issues.push({
code: "invalid_union",
input: final.value,
inst,
Copy link
Contributor

Choose a reason for hiding this comment

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

Empty errors array for multiple matches - When multiple options match, you're correctly using inclusive: false with an empty errors array to distinguish from zero-match failures. This is semantically correct but might be surprising to error consumers. Verify that error formatters/locales handle this case properly (checking inclusive field when errors is empty).

inst: $ZodUnion,
ctx?: ParseContext
) {
const successes = results.filter((r) => r.issues.length === 0);
Copy link
Contributor

Choose a reason for hiding this comment

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

Clean XOR implementation - The logic here is clear: filter successes, handle exactly-one vs zero vs multiple. Nice separation of concerns with the dedicated handleExclusiveUnionResults function.

const element = convertSchema(items as JSONSchema.JSONSchema, ctx);
let arraySchema = z.array(element);

// Apply constraints
Copy link
Contributor

Choose a reason for hiding this comment

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

Perfect mapping of JSON Schema oneOf to z.xor() - This correctly implements JSON Schema's 'exactly one' semantics. The intersection handling for when hasExplicitType is also correct.

stringSchema = stringSchema.check(z.e164());
} else if (format === "jwt") {
stringSchema = stringSchema.check(z.jwt());
} else if (format === "emoji") {
Copy link
Contributor

Choose a reason for hiding this comment

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

Pattern behavior differs from Zod regex() - Good comment! JSON Schema patterns match anywhere (like JS .match()), while Zod's .regex() is implicitly anchored (like .test() with ^...$). Since you're passing the pattern directly to new RegExp(), this correctly preserves JSON Schema semantics. Worth noting in docs that users migrating patterns from Zod to JSON Schema may need to adjust.

z.object({ type: z.literal("card"), cardNumber: z.string() }),
z.object({ type: z.literal("bank"), accountNumber: z.string() }),
]);

Copy link
Contributor

Choose a reason for hiding this comment

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

Excellent XOR documentation - The overlapping example clearly demonstrates the failure case. One enhancement: consider showing what the actual error message looks like so users know how to debug XOR failures (e.g., does it say 'multiple matches' or something else?).

@pullfrog
Copy link
Contributor

pullfrog bot commented Dec 14, 2025

Review Complete - XOR Implementation Focus

Reviewed the PR with specific attention to the XOR functionality as requested. Here's what I found:

XOR Implementation (z.xor())

  • ✅ Core logic is solid - correctly evaluates all options to detect multiple matches
  • ✅ Good separation of concerns with handleExclusiveUnionResults helper
  • ✅ Proper error handling distinguishing zero matches vs multiple matches via inclusive: false
  • 💡 Suggested adding comments explaining why XOR can't early-return like regular unions

JSON Schema Integration

  • ✅ Correctly maps JSON Schema oneOf to z.xor()
  • ✅ Proper handling of intersection when combined with explicit types
  • ✅ Pattern matching correctly preserves JSON Schema semantics (not implicitly anchored)

Documentation & Tests

  • ✅ Clear examples in api.mdx showing both success and failure cases
  • ✅ Good test coverage including edge cases
  • 💡 Suggested showing error messages in docs to help users debug XOR failures

Overall: The XOR implementation is well-designed and correctly implements exclusive union semantics. The integration with fromJSONSchema properly maps JSON Schema's oneOf behavior. No blocking issues found.

Pullfrog  | Triggered by Pullfrog | Using Claude CodeView workflow runpullfrog.com𝕏

@colinhacks colinhacks changed the title Add z.fromJSONSchema(), z.looseRecord(),z.xor() Add z.fromJSONSchema(), z.looseRecord(), z.xor() Dec 15, 2025
@colinhacks colinhacks merged commit e17dcb6 into main Dec 15, 2025
5 checks passed
@colinhacks colinhacks deleted the from-json-schema branch December 15, 2025 07:17
@brettz9
Copy link

brettz9 commented Dec 15, 2025

The release notes use xor() without an array:

z.xor(
  z.object({ type: "user", name: z.string() }),
  z.object({ type: "admin", role: z.string() })
)

...whicha appears to be an error?

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.

3 participants