diff --git a/src/core/converter.ts b/src/core/converter.ts index 76184fc..f83ddb8 100644 --- a/src/core/converter.ts +++ b/src/core/converter.ts @@ -74,9 +74,10 @@ const primitiveHandlers: PrimitiveHandler[] = [ new ItemsHandler(), // Object constraints + // Build full object shape early so later refinements operate on the final structure. + new PropertiesHandler(), new MaxPropertiesHandler(), new MinPropertiesHandler(), - new PropertiesHandler(), ]; const refinementHandlers: RefinementHandler[] = [ @@ -86,13 +87,14 @@ const refinementHandlers: RefinementHandler[] = [ new ConstComplexHandler(), // Logical combinations + // Run object refinement once the base shape exists, ahead of combinator refinements. + new ObjectPropertiesHandler(), new AllOfHandler(), new AnyOfHandler(), new OneOfHandler(), // Type-specific refinements new PrefixItemsHandler(), - new ObjectPropertiesHandler(), // Array refinements new ContainsHandler(), diff --git a/src/core/types.ts b/src/core/types.ts index d35686b..51616e3 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -8,7 +8,8 @@ export interface TypeSchemas { null?: z.ZodNull | false; array?: z.ZodArray | false; tuple?: z.ZodTuple | false; - object?: z.ZodObject | false; + // Object schemas may be wrapped (preprocess/pipe), so allow any Zod type to preserve wrappers applied during conversion. + object?: z.ZodTypeAny | false; } export interface PrimitiveHandler { diff --git a/src/core/utils.test.ts b/src/core/utils.test.ts new file mode 100644 index 0000000..18ae2cf --- /dev/null +++ b/src/core/utils.test.ts @@ -0,0 +1,25 @@ +import { describe, it, expect } from "vitest"; +import { z } from "zod/v4"; +import { unwrapPreprocess } from "./utils"; + +describe("unwrapPreprocess", () => { + it("returns the schema unchanged when no preprocess wrappers are present", () => { + const base = z.object({ foo: z.string() }); + expect(unwrapPreprocess(base)).toBe(base); + }); + + it("unwraps single preprocess layers applied during conversion", () => { + const base = z.object({ foo: z.string() }); + const wrapped = z.preprocess((value) => value, base); + expect(unwrapPreprocess(wrapped)).toBe(base); + }); + + it("unwraps nested preprocess and pipe combinations", () => { + const base = z.object({ foo: z.string() }); + const wrapped = z + .preprocess((value) => value, base) + .pipe(base); + + expect(unwrapPreprocess(wrapped)).toBe(base); + }); +}); diff --git a/src/core/utils.ts b/src/core/utils.ts index d913ddd..29ac456 100644 --- a/src/core/utils.ts +++ b/src/core/utils.ts @@ -7,19 +7,19 @@ export function deepEqual(a: any, b: any): boolean { if (a === b) return true; if (a == null || b == null) return a === b; if (typeof a !== typeof b) return false; - + if (Array.isArray(a) && Array.isArray(b)) { if (a.length !== b.length) return false; return a.every((item, index) => deepEqual(item, b[index])); } - - if (typeof a === 'object' && typeof b === 'object') { + + if (typeof a === "object" && typeof b === "object") { const keysA = Object.keys(a); const keysB = Object.keys(b); if (keysA.length !== keysB.length) return false; - return keysA.every(key => keysB.includes(key) && deepEqual(a[key], b[key])); + return keysA.every((key) => keysB.includes(key) && deepEqual(a[key], b[key])); } - + return false; } @@ -49,4 +49,28 @@ export function createUniqueItemsValidator() { */ export function isValidWithSchema(schema: z.ZodTypeAny, value: any): boolean { return schema.safeParse(value).success; -} \ No newline at end of file +} + +/** + * Removes preprocess/pipe wrappers so downstream consumers can treat the schema as its underlying type. + */ +export function unwrapPreprocess(schema: z.ZodTypeAny): z.ZodTypeAny { + let current = schema; + + while (true) { + const def = (current as any)?._def; + if (def?.effect?.type === "preprocess" && def?.schema) { + current = def.schema; + continue; + } + + if (def?.type === "pipe" && def?.out) { + current = def.out; + continue; + } + + break; + } + + return current; +} diff --git a/src/discriminated-union.test.ts b/src/discriminated-union.test.ts new file mode 100644 index 0000000..ec496a6 --- /dev/null +++ b/src/discriminated-union.test.ts @@ -0,0 +1,119 @@ +import { describe, it, expect } from "vitest"; +import { convertJsonSchemaToZod } from "./core/converter"; + +const schema = { + type: "object", + required: ["nodeType", "parentNodeId"], + properties: { + // Common properties defined ONCE at the top level + parentNodeId: { + type: "string", + }, + value: { + type: "string", + }, + }, + oneOf: [ + { + // QUESTION Node - only discriminator and variant-specific properties + properties: { + nodeType: { const: "QUESTION" }, + nodeSpecifics: { + type: "object", + properties: { + questionName: { + type: ["string", "null"], + }, + }, + required: ["questionName"], + unevaluatedProperties: false, + }, + }, + }, + { + // DECISION Node + properties: { + nodeType: { const: "DECISION" }, + nodeSpecifics: { + type: "object", + properties: { + notes: { type: ["string", "null"] }, + }, + required: ["notes"], + unevaluatedProperties: false, + }, + }, + }, + ], + unevaluatedProperties: false, +}; + +it("should validate QUESTION", () => { + const input = { + parentNodeId: "123", + value: "456", + nodeType: "QUESTION", // the type here defines what's allowed in the nodeSpecifics, and in this case they match + nodeSpecifics: { + questionName: "What is your name?", + }, + }; + + const zodSchema = convertJsonSchemaToZod(schema as any); + expect(zodSchema.safeParse(input).success).toBe(true); +}); + +it("should validate DECISION", () => { + const input = { + parentNodeId: "123", + value: "456", + nodeType: "DECISION", // the type here defines what's allowed in the nodeSpecifics, and in this case they match + nodeSpecifics: { + notes: "some notes", + }, + }; + + const zodSchema = convertJsonSchemaToZod(schema as any); + expect(zodSchema.safeParse(input).success).toBe(true); +}); + +it("should not validate DECISION with questionName", () => { + const input = { + parentNodeId: "123", + value: "456", + nodeType: "DECISION", // the type here defines what's allowed in the nodeSpecifics, and in this case they do not match + nodeSpecifics: { + questionName: "What is your name?", + }, + }; + + const zodSchema = convertJsonSchemaToZod(schema as any); + expect(zodSchema.safeParse(input).success).toBe(false); +}); + +it("should not validate QUESTION with notes", () => { + const input = { + parentNodeId: "123", + value: "456", + nodeType: "QUESTION", // the type here defines what's allowed in the nodeSpecifics, and in this case they do not match + nodeSpecifics: { + notes: "some notes", + }, + }; + + const zodSchema = convertJsonSchemaToZod(schema as any); + expect(zodSchema.safeParse(input).success).toBe(false); +}); + +it("should not validate INVALID", () => { + const input = { + parentNodeId: "123", + value: "456", + nodeType: "INVALID", // the type here defines what's allowed in the nodeSpecifics, the node type is completely invalid + nodeSpecifics: { + notes: "some notes", + }, + }; + + const zodSchema = convertJsonSchemaToZod(schema as any); + expect(zodSchema.safeParse(input).success).toBe(false); +}); diff --git a/src/handlers/primitive/object.ts b/src/handlers/primitive/object.ts index 822d7af..2205ddb 100644 --- a/src/handlers/primitive/object.ts +++ b/src/handlers/primitive/object.ts @@ -1,6 +1,25 @@ import { z } from "zod/v4"; import type { JSONSchema } from "zod/v4/core"; import { PrimitiveHandler, TypeSchemas } from "../../core/types"; +import { convertJsonSchemaToZod } from "../../core/converter"; + +// Clone incoming objects onto a null-prototype so JSON Schema keywords can see "__proto__" et al. as real properties without mutating the original input. +function sanitizeObjectInput(input: unknown): unknown { + if (typeof input !== "object" || input === null || Array.isArray(input)) { + return input; + } + + const source = input as Record; + const target: Record = Object.create(null); + + // A plain assignment is enough—accessor properties will resolve through the original object, + // and all own keys (including "__proto__" and symbols) are preserved. + for (const key of Reflect.ownKeys(source)) { + target[key] = source[key]; + } + + return target; +} export class PropertiesHandler implements PrimitiveHandler { apply(types: TypeSchemas, schema: JSONSchema.BaseSchema): void { @@ -9,12 +28,70 @@ export class PropertiesHandler implements PrimitiveHandler { // Only process if object is still allowed if (types.object === false) return; - // If object has object-specific constraints, just ensure we have an object type - // The actual property validation will be handled in the refinement phase - if (objectSchema.properties || objectSchema.required || objectSchema.additionalProperties !== undefined) { - // Just create a passthrough object - refinement handler will add constraints - types.object = types.object || z.object({}).passthrough(); + const hasPropertyKeywords = + objectSchema.properties !== undefined || + (Array.isArray(objectSchema.required) && objectSchema.required.length > 0) || + objectSchema.additionalProperties !== undefined; + + if (!hasPropertyKeywords) { + return; } + + // Track whether patternProperties will supply their own allowance so we avoid locking the schema down too early. + const hasPatternProperties = + objectSchema.patternProperties !== undefined && + typeof objectSchema.patternProperties === "object" && + Object.keys(objectSchema.patternProperties).length > 0; + + const requiredSet = Array.isArray(objectSchema.required) + ? new Set(objectSchema.required) + : undefined; + + const shape: Record = {}; + + if (objectSchema.properties) { + for (const [key, propSchema] of Object.entries(objectSchema.properties)) { + if (propSchema === undefined) continue; + + const propertyZod = convertJsonSchemaToZod(propSchema); + const isRequired = requiredSet ? requiredSet.has(key) : false; + + if (requiredSet) { + requiredSet.delete(key); + } + + shape[key] = isRequired ? propertyZod : propertyZod.optional(); + } + } + + if (requiredSet && requiredSet.size > 0) { + for (const key of requiredSet) { + shape[key] = z.any(); + } + } + + let objectZod = z.object(shape); + + if (objectSchema.additionalProperties === false) { + objectZod = hasPatternProperties ? objectZod.passthrough() : objectZod.strict(); + } else if ( + objectSchema.additionalProperties !== undefined && + objectSchema.additionalProperties !== true + ) { + if (typeof objectSchema.additionalProperties === "object") { + const additionalSchema = convertJsonSchemaToZod(objectSchema.additionalProperties); + objectZod = hasPatternProperties ? objectZod.passthrough() : objectZod.catchall(additionalSchema); + } else { + objectZod = objectZod.passthrough(); + } + } else { + objectZod = objectZod.passthrough(); + } + + // Preprocess ensures downstream refinements receive the sanitized clone while preserving the high-level object shape. + const objectWithPreprocess = z.preprocess(sanitizeObjectInput, objectZod); + + types.object = objectWithPreprocess; } } @@ -64,4 +141,4 @@ export class MinPropertiesHandler implements PrimitiveHandler { ); } } -} \ No newline at end of file +} diff --git a/src/handlers/refinement/objectProperties.ts b/src/handlers/refinement/objectProperties.ts index c61ca58..2d1342f 100644 --- a/src/handlers/refinement/objectProperties.ts +++ b/src/handlers/refinement/objectProperties.ts @@ -2,6 +2,7 @@ import { z } from "zod/v4"; import type { JSONSchema } from "zod/v4/core"; import { RefinementHandler } from "../../core/types"; import { convertJsonSchemaToZod } from "../../core/converter"; +import { unwrapPreprocess } from "../../core/utils"; export class ObjectPropertiesHandler implements RefinementHandler { apply(zodSchema: z.ZodTypeAny, schema: JSONSchema.BaseSchema): z.ZodTypeAny { @@ -12,43 +13,55 @@ export class ObjectPropertiesHandler implements RefinementHandler { return zodSchema; } - // Check if the schema is a single object type (not a union) - if (zodSchema instanceof z.ZodObject || zodSchema instanceof z.ZodRecord) { - // Build proper shape with converted property schemas - const shape: Record = {}; - - if (objectSchema.properties) { - for (const [key, propSchema] of Object.entries(objectSchema.properties)) { - if (propSchema !== undefined) { - shape[key] = convertJsonSchemaToZod(propSchema); - } - } - } - - // Handle required properties - if (objectSchema.required && Array.isArray(objectSchema.required)) { - const required = new Set(objectSchema.required); - for (const key of Object.keys(shape)) { - if (!required.has(key)) { - shape[key] = shape[key].optional(); - } - } - } else { - // In JSON Schema, properties are optional by default - for (const key of Object.keys(shape)) { - shape[key] = shape[key].optional(); - } - } - - // Recreate the object with proper shape - if (objectSchema.additionalProperties === false) { - return z.object(shape); - } else { - return z.object(shape).passthrough(); - } + const propertyEntries = objectSchema.properties + ? Object.entries(objectSchema.properties).filter(([, propSchema]) => propSchema !== undefined) + : []; + // Cache converted property schemas so we only pay the conversion cost once per property. + const propertySchemas = new Map(); + for (const [propName, propSchema] of propertyEntries) { + propertySchemas.set(propName, convertJsonSchemaToZod(propSchema)); } - - // For unions or other complex types, use refinement + + // Precompile patternProperties so each additional key can be checked cheaply. + const patternEntries = + objectSchema.patternProperties && typeof objectSchema.patternProperties === "object" + ? Object.entries(objectSchema.patternProperties) + .filter(([, patternSchema]) => patternSchema !== undefined) + .map(([pattern, patternSchema]) => { + try { + return { + regex: new RegExp(pattern), + schema: convertJsonSchemaToZod(patternSchema), + }; + } catch { + return undefined; + } + }) + .filter((entry): entry is { regex: RegExp; schema: z.ZodTypeAny } => entry !== undefined) + : []; + + const additionalSchema = + objectSchema.additionalProperties && typeof objectSchema.additionalProperties === "object" + ? convertJsonSchemaToZod(objectSchema.additionalProperties) + : undefined; + + const allowsAdditional = + objectSchema.additionalProperties === undefined || objectSchema.additionalProperties === true; + + const unwrappedSchema = unwrapPreprocess(zodSchema); // Removes preprocessing wrappers so object detection works for sanitized schemas. + const isDirectObject = unwrappedSchema instanceof z.ZodObject || unwrappedSchema instanceof z.ZodRecord; + const hasPatternConstraints = patternEntries.length > 0; + const needsAdditionalFalseRefinement = + objectSchema.additionalProperties === false && hasPatternConstraints; + const needsAdditionalSchemaRefinement = additionalSchema !== undefined && !isDirectObject; + + const requiresRefinementForObject = + hasPatternConstraints || needsAdditionalSchemaRefinement || needsAdditionalFalseRefinement; + + if (isDirectObject && !requiresRefinementForObject) { + return zodSchema; + } + return zodSchema.refine( (value: any) => { // Only apply object constraints to objects @@ -57,21 +70,15 @@ export class ObjectPropertiesHandler implements RefinementHandler { } // Apply properties constraint - if (objectSchema.properties) { - for (const [propName, propSchema] of Object.entries(objectSchema.properties)) { - if (propSchema !== undefined) { - // Use a more robust way to check if property exists - // This handles JavaScript special property names correctly - const propExists = Object.getOwnPropertyDescriptor(value, propName) !== undefined; - - if (propExists) { - const zodPropSchema = convertJsonSchemaToZod(propSchema); - const propResult = zodPropSchema.safeParse(value[propName]); - if (!propResult.success) { - return false; - } - } - } + for (const [propName] of propertyEntries) { + if (!Object.prototype.hasOwnProperty.call(value, propName)) { + continue; + } + + const propValue = (value as Record)[propName]; + const zodPropSchema = propertySchemas.get(propName)!; + if (!zodPropSchema.safeParse(propValue).success) { + return false; } } @@ -79,20 +86,42 @@ export class ObjectPropertiesHandler implements RefinementHandler { if (objectSchema.required && Array.isArray(objectSchema.required)) { for (const requiredProp of objectSchema.required) { // Use robust property detection for required props too - const propExists = Object.getOwnPropertyDescriptor(value, requiredProp) !== undefined; - if (!propExists) { + if (!Object.prototype.hasOwnProperty.call(value, requiredProp)) { return false; } } } // Apply additionalProperties constraint - if (objectSchema.additionalProperties === false && objectSchema.properties) { - const allowedProps = new Set(Object.keys(objectSchema.properties)); - for (const prop in value) { - if (!allowedProps.has(prop)) { + const knownPropertyNames = new Set(propertyEntries.map(([key]) => key)); + for (const [key, propValue] of Object.entries(value as Record)) { + if (knownPropertyNames.has(key)) { + continue; + } + + const matchingPatterns = patternEntries.filter((entry) => entry.regex.test(key)); + if (matchingPatterns.length > 0) { + for (const entry of matchingPatterns) { + if (!entry.schema.safeParse(propValue).success) { + return false; + } + } + continue; + } + + if (objectSchema.additionalProperties === false) { + return false; + } + + if (additionalSchema) { + if (!additionalSchema.safeParse(propValue).success) { return false; } + continue; + } + + if (!allowsAdditional) { + return false; } } @@ -101,4 +130,4 @@ export class ObjectPropertiesHandler implements RefinementHandler { { message: "Object constraints validation failed" } ); } -} \ No newline at end of file +} diff --git a/src/index.ts b/src/index.ts index c1448b1..99062ed 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,7 @@ import { z } from "zod/v4"; import type { JSONSchema } from "zod/v4/core"; import { convertJsonSchemaToZod } from "./core/converter"; +import { unwrapPreprocess } from "./core/utils"; // Re-export the main converter function export { convertJsonSchemaToZod }; @@ -34,6 +35,28 @@ type InferZodRawShape> = { [K in keyof T]: InferZodTypeFromJsonSchema; }; +// Raw shape consumers expect "real" object types, so peel off preprocess/pipe layers that were introduced during conversion. +function unwrapPreprocess(schema: z.ZodTypeAny): z.ZodTypeAny { + let current = schema; + + while (true) { + const def = (current as any)?._def; + if (def?.effect?.type === "preprocess" && def?.schema) { + current = def.schema; + continue; + } + + if (def?.type === "pipe" && def?.out) { + current = def.out; + continue; + } + + break; + } + + return current; +} + /** * Converts a JSON Schema object to a Zod raw shape with proper typing * @param schema The JSON Schema object to convert @@ -60,20 +83,21 @@ export function jsonSchemaObjectToZodRawShape(schema: JSONSchema.Schema): Record for (const [key, value] of Object.entries(schema.properties ?? {})) { if (value === undefined) continue; - let zodType = convertJsonSchemaToZod(value); + const zodType = convertJsonSchemaToZod(value); + let shapeType = unwrapPreprocess(zodType); // If there's a required array and the field is not in it, make it optional // If there's no required array, all fields are optional by default in JSON Schema if (requiredArray.length > 0) { if (!requiredFields.has(key)) { - zodType = zodType.optional(); + shapeType = shapeType.optional(); } } else { // No required array means all fields are optional - zodType = zodType.optional(); + shapeType = shapeType.optional(); } - raw[key] = zodType; + raw[key] = shapeType; } return raw; }