Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions src/core/converter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [
Expand All @@ -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(),
Expand Down
3 changes: 2 additions & 1 deletion src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ export interface TypeSchemas {
null?: z.ZodNull | false;
array?: z.ZodArray<any> | false;
tuple?: z.ZodTuple | false;
object?: z.ZodObject<any> | 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 {
Expand Down
25 changes: 25 additions & 0 deletions src/core/utils.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
36 changes: 30 additions & 6 deletions src/core/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -49,4 +49,28 @@ export function createUniqueItemsValidator() {
*/
export function isValidWithSchema(schema: z.ZodTypeAny, value: any): boolean {
return schema.safeParse(value).success;
}
}

/**
* 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;
}
119 changes: 119 additions & 0 deletions src/discriminated-union.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
89 changes: 83 additions & 6 deletions src/handlers/primitive/object.ts
Original file line number Diff line number Diff line change
@@ -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<PropertyKey, unknown>;
const target: Record<PropertyKey, unknown> = 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 {
Expand All @@ -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<string>(objectSchema.required)
: undefined;

const shape: Record<string, z.ZodTypeAny> = {};

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;
}
}

Expand Down Expand Up @@ -64,4 +141,4 @@ export class MinPropertiesHandler implements PrimitiveHandler {
);
}
}
}
}
Loading
Loading