import { expect, test } from "vitest"; import { fromJSONSchema } from "../from-json-schema.js"; import * as z from "../index.js"; test("basic string schema", () => { const schema = fromJSONSchema({ type: "string" }); expect(schema.parse("hello")).toBe("hello"); expect(() => schema.parse(123)).toThrow(); }); test("string with constraints", () => { const schema = fromJSONSchema({ type: "string", minLength: 3, maxLength: 10, pattern: "^[a-z]+$", }); expect(schema.parse("hello")).toBe("hello"); expect(schema.parse("helloworld")).toBe("helloworld"); // exactly 10 chars - valid expect(() => schema.parse("hi")).toThrow(); // too short expect(() => schema.parse("helloworld1")).toThrow(); // too long (11 chars) expect(() => schema.parse("Hello")).toThrow(); // pattern mismatch }); test("pattern is not implicitly anchored", () => { // JSON Schema patterns match anywhere in the string, not just the full string const schema = fromJSONSchema({ type: "string", pattern: "foo", }); expect(schema.parse("foo")).toBe("foo"); expect(schema.parse("foobar")).toBe("foobar"); // matches at start expect(schema.parse("barfoo")).toBe("barfoo"); // matches at end expect(schema.parse("barfoobar")).toBe("barfoobar"); // matches in middle expect(() => schema.parse("bar")).toThrow(); // no match }); test("number schema", () => { const schema = fromJSONSchema({ type: "number" }); expect(schema.parse(42)).toBe(42); expect(() => schema.parse("42")).toThrow(); }); test("number with constraints", () => { const schema = fromJSONSchema({ type: "number", minimum: 0, maximum: 100, multipleOf: 5, }); expect(schema.parse(50)).toBe(50); expect(() => schema.parse(-1)).toThrow(); expect(() => schema.parse(101)).toThrow(); expect(() => schema.parse(47)).toThrow(); // not multiple of 5 }); test("draft-04 boolean exclusive bounds do not emit a redundant inclusive bound", () => { const schema = fromJSONSchema({ type: "number", minimum: 5, maximum: 10, exclusiveMinimum: true, exclusiveMaximum: true, }); // exclusive on both ends: boundaries rejected, interior accepted expect(() => schema.parse(5)).toThrow(); expect(() => schema.parse(10)).toThrow(); expect(schema.parse(7)).toBe(7); // the dominated inclusive bound would otherwise report a second, weaker issue alongside the exclusive one expect(schema.safeParse(4).error!.issues).toHaveLength(1); expect(schema.safeParse(11).error!.issues).toHaveLength(1); // the inclusive .min()/.max() must be skipped so only the exclusive checks remain const checks = (schema as any)._zod.def.checks.map((c: any) => [ c._zod.def.check, c._zod.def.value, c._zod.def.inclusive, ]); expect(checks).toEqual([ ["greater_than", 5, false], ["less_than", 10, false], ]); }); test("integer schema", () => { const schema = fromJSONSchema({ type: "integer" }); expect(schema.parse(42)).toBe(42); expect(() => schema.parse(42.5)).toThrow(); }); test("boolean schema", () => { const schema = fromJSONSchema({ type: "boolean" }); expect(schema.parse(true)).toBe(true); expect(schema.parse(false)).toBe(false); expect(() => schema.parse("true")).toThrow(); }); test("null schema", () => { const schema = fromJSONSchema({ type: "null" }); expect(schema.parse(null)).toBe(null); expect(() => schema.parse(undefined)).toThrow(); }); test("object schema", () => { const schema = fromJSONSchema({ type: "object", properties: { name: { type: "string" }, age: { type: "number" }, }, required: ["name"], }); expect(schema.parse({ name: "John", age: 30 })).toEqual({ name: "John", age: 30 }); expect(schema.parse({ name: "John" })).toEqual({ name: "John" }); expect(() => schema.parse({ age: 30 })).toThrow(); // missing required }); test("object with additionalProperties false", () => { const schema = fromJSONSchema({ type: "object", properties: { name: { type: "string" }, }, additionalProperties: false, }); expect(schema.parse({ name: "John" })).toEqual({ name: "John" }); expect(() => schema.parse({ name: "John", extra: "field" })).toThrow(); }); test("array schema", () => { const schema = fromJSONSchema({ type: "array", items: { type: "string" }, }); expect(schema.parse(["a", "b", "c"])).toEqual(["a", "b", "c"]); expect(() => schema.parse([1, 2, 3])).toThrow(); }); test("array with constraints", () => { const schema = fromJSONSchema({ type: "array", items: { type: "number" }, minItems: 2, maxItems: 4, }); expect(schema.parse([1, 2])).toEqual([1, 2]); expect(schema.parse([1, 2, 3, 4])).toEqual([1, 2, 3, 4]); expect(() => schema.parse([1])).toThrow(); expect(() => schema.parse([1, 2, 3, 4, 5])).toThrow(); }); test("tuple with prefixItems (draft-2020-12)", () => { const schema = fromJSONSchema({ $schema: "https://json-schema.org/draft/2020-12/schema", type: "array", prefixItems: [{ type: "string" }, { type: "number" }], }); expect(schema.parse([])).toEqual([]); expect(schema.parse(["hello"])).toEqual(["hello"]); expect(schema.parse(["hello", 42])).toEqual(["hello", 42]); // no `items`, so the tail is unconstrained expect(schema.parse(["hello", 42, "extra"])).toEqual(["hello", 42, "extra"]); expect(() => schema.parse([1])).toThrow(); expect(() => schema.parse(["hello", "world"])).toThrow(); }); test("tuple with prefixItems and minItems (draft-2020-12)", () => { const partialItems = fromJSONSchema({ $schema: "https://json-schema.org/draft/2020-12/schema", type: "array", prefixItems: [{ type: "string" }, { type: "number" }], minItems: 1, }); expect(() => partialItems.parse([])).toThrow(); expect(partialItems.parse(["hello"])).toEqual(["hello"]); const allRequired = fromJSONSchema({ $schema: "https://json-schema.org/draft/2020-12/schema", type: "array", prefixItems: [{ type: "string" }, { type: "number" }], minItems: 2, }); expect(() => allRequired.parse(["hello"])).toThrow(); expect(allRequired.parse(["hello", 42])).toEqual(["hello", 42]); }); test("tuple with prefixItems allows extra items by default (draft-2020-12)", () => { const schema = fromJSONSchema({ $schema: "https://json-schema.org/draft/2020-12/schema", type: "array", prefixItems: [{ type: "string" }], }); expect(schema.parse(["hello", 42, true])).toEqual(["hello", 42, true]); }); test("tuple with prefixItems respects items schema (draft-2020-12)", () => { const schema = fromJSONSchema({ $schema: "https://json-schema.org/draft/2020-12/schema", type: "array", prefixItems: [{ type: "string" }], items: { type: "number" }, }); expect(schema.parse(["hello", 1, 2])).toEqual(["hello", 1, 2]); expect(() => schema.parse(["hello", 1, "extra"])).toThrow(); }); test("tuple with prefixItems and items true allows extra items (draft-2020-12)", () => { const schema = fromJSONSchema({ $schema: "https://json-schema.org/draft/2020-12/schema", type: "array", prefixItems: [{ type: "string" }, { type: "number" }], items: true, }); expect(schema.parse(["hello", 42, "extra", true])).toEqual(["hello", 42, "extra", true]); }); test("tuple with prefixItems and items false rejects extra items (draft-2020-12)", () => { const schema = fromJSONSchema({ $schema: "https://json-schema.org/draft/2020-12/schema", type: "array", prefixItems: [{ type: "string" }], items: false, }); expect(schema.parse(["hello"])).toEqual(["hello"]); expect(() => schema.parse(["hello", 42])).toThrow(); }); test("tuple with items array (draft-7)", () => { const schema = fromJSONSchema({ $schema: "http://json-schema.org/draft-07/schema#", type: "array", items: [{ type: "string" }, { type: "number" }], additionalItems: false, }); expect(schema.parse([])).toEqual([]); expect(schema.parse(["hello"])).toEqual(["hello"]); expect(schema.parse(["hello", 42])).toEqual(["hello", 42]); expect(() => schema.parse([1])).toThrow(); expect(() => schema.parse(["hello", "world"])).toThrow(); expect(() => schema.parse(["hello", 42, "extra"])).toThrow(); }); test("tuple with items array and minItems (draft-7)", () => { const partialItems = fromJSONSchema({ $schema: "http://json-schema.org/draft-07/schema#", type: "array", items: [{ type: "string" }, { type: "number" }], additionalItems: false, minItems: 1, }); expect(() => partialItems.parse([])).toThrow(); expect(partialItems.parse(["hello"])).toEqual(["hello"]); const allRequired = fromJSONSchema({ $schema: "http://json-schema.org/draft-07/schema#", type: "array", items: [{ type: "string" }, { type: "number" }], additionalItems: false, minItems: 2, }); expect(() => allRequired.parse(["hello"])).toThrow(); expect(allRequired.parse(["hello", 42])).toEqual(["hello", 42]); }); test("tuple with items array allows extra items by default (draft-7)", () => { const schema = fromJSONSchema({ $schema: "http://json-schema.org/draft-07/schema#", type: "array", items: [{ type: "string" }], }); expect(schema.parse(["hello", 42, true])).toEqual(["hello", 42, true]); }); test("tuple with items array and additionalItems true allows extra items (draft-7)", () => { const schema = fromJSONSchema({ $schema: "http://json-schema.org/draft-07/schema#", type: "array", items: [{ type: "string" }, { type: "number" }], additionalItems: true, }); expect(schema.parse(["hello", 42, "extra", true])).toEqual(["hello", 42, "extra", true]); }); test("tuple with items array respects additionalItems schema (draft-7)", () => { const schema = fromJSONSchema({ $schema: "http://json-schema.org/draft-07/schema#", type: "array", items: [{ type: "string" }], additionalItems: { type: "number" }, }); expect(schema.parse(["hello", 1, 2])).toEqual(["hello", 1, 2]); expect(() => schema.parse(["hello", 1, "extra"])).toThrow(); }); test("enum schema", () => { const schema = fromJSONSchema({ enum: ["red", "green", "blue"], }); expect(schema.parse("red")).toBe("red"); expect(() => schema.parse("yellow")).toThrow(); }); test("const schema", () => { const schema = fromJSONSchema({ const: "hello", }); expect(schema.parse("hello")).toBe("hello"); expect(() => schema.parse("world")).toThrow(); }); test("anyOf schema", () => { const schema = fromJSONSchema({ anyOf: [{ type: "string" }, { type: "number" }], }); expect(schema.parse("hello")).toBe("hello"); expect(schema.parse(42)).toBe(42); expect(() => schema.parse(true)).toThrow(); }); test("allOf schema", () => { const schema = fromJSONSchema({ allOf: [ { type: "object", properties: { name: { type: "string" } }, required: ["name"] }, { type: "object", properties: { age: { type: "number" } }, required: ["age"] }, ], }); const result = schema.parse({ name: "John", age: 30 }) as { name: string; age: number }; expect(result.name).toBe("John"); expect(result.age).toBe(30); }); test("allOf with empty array", () => { // Empty allOf without explicit type returns any const schema1 = fromJSONSchema({ allOf: [], }); expect(schema1.parse("hello")).toBe("hello"); expect(schema1.parse(123)).toBe(123); expect(schema1.parse({})).toEqual({}); // Empty allOf with explicit type returns base schema const schema2 = fromJSONSchema({ type: "string", allOf: [], }); expect(schema2.parse("hello")).toBe("hello"); expect(() => schema2.parse(123)).toThrow(); }); 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("type with anyOf creates intersection", () => { // type: string AND (type:string,minLength:5 OR type:string,pattern:^a) const schema = fromJSONSchema({ type: "string", anyOf: [ { type: "string", minLength: 5 }, { type: "string", pattern: "^a" }, ], }); // Should pass: string AND (minLength:5 OR pattern:^a) - matches minLength expect(schema.parse("hello")).toBe("hello"); // Should pass: string AND (minLength:5 OR pattern:^a) - matches pattern expect(schema.parse("abc")).toBe("abc"); // Should fail: string but neither minLength nor pattern match expect(() => schema.parse("hi")).toThrow(); // Should fail: not a string expect(() => schema.parse(123)).toThrow(); }); test("type with oneOf creates intersection", () => { // type: string AND (exactly one of: type:string,minLength:5 OR type:string,pattern:^a) const schema = fromJSONSchema({ type: "string", oneOf: [ { type: "string", minLength: 5 }, { type: "string", pattern: "^a" }, ], }); // Should pass: string AND minLength:5 (exactly one match - "hello" length 5 >= 5, doesn't start with 'a') expect(schema.parse("hello")).toBe("hello"); // Should pass: string AND pattern:^a (exactly one match - "abc" starts with 'a', length 3 < 5) expect(schema.parse("abc")).toBe("abc"); // Should fail: string but neither match expect(() => schema.parse("hi")).toThrow(); // Should fail: not a string expect(() => schema.parse(123)).toThrow(); // Should fail: matches both (length >= 5 AND starts with 'a') - exclusive union fails expect(() => schema.parse("apple")).toThrow(); }); test("unevaluatedItems throws error", () => { expect(() => { fromJSONSchema({ type: "array", unevaluatedItems: false, }); }).toThrow("unevaluatedItems is not supported"); }); test("unevaluatedProperties throws error", () => { expect(() => { fromJSONSchema({ type: "object", unevaluatedProperties: false, }); }).toThrow("unevaluatedProperties is not supported"); }); test("if/then/else throws error", () => { expect(() => { fromJSONSchema({ if: { type: "string" }, then: { type: "number" }, }); }).toThrow("Conditional schemas"); }); test("external $ref throws error", () => { expect(() => { fromJSONSchema({ $ref: "https://example.com/schema#/definitions/User", }); }).toThrow("External $ref is not supported"); }); test("local $ref resolution", () => { const schema = fromJSONSchema({ $defs: { User: { type: "object", properties: { name: { type: "string" }, }, required: ["name"], }, }, $ref: "#/$defs/User", }); expect(schema.parse({ name: "John" })).toEqual({ name: "John" }); expect(() => schema.parse({})).toThrow(); }); test("local $ref resolution unescapes JSON Pointer tokens", () => { // `~1` must be decoded before `~0`, so the id "a~1b" survives as itself and not as "a/b". for (const id of ["Shared/User", "My~Model", "a~b/c", "a~1b"]) { const inner = z.object({ name: z.string() }).meta({ id }); const schema = fromJSONSchema(z.toJSONSchema(z.object({ inner }))); expect(schema.parse({ inner: { name: "John" } })).toEqual({ inner: { name: "John" } }); } }); test("circular $ref with lazy", () => { const schema = fromJSONSchema({ $defs: { Node: { type: "object", properties: { value: { type: "string" }, children: { type: "array", items: { $ref: "#/$defs/Node" }, }, }, }, }, $ref: "#/$defs/Node", }); type Node = { value: string; children: Node[] }; const result = schema.parse({ value: "root", children: [{ value: "child", children: [] }], }) as Node; expect(result.value).toBe("root"); expect(result.children[0]?.value).toBe("child"); }); test("patternProperties", () => { const schema = fromJSONSchema({ type: "object", patternProperties: { "^S_": { type: "string" }, }, }); const result = schema.parse({ S_name: "John", S_age: "30" }) as Record; expect(result.S_name).toBe("John"); expect(result.S_age).toBe("30"); }); test("propertyNames with enum does not require keys", () => { const schema = fromJSONSchema({ type: "object", propertyNames: { enum: ["a", "b"] }, additionalProperties: { type: "string" }, }); expect(schema.parse({ a: "x" })).toEqual({ a: "x" }); expect(schema.parse({ b: "y" })).toEqual({ b: "y" }); expect(schema.parse({})).toEqual({}); expect(() => schema.parse({ c: "z" })).toThrow(); }); test("propertyNames with enum alongside properties", () => { const schema = fromJSONSchema({ type: "object", properties: { a: { type: "string" } }, propertyNames: { enum: ["a", "b"] }, additionalProperties: { type: "string" }, }); expect(schema.parse({ a: "x" })).toEqual({ a: "x" }); expect(schema.parse({})).toEqual({}); expect(() => schema.parse({ c: "z" })).toThrow(); expect(() => schema.parse({ a: "x", c: "z" })).toThrow(); }); test("propertyNames with enum still honors required", () => { const schema = fromJSONSchema({ type: "object", properties: { a: { type: "string" }, b: { type: "string" } }, required: ["a"], propertyNames: { enum: ["a", "b"] }, additionalProperties: { type: "string" }, }); expect(schema.parse({ a: "x" })).toEqual({ a: "x" }); expect(schema.parse({ a: "x", b: "y" })).toEqual({ a: "x", b: "y" }); expect(() => schema.parse({})).toThrow(); expect(() => schema.parse({ b: "y" })).toThrow(); }); test("propertyNames does not apply additionalProperties to declared properties", () => { const schema = fromJSONSchema({ type: "object", properties: { a: { type: "number" } }, propertyNames: { enum: ["a", "b"] }, additionalProperties: { type: "string" }, }); expect(schema.parse({ a: 1 })).toEqual({ a: 1 }); expect(schema.parse({ a: 1, b: "x" })).toEqual({ a: 1, b: "x" }); expect(() => schema.parse({ b: 1 })).toThrow(); }); test("propertyNames alongside additionalProperties: false", () => { const schema = fromJSONSchema({ type: "object", properties: { a: { type: "string" } }, propertyNames: { enum: ["a", "b"] }, additionalProperties: false, }); expect(schema.parse({ a: "x" })).toEqual({ a: "x" }); expect(() => schema.parse({ a: "x", b: "y" })).toThrow(); expect(() => schema.parse({ c: "z" })).toThrow(); }); test("propertyNames alongside patternProperties", () => { const schema = fromJSONSchema({ type: "object", patternProperties: { "^S_": { type: "string" } }, propertyNames: { type: "string", pattern: "^S_" }, }); expect(schema.parse({ S_a: "x" })).toEqual({ S_a: "x" }); expect(() => schema.parse({ S_a: 1 })).toThrow(); expect(() => schema.parse({ other: "x" })).toThrow(); }); test("propertyNames rejects keys the object parse would drop or synthesize", () => { const schema = fromJSONSchema({ type: "object", propertyNames: { enum: ["a"] } }); // An object parse strips `__proto__`, so the guard has to see the raw input. expect(() => schema.parse(JSON.parse('{"__proto__":1}'))).toThrow(); // A property `default` adds a key that was never in the instance. const withDefault = fromJSONSchema({ type: "object", properties: { AB: { type: "string", default: "x" } }, propertyNames: { type: "string", pattern: "^[a-z]+$" }, }); expect(withDefault.parse({})).toEqual({ AB: "x" }); }); test("propertyNames subschema without a type still constrains keys", () => { const schema = fromJSONSchema({ type: "object", propertyNames: { pattern: "^[A-Za-z_][A-Za-z0-9_]*$" }, }); expect(schema.parse({ valid_key: "x" })).toEqual({ valid_key: "x" }); expect(() => schema.parse({ "001 invalid": "x" })).toThrow(); }); test("boolean propertyNames", () => { expect(() => fromJSONSchema({ type: "object", propertyNames: false }).parse({ a: 1 })).toThrow(); expect(fromJSONSchema({ type: "object", propertyNames: false }).parse({})).toEqual({}); expect(fromJSONSchema({ type: "object", propertyNames: true }).parse({ anything: 1 })).toEqual({ anything: 1 }); }); test("propertyNames metadata is not attached where it is inert", () => { // Not an object: nothing enforces it, so it must not be advertised either. expect(z.toJSONSchema(fromJSONSchema({ type: "string", propertyNames: { enum: ["a"] } }))).not.toHaveProperty( "propertyNames" ); // A $ref target is shared by every reference to it; metadata must not leak onto it. const shared = z.toJSONSchema( fromJSONSchema({ $defs: { N: { type: "object", properties: { v: { type: "string" } } } }, type: "object", properties: { a: { $ref: "#/$defs/N", propertyNames: { enum: ["v"] } }, b: { $ref: "#/$defs/N" } }, }) ) as any; expect(shared.properties.b).not.toHaveProperty("propertyNames"); }); test("propertyNames survives a toJSONSchema round trip", () => { const roundTripped = z.toJSONSchema( fromJSONSchema({ type: "object", propertyNames: { enum: ["a", "b"] }, additionalProperties: { type: "string" }, }) ) as Record; expect(roundTripped.propertyNames).toEqual({ enum: ["a", "b"] }); expect(roundTripped.required).toBeUndefined(); // The key guard is a pipe; neither side may swallow the object it wraps. for (const io of ["input", "output"] as const) { const out = z.toJSONSchema( fromJSONSchema({ type: "object", properties: { a: { type: "string" } }, propertyNames: { enum: ["a", "b"] }, additionalProperties: { type: "string" }, }), { io } ) as Record; expect(out.type).toBe("object"); expect(out.properties).toEqual({ a: { type: "string" } }); expect(out.propertyNames).toEqual({ enum: ["a", "b"] }); } }); test("patternProperties with regular properties", () => { // Note: When patternProperties is combined with properties, the intersection validates all keys against the pattern. This test uses a pattern that matches the regular property name as well. const schema = fromJSONSchema({ type: "object", properties: { S_name: { type: "string" }, }, patternProperties: { "^S_": { type: "string" }, }, required: ["S_name"], }); const result = schema.parse({ S_name: "John", S_extra: "value" }) as Record; expect(result.S_name).toBe("John"); expect(result.S_extra).toBe("value"); }); test("multiple patternProperties", () => { const schema = fromJSONSchema({ type: "object", patternProperties: { "^S_": { type: "string" }, "^N_": { type: "number" }, }, }); const result = schema.parse({ S_name: "John", N_count: 123 }) as Record; expect(result.S_name).toBe("John"); expect(result.N_count).toBe(123); // Keys not matching any pattern should pass through const result2 = schema.parse({ S_name: "John", N_count: 123, other: "value" }) as Record; expect(result2.other).toBe("value"); }); test("multiple overlapping patternProperties", () => { // If a key matches multiple patterns, value must satisfy all schemas const schema = fromJSONSchema({ type: "object", patternProperties: { "^S_": { type: "string" }, "^S_N": { type: "string", minLength: 3 }, }, }); // S_name matches ^S_ but not ^S_N expect(schema.parse({ S_name: "John" })).toEqual({ S_name: "John" }); // S_N matches both patterns - must satisfy both (string with minLength 3) expect(schema.parse({ S_N: "abc" })).toEqual({ S_N: "abc" }); expect(() => schema.parse({ S_N: "ab" })).toThrow(); // too short for ^S_N pattern }); test("patternProperties with additionalProperties false", () => { const schema = fromJSONSchema({ type: "object", patternProperties: { "^S_": { type: "string" }, }, additionalProperties: false, }); expect(schema.parse({ S_name: "ok" })).toEqual({ S_name: "ok" }); expect(() => schema.parse({ X_A: false })).toThrow(); expect(() => schema.parse({ S_name: 1 })).toThrow(); }); test("patternProperties and properties with additionalProperties false", () => { const schema = fromJSONSchema({ type: "object", properties: { name: { type: "string" }, }, patternProperties: { "^S_": { type: "string" }, }, additionalProperties: false, required: ["name"], }); expect(schema.parse({ name: "ok", S_count: "1" })).toEqual({ name: "ok", S_count: "1" }); expect(() => schema.parse({ name: "ok", S_count: 1 })).toThrow(); expect(() => schema.parse({ name: "ok", other: "value" })).toThrow(); }); test("multiple patternProperties with additionalProperties false", () => { const schema = fromJSONSchema({ type: "object", patternProperties: { "^S_": { type: "string" }, "^N_": { type: "number" }, }, additionalProperties: false, }); expect(schema.parse({ S_a: "a", N_b: 1 })).toEqual({ S_a: "a", N_b: 1 }); expect(() => schema.parse({ S_a: "a", N_b: 1, other: "value" })).toThrow(); }); test("default value", () => { const schema = fromJSONSchema({ type: "string", default: "hello", }); // Default is applied during parsing if value is missing/undefined. This depends on Zod's default behavior expect(schema.parse("world")).toBe("world"); }); test("description metadata", () => { const schema = fromJSONSchema({ type: "string", description: "A string value", }); expect(schema.parse("hello")).toBe("hello"); }); test("version detection - draft-2020-12", () => { const schema = fromJSONSchema({ $schema: "https://json-schema.org/draft/2020-12/schema", type: "array", prefixItems: [{ type: "string" }], }); expect(schema.parse(["hello"])).toEqual(["hello"]); }); test("version detection - draft-7", () => { const schema = fromJSONSchema({ $schema: "http://json-schema.org/draft-07/schema#", type: "array", items: [{ type: "string" }], }); expect(schema.parse(["hello"])).toEqual(["hello"]); }); test("version detection - draft-4", () => { const schema = fromJSONSchema({ $schema: "http://json-schema.org/draft-04/schema#", type: "array", items: [{ type: "string" }], }); expect(schema.parse(["hello"])).toEqual(["hello"]); }); test("default version (draft-2020-12)", () => { const schema = fromJSONSchema({ type: "array", prefixItems: [{ type: "string" }], }); expect(schema.parse(["hello"])).toEqual(["hello"]); }); test("string format - email", () => { const schema = fromJSONSchema({ type: "string", format: "email", }); expect(schema.parse("test@example.com")).toBe("test@example.com"); }); test("string format - uuid", () => { const schema = fromJSONSchema({ type: "string", format: "uuid", }); const uuid = "550e8400-e29b-41d4-a716-446655440000"; expect(schema.parse(uuid)).toBe(uuid); }); test("string format - date-time", () => { const schema = fromJSONSchema({ type: "string", format: "date-time", }); expect(schema.safeParse("2026-07-29T14:30:00Z").success).toBe(true); expect(schema.safeParse("2026-07-29T16:30:00+02:00").success).toBe(true); expect(schema.safeParse("2026-07-29T14:30:00").success).toBe(false); expect(schema.safeParse("2026-07-29T16:30:00+0200").success).toBe(false); const zuluOnly = fromJSONSchema({ type: "string", format: "date-time", pattern: "Z$", }); expect(zuluOnly.safeParse("2026-07-29T14:30:00Z").success).toBe(true); expect(zuluOnly.safeParse("2026-07-29T16:30:00+02:00").success).toBe(false); expect(zuluOnly.safeParse("garbageZ").success).toBe(false); const roundTripped = fromJSONSchema(z.toJSONSchema(z.iso.datetime({ offset: true }))); expect(roundTripped.safeParse("2026-07-29T16:30:00+02:00").success).toBe(true); }); test("string format - time", () => { const schema = fromJSONSchema({ type: "string", format: "time", }); // full-time, so seconds and an offset are both required expect(schema.safeParse("14:30:00Z").success).toBe(true); expect(schema.safeParse("16:30:00+02:00").success).toBe(true); expect(schema.safeParse("16:30:00.123-05:30").success).toBe(true); expect(schema.safeParse("14:30:00").success).toBe(false); expect(schema.safeParse("14:30Z").success).toBe(false); expect(schema.safeParse("16:30:00+0200").success).toBe(false); expect(schema.safeParse("16:30:00+24:00").success).toBe(false); }); test("string format - hostname", () => { const schema = fromJSONSchema({ type: "string", format: "hostname", }); expect(schema.parse("example.com")).toBe("example.com"); expect(() => schema.parse("not a hostname!")).toThrow(); }); test("exclusiveMinimum and exclusiveMaximum", () => { const schema = fromJSONSchema({ type: "number", exclusiveMinimum: 0, exclusiveMaximum: 100, }); expect(schema.parse(50)).toBe(50); expect(() => schema.parse(0)).toThrow(); expect(() => schema.parse(100)).toThrow(); }); test("boolean schema (true/false)", () => { const trueSchema = fromJSONSchema(true); expect(trueSchema.parse("anything")).toBe("anything"); const falseSchema = fromJSONSchema(false); expect(() => falseSchema.parse("anything")).toThrow(); }); test("empty object schema", () => { const schema = fromJSONSchema({ type: "object", }); expect(schema.parse({})).toEqual({}); expect(schema.parse({ extra: "field" })).toEqual({ extra: "field" }); }); test("array without items", () => { const schema = fromJSONSchema({ type: "array", }); expect(schema.parse([1, "string", true])).toEqual([1, "string", true]); }); test("mixed enum types", () => { const schema = fromJSONSchema({ enum: ["string", 42, true, null], }); expect(schema.parse("string")).toBe("string"); expect(schema.parse(42)).toBe(42); expect(schema.parse(true)).toBe(true); expect(schema.parse(null)).toBe(null); }); test("nullable in OpenAPI 3.0", () => { // General nullable case (not just enum: [null]) const stringSchema = fromJSONSchema( { type: "string", nullable: true, }, { defaultTarget: "openapi-3.0" } ); expect(stringSchema.parse("hello")).toBe("hello"); expect(stringSchema.parse(null)).toBe(null); expect(() => stringSchema.parse(123)).toThrow(); const numberSchema = fromJSONSchema( { type: "number", nullable: true, }, { defaultTarget: "openapi-3.0" } ); expect(numberSchema.parse(42)).toBe(42); expect(numberSchema.parse(null)).toBe(null); expect(() => numberSchema.parse("string")).toThrow(); const objectSchema = fromJSONSchema( { type: "object", properties: { name: { type: "string" } }, nullable: true, }, { defaultTarget: "openapi-3.0" } ); expect(objectSchema.parse({ name: "John" })).toEqual({ name: "John" }); expect(objectSchema.parse(null)).toBe(null); }); // Metadata extraction tests test("unrecognized keys stored in globalRegistry by default", () => { const schema = fromJSONSchema({ type: "string", title: "My String", deprecated: true, examples: ["hello", "world"], "x-custom": "custom value", }); const meta = z.globalRegistry.get(schema); expect(meta).toBeDefined(); expect(meta?.title).toBe("My String"); expect(meta?.deprecated).toBe(true); expect(meta?.examples).toEqual(["hello", "world"]); expect((meta as any)?.["x-custom"]).toBe("custom value"); // Clean up z.globalRegistry.remove(schema); }); test("unrecognized keys stored in custom registry", () => { const customRegistry = z.registry<{ title?: string; deprecated?: boolean }>(); const schema = fromJSONSchema( { type: "number", title: "Age", deprecated: true, }, { registry: customRegistry } ); // Should be in custom registry const meta = customRegistry.get(schema); expect(meta).toBeDefined(); expect(meta?.title).toBe("Age"); expect(meta?.deprecated).toBe(true); // Should NOT be in globalRegistry expect(z.globalRegistry.get(schema)).toBeUndefined(); }); test("$id and id are captured as metadata", () => { const customRegistry = z.registry<{ $id?: string; id?: string }>(); const schema1 = fromJSONSchema( { $id: "https://example.com/schemas/user", type: "object", }, { registry: customRegistry } ); expect(customRegistry.get(schema1)?.$id).toBe("https://example.com/schemas/user"); const schema2 = fromJSONSchema( { id: "legacy-id", type: "string", }, { registry: customRegistry } ); expect(customRegistry.get(schema2)?.id).toBe("legacy-id"); }); test("x-* extension keys are captured as metadata", () => { const customRegistry = z.registry>(); const schema = fromJSONSchema( { type: "string", "x-openapi-example": "example value", "x-internal": true, "x-tags": ["api", "public"], }, { registry: customRegistry } ); const meta = customRegistry.get(schema); expect(meta?.["x-openapi-example"]).toBe("example value"); expect(meta?.["x-internal"]).toBe(true); expect(meta?.["x-tags"]).toEqual(["api", "public"]); }); test("metadata on nested schemas", () => { const customRegistry = z.registry>(); const parentSchema = fromJSONSchema( { type: "object", title: "User", properties: { name: { type: "string", title: "Name", "x-field-order": 1, }, age: { type: "number", title: "Age", deprecated: true, }, }, }, { registry: customRegistry } ); // Verify parent schema has its metadata expect(customRegistry.get(parentSchema)?.title).toBe("User"); // We can't easily access nested schemas directly, but we can verify the registry is being used correctly by checking a separate schema const simpleSchema = fromJSONSchema( { type: "string", title: "Simple", }, { registry: customRegistry } ); expect(customRegistry.get(simpleSchema)?.title).toBe("Simple"); }); test("no metadata added when no unrecognized keys", () => { const customRegistry = z.registry>(); const schema = fromJSONSchema( { type: "string", minLength: 1, maxLength: 100, description: "A regular string", }, { registry: customRegistry } ); // description is handled via .describe(), so it shouldn't be in metadata. All other keys are recognized, so no metadata should be added expect(customRegistry.get(schema)).toBeUndefined(); }); test("writeOnly and examples are captured as metadata", () => { const customRegistry = z.registry<{ writeOnly?: boolean; examples?: unknown[] }>(); const schema = fromJSONSchema( { type: "string", writeOnly: true, examples: ["password123", "secret"], }, { registry: customRegistry } ); const meta = customRegistry.get(schema); expect(meta?.writeOnly).toBe(true); expect(meta?.examples).toEqual(["password123", "secret"]); }); test("$comment and $anchor are captured as metadata", () => { const customRegistry = z.registry<{ $comment?: string; $anchor?: string }>(); const schema = fromJSONSchema( { type: "string", $comment: "This is a developer note", $anchor: "my-anchor", }, { registry: customRegistry } ); const meta = customRegistry.get(schema); expect(meta?.$comment).toBe("This is a developer note"); expect(meta?.$anchor).toBe("my-anchor"); }); test("contentEncoding and contentMediaType are stored as metadata", () => { const customRegistry = z.registry<{ contentEncoding?: string; contentMediaType?: string }>(); const schema = fromJSONSchema( { type: "string", contentEncoding: "base64", contentMediaType: "image/png", }, { registry: customRegistry } ); // Should just be a string schema expect(schema.parse("aGVsbG8gd29ybGQ=")).toBe("aGVsbG8gd29ybGQ="); // Content keywords should be in metadata const meta = customRegistry.get(schema); expect(meta?.contentEncoding).toBe("base64"); expect(meta?.contentMediaType).toBe("image/png"); }); test("description on enum schema is applied", () => { const schema = fromJSONSchema({ enum: ["red", "green", "blue"], description: "A color value", }); expect(schema.description).toBe("A color value"); expect(schema.parse("red")).toBe("red"); }); test("description on const schema is applied", () => { const schema = fromJSONSchema({ const: "hello", description: "A greeting", }); expect(schema.description).toBe("A greeting"); expect(schema.parse("hello")).toBe("hello"); }); test("description on not: {} (never) schema is applied", () => { const schema = fromJSONSchema({ not: {}, description: "A never schema", }); expect(schema.description).toBe("A never schema"); expect(() => schema.parse("anything")).toThrow(); }); test("default on enum schema is applied", () => { const schema = fromJSONSchema({ enum: ["red", "green", "blue"], default: "red", }); expect(schema.parse(undefined)).toBe("red"); }); test("default on const schema is applied", () => { const schema = fromJSONSchema({ const: "hello", default: "hello", }); expect(schema.parse(undefined)).toBe("hello"); }); test("description and default on enum schema are both applied", () => { const schema = fromJSONSchema({ enum: ["red", "green", "blue"], description: "A color value", default: "red", }); expect(schema.description).toBe("A color value"); expect(schema.parse(undefined)).toBe("red"); expect(schema.parse("green")).toBe("green"); }); test("description on type-array schema is applied", () => { const schema = fromJSONSchema({ type: ["string", "number"], description: "A string or number", } as any); expect(schema.description).toBe("A string or number"); expect(schema.parse("hello")).toBe("hello"); expect(schema.parse(42)).toBe(42); }); test("default on type-array schema is applied", () => { const schema = fromJSONSchema({ type: ["string", "number"], default: "fallback", } as any); expect(schema.parse(undefined)).toBe("fallback"); expect(schema.parse("hello")).toBe("hello"); expect(schema.parse(42)).toBe(42); }); test("description on schema with anyOf is applied to the outer schema", () => { const schema = fromJSONSchema({ description: "Either a string or a number", anyOf: [{ type: "string" }, { type: "number" }], }); expect(schema.description).toBe("Either a string or a number"); expect(schema.parse("hello")).toBe("hello"); expect(schema.parse(42)).toBe(42); }); test("default on schema with anyOf is applied to the outer schema", () => { const schema = fromJSONSchema({ default: "fallback", anyOf: [{ type: "string" }, { type: "number" }], }); expect(schema.parse(undefined)).toBe("fallback"); expect(schema.parse(42)).toBe(42); }); test("description and unrecognized metadata coexist on the same schema", () => { const customRegistry = z.registry<{ "x-custom"?: string; description?: string }>(); const schema = fromJSONSchema( { type: "string", description: "A custom string", "x-custom": "value", }, { registry: customRegistry } ); expect(schema.description).toBe("A custom string"); expect(customRegistry.get(schema)?.["x-custom"]).toBe("value"); }); test("circular input throws a clear error", () => { const person: any = { type: "object", properties: { name: { type: "string" } }, required: ["name"], }; person.properties.bestFriend = person; expect(() => fromJSONSchema(person)).toThrow(/not valid JSON/); }); test("getter-based input that synthesizes a cycle throws", () => { const root: any = { type: "object", properties: { name: { type: "string" } } }; Object.defineProperty(root.properties, "self", { enumerable: true, get() { return root; }, }); expect(() => fromJSONSchema(root)).toThrow(/not valid JSON/); }); test("BigInt in input throws", () => { const input: any = { type: "integer", minimum: 1n }; expect(() => fromJSONSchema(input)).toThrow(/not valid JSON/); }); test("class-instance input is normalized to a plain object", () => { class StringSchema { type = "string" as const; minLength = 2; } const schema = fromJSONSchema(new StringSchema() as any); expect(schema.parse("hi")).toBe("hi"); expect(() => schema.parse("h")).toThrow(); }); test("getter-based properties are materialized", () => { const input: any = { type: "object", properties: {}, required: [] }; Object.defineProperty(input.properties, "name", { enumerable: true, get() { return { type: "string" }; }, }); const schema = fromJSONSchema(input); expect(schema.parse({ name: "Alice" })).toEqual({ name: "Alice" }); }); test("Date default is coerced to its JSON string form", () => { const date = new Date("2026-01-02T03:04:05.000Z"); const schema = fromJSONSchema({ type: "string", default: date as any }); expect(schema.parse(undefined)).toBe(date.toISOString()); }); // An object literal can't express an own "__proto__" key — `{ __proto__: x }` sets the literal's prototype instead. JSON.parse is both the realistic source of a JSON Schema and the only way to write these cases. test("required __proto__ property is represented in the shape but stripped during parsing", () => { const schema = fromJSONSchema( JSON.parse(`{ "type": "object", "properties": { "__proto__": { "type": "string", "const": "admin" }, "role": { "type": "string" } }, "required": ["__proto__", "role"] }`) ); expect(Object.prototype.hasOwnProperty.call((schema as z.ZodObject).shape, "__proto__")).toBe(true); expect(schema.parse({ role: "x" })).toEqual({ role: "x" }); expect(schema.parse(JSON.parse(`{ "role": "x", "__proto__": "wrong" }`))).toEqual({ role: "x" }); expect(schema.parse(JSON.parse(`{ "role": "x", "__proto__": "admin" }`))).toEqual({ role: "x" }); }); test("__proto__ annotation key reaches the registry", () => { const registry = z.registry>(); const schema = fromJSONSchema(JSON.parse(`{ "type": "string", "__proto__": { "custom": 1 } }`), { registry }); const meta = registry.get(schema)!; expect(Object.prototype.hasOwnProperty.call(meta, "__proto__")).toBe(true); expect(meta.__proto__).toEqual({ custom: 1 }); }); test("minProperties and maxProperties count the raw input", () => { const schema = fromJSONSchema({ type: "object", properties: { a: { type: "string" } }, minProperties: 1, maxProperties: 2, }); expect(schema.safeParse({ a: "x" }).success).toBe(true); expect(schema.safeParse({}).error!.issues[0]).toMatchObject({ code: "too_small", origin: "object", minimum: 1 }); expect(schema.safeParse({ a: "x", b: 1, c: 2 }).error!.issues[0]).toMatchObject({ code: "too_big", origin: "object", maximum: 2, }); // raw input: a defaulted property does not satisfy the minimum, and a dropped __proto__ still counts const defaulted = fromJSONSchema({ type: "object", properties: { a: { type: "string", default: "x" } }, minProperties: 1, }); expect(defaulted.safeParse({}).success).toBe(false); const capped = fromJSONSchema({ type: "object", maxProperties: 1 }); expect(capped.safeParse(JSON.parse('{"__proto__":1,"a":1}')).success).toBe(false); expect(z.toJSONSchema(schema)).toMatchObject({ minProperties: 1, maxProperties: 2 }); }); test("minProperties composes with propertyNames in one guard", () => { const schema = fromJSONSchema({ type: "object", propertyNames: { pattern: "^a" }, minProperties: 1 }); expect(schema.safeParse({}).error!.issues[0]!.code).toBe("too_small"); expect(schema.safeParse({ b: 1 }).error!.issues[0]!.code).toBe("invalid_key"); expect(schema.safeParse({ a: 1 }).success).toBe(true); }); test("uniqueItems rejects structural duplicates", () => { const schema = fromJSONSchema({ type: "array", uniqueItems: true }); expect( schema.safeParse([ { a: 1, b: 2 }, { b: 2, a: 1 }, ]).error!.issues[0] ).toMatchObject({ code: "custom", path: [1], }); expect(schema.safeParse([1, "1", true, 1]).success).toBe(false); expect(schema.safeParse([{ a: 1 }, { a: 2 }, [1], [2]]).success).toBe(true); expect(z.toJSONSchema(schema)).toMatchObject({ uniqueItems: true }); }); test("contains with minContains and maxContains", () => { const schema = fromJSONSchema({ type: "array", contains: { type: "integer" } }); expect(schema.safeParse(["a"]).success).toBe(false); expect(schema.safeParse(["a", 1]).success).toBe(true); const bounded = fromJSONSchema({ type: "array", contains: { type: "integer" }, minContains: 2, maxContains: 3 }); expect(bounded.safeParse([1, "a"]).success).toBe(false); expect(bounded.safeParse([1, 2, "a"]).success).toBe(true); expect(bounded.safeParse([1, 2, 3, 4]).success).toBe(false); // minContains: 0 accepts an array with no matches at all expect(fromJSONSchema({ type: "array", contains: { type: "integer" }, minContains: 0 }).safeParse([]).success).toBe( true ); expect(z.toJSONSchema(bounded)).toMatchObject({ contains: { type: "integer" }, minContains: 2, maxContains: 3 }); }); test("uniqueItems and contains see the raw instance, not the parsed output", () => { const items = { type: "object", properties: { a: { type: "string", default: "x" } } } as const; // no instance item carries `a`; the default must not synthesize the match const contains = fromJSONSchema({ type: "array", items, contains: { type: "object", properties: { a: { const: "x" } }, required: ["a"] }, }); expect(contains.safeParse([{}]).success).toBe(false); expect(contains.safeParse([{ a: "x" }]).success).toBe(true); // the instance items differ; the default must not collapse them into duplicates const unique = fromJSONSchema({ type: "array", items, uniqueItems: true }); expect(unique.safeParse([{ a: "x" }, {}]).success).toBe(true); expect(unique.safeParse([{}, {}]).error!.issues[0]).toMatchObject({ code: "custom", path: [1] }); }); test("a guard keyword takes precedence over the length keywords", () => { const schema = fromJSONSchema({ type: "array", items: { type: "integer" }, minItems: 3, uniqueItems: true }); // the guard runs on the input side of the pipe, so its issue aborts before minItems is reached expect(schema.safeParse([1, 1]).error!.issues.map((i) => i.code)).toEqual(["custom"]); expect(schema.safeParse([1, 2]).error!.issues.map((i) => i.code)).toEqual(["too_small"]); }); test("a guard keyword drops default and examples from the input-mode document", () => { // the guard is a transform on the pipe's input side, and toJSONSchema strips annotations from a transforming schema in input mode const schema = fromJSONSchema({ type: "array", uniqueItems: true, default: [], examples: [[1]] }); expect(z.toJSONSchema(schema, { io: "input" })).not.toHaveProperty("default"); expect(z.toJSONSchema(schema, { io: "output" })).toMatchObject({ default: [], examples: [[1]] }); // without a guard keyword both modes carry them const plain = fromJSONSchema({ type: "array", default: [], examples: [[1]] }); expect(z.toJSONSchema(plain, { io: "input" })).toMatchObject({ default: [], examples: [[1]] }); }); test("uniqueItems equality follows JSON semantics", () => { const schema = fromJSONSchema({ type: "array", uniqueItems: true }); expect(schema.safeParse([1, "1", true, null]).success).toBe(true); expect(schema.safeParse([{ a: undefined }, {}]).success).toBe(true); expect( schema.safeParse([ [1, [2]], [1, [2]], ]).success ).toBe(false); expect(schema.safeParse([{ a: "b=c" }, { "a=b": "c" }]).success).toBe(true); // every duplicate is reported against the first element it matches expect(schema.safeParse([1, 1, 1]).error!.issues.map((i) => i.path[0])).toEqual([1, 2]); // a cycle cannot be compared, so it is never called a duplicate rather than hanging const cyclic: any = {}; cyclic.self = cyclic; expect(schema.safeParse([cyclic, cyclic]).success).toBe(true); }); test("a carried subschema that references $defs is dropped rather than emitted dangling", () => { const schema = fromJSONSchema({ type: "array", contains: { $ref: "#/$defs/Hit" }, $defs: { Hit: { type: "object", properties: { id: { type: "string" } }, required: ["id"] } }, }); // enforcement is unaffected; only the round trip gives the keyword up expect(schema.safeParse([{ id: "a" }]).success).toBe(true); expect(schema.safeParse([{}]).success).toBe(false); expect(JSON.stringify(z.toJSONSchema(schema))).not.toContain("$ref"); expect(z.toJSONSchema(schema)).not.toHaveProperty("contains"); // an inline subschema is self-contained, so it still round-trips const inline = fromJSONSchema({ type: "array", contains: { type: "integer" } }); expect(z.toJSONSchema(inline)).toMatchObject({ contains: { type: "integer" } }); // only schema positions are walked, so a `$ref` key in instance data or an unknown annotation still travels for (const annotation of [{ default: { $ref: "literal" } }, { "x-note": { $ref: "literal" } }]) { const annotated = fromJSONSchema({ type: "array", contains: { type: "integer", ...annotation } }); expect(annotated.safeParse(["x"]).success).toBe(false); expect(z.toJSONSchema(annotated)).toMatchObject({ contains: { type: "integer" } }); } // draft-7 `dependencies` is a schema map; its array form names properties and holds no reference const dependent = fromJSONSchema({ type: "array", contains: { type: "object", dependencies: { a: { $ref: "#/definitions/Hit" } } }, definitions: { Hit: { type: "object" } }, }); expect(z.toJSONSchema(dependent)).not.toHaveProperty("contains"); const named = fromJSONSchema({ type: "array", contains: { type: "object", dependencies: { a: ["b"] } }, }); expect(z.toJSONSchema(named)).toMatchObject({ contains: { dependencies: { a: ["b"] } } }); // a reference nested in a schema position is still caught const nested = fromJSONSchema({ type: "array", contains: { type: "array", items: { $ref: "#/$defs/Hit" } }, $defs: { Hit: { type: "string" } }, }); expect(z.toJSONSchema(nested)).not.toHaveProperty("contains"); });