Skip to content
16 changes: 14 additions & 2 deletions langchain-core/src/utils/json_schema.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
import { toJSONSchema } from "zod/v4/core";
import { type JsonSchema7Type, zodToJsonSchema } from "zod-to-json-schema";
import { dereference, type Schema } from "@cfworker/json-schema";
import { isZodSchemaV3, isZodSchemaV4, InteropZodType } from "./types/zod.js";
import {
isZodSchemaV3,
isZodSchemaV4,
InteropZodType,
interopZodObjectStrict,
isZodObjectV4,
ZodObjectV4,
} from "./types/zod.js";

export type JSONSchema = JsonSchema7Type;

Expand All @@ -14,7 +21,12 @@ export { deepCompareStrict, Validator } from "@cfworker/json-schema";
*/
export function toJsonSchema(schema: InteropZodType | JSONSchema): JSONSchema {
if (isZodSchemaV4(schema)) {
return toJSONSchema(schema);
if (isZodObjectV4(schema)) {
const strictSchema = interopZodObjectStrict(schema, true) as ZodObjectV4;
return toJSONSchema(strictSchema, { io: "input" });
} else {
return toJSONSchema(schema, { io: "input" });
}
Copy link
Member Author

Choose a reason for hiding this comment

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

Its okay to assume that we should use the input side of the schema entirely here since the zodToJsonSchema utility that's been around for a while defaults to using that.

Zod's json schema serializer also assumes that the input side for object schemas should mean there's no additionalProperties output on the JSON schema unless its an explicit strict object. This also departs from zodToJsonSchema's behavior, so interopZodObjectStrict recursively applies strict conditions to the object so that this field populates.

Copy link
Member Author

Choose a reason for hiding this comment

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

}
if (isZodSchemaV3(schema)) {
return zodToJsonSchema(schema);
Expand Down
212 changes: 212 additions & 0 deletions langchain-core/src/utils/types/tests/zod.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ import {
interopZodObjectPartial,
interopZodObjectPassthrough,
getInteropZodDefaultGetter,
interopZodObjectStrict,
ZodObjectV4,
} from "../zod.js";

describe("Zod utility functions", () => {
Expand Down Expand Up @@ -968,6 +970,82 @@ describe("Zod utility functions", () => {
extra: "field",
});
});

it("should handle recursive passthrough validation", () => {
const schema = z4.object({
user: z4.strictObject({
name: z4.string(),
age: z4.number(),
}),
});
const passthrough = interopZodObjectPassthrough(schema, true);
expect(
interopParse(passthrough, {
user: {
name: "John",
age: 30,
extra: "field",
additional: 123,
},
extra: "field",
})
).toEqual({
user: {
name: "John",
age: 30,
extra: "field",
additional: 123,
},
extra: "field",
});
});

it("should not apply passthrough validation recursively by default", () => {
const schema = z4.object({
user: z4.strictObject({
name: z4.string(),
age: z4.number(),
}),
});
const passthrough = interopZodObjectPassthrough(schema);
expect(() =>
interopParse(passthrough, {
user: {
name: "John",
age: 30,
extra: "field",
},
})
).toThrow();
});

it("should add `additionalProperties: {}` when serialized to JSON schema", () => {
const schema = z4.object({
name: z4.string(),
age: z4.number(),
});
const passthrough = interopZodObjectPassthrough(schema) as ZodObjectV4;
const jsonSchema = z4.toJSONSchema(passthrough, { io: "input" });
expect(jsonSchema.additionalProperties).toEqual({});
});

it("should add `additionalProperties: {}` when serialized to JSON schema recursively", () => {
const schema = z4.object({
user: z4.object({
name: z4.string(),
age: z4.number(),
}),
});
const passthrough = interopZodObjectPassthrough(
schema,
true
) as ZodObjectV4;
const jsonSchema = z4.toJSONSchema(passthrough, { io: "input" });
console.log(jsonSchema);
expect(jsonSchema.additionalProperties).toEqual({});
// @ts-expect-error - JSON schema types are not generic, but we still want to check the nested object
expect(jsonSchema.properties?.user?.additionalProperties).toEqual({});
});
});
});

Expand Down Expand Up @@ -1058,4 +1136,138 @@ describe("Zod utility functions", () => {
});
});
});

describe("interopZodObjectStrict", () => {
describe("v3 schemas", () => {
it("should make object schema strict", () => {
const schema = z3.object({
name: z3.string(),
age: z3.number(),
});
const strict = interopZodObjectStrict(schema);
expect(strict).toBeInstanceOf(z3.ZodObject);
const shape = getInteropZodObjectShape(strict);
expect(Object.keys(shape)).toEqual(["name", "age"]);
expect(shape.name).toBeInstanceOf(z3.ZodString);
expect(shape.age).toBeInstanceOf(z3.ZodNumber);
});

it("should reject extra properties", () => {
const schema = z3.object({
name: z3.string(),
age: z3.number(),
});
const strict = interopZodObjectStrict(schema);
expect(() =>
interopParse(strict, { name: "John", age: 30, extra: "field" })
).toThrow();
});
});

describe("v4 schemas", () => {
it("should make object schema strict", () => {
const schema = z4.object({
name: z4.string(),
age: z4.number(),
});
const strict = interopZodObjectStrict(schema);
expect(strict).toBeInstanceOf(z4.ZodObject);
const shape = getInteropZodObjectShape(strict);
expect(Object.keys(shape)).toEqual(["name", "age"]);
expect(shape.name).toBeInstanceOf(z4.ZodString);
expect(shape.age).toBeInstanceOf(z4.ZodNumber);
});

it("should reject extra properties", () => {
const schema = z4.object({
name: z4.string(),
age: z4.number(),
});
const strict = interopZodObjectStrict(schema);
expect(() =>
interopParse(strict, { name: "John", age: 30, extra: "field" })
).toThrow();
});

it("should handle recursive strict validation", () => {
const schema = z4.object({
user: z4.object({
name: z4.string(),
age: z4.number(),
}),
});
const strict = interopZodObjectStrict(schema, true);
expect(() =>
interopParse(strict, {
user: { name: "John", age: 30, extra: "field" },
})
).toThrow();
});

it("should not apply strict validation recursively by default", () => {
const schema = z4.object({
user: z4.looseObject({
name: z4.string(),
age: z4.number(),
}),
});
const strict = interopZodObjectStrict(schema);
expect(
interopParse(strict, {
user: { name: "John", age: 30, extra: "field" },
})
).toEqual({
user: { name: "John", age: 30, extra: "field" },
});
});

it("should add `additionalProperties: false` when serialized to JSON schema", () => {
const schema = z4.object({
name: z4.string(),
age: z4.number(),
});
const strict = interopZodObjectStrict(schema) as ZodObjectV4;
const jsonSchema = z4.toJSONSchema(strict, { io: "input" });
expect(jsonSchema.additionalProperties).toBe(false);
});

it("should add `additionalProperties: false` when serialized to JSON schema recursively", () => {
const schema = z4.object({
user: z4.object({
name: z4.string(),
age: z4.number(),
}),
});
const strict = interopZodObjectStrict(schema, true) as ZodObjectV4;
const jsonSchema = z4.toJSONSchema(strict, { io: "input" });
expect(jsonSchema.additionalProperties).toBe(false);
// @ts-expect-error - JSON schema types are not generic, but we still want to check the nested object
expect(jsonSchema.properties?.user?.additionalProperties).toBe(false);
});
});

it("should throw error for non-object schemas", () => {
// @ts-expect-error - Testing invalid input
expect(() => interopZodObjectStrict(z3.string())).toThrow();
// @ts-expect-error - Testing invalid input
expect(() => interopZodObjectStrict(z4.string())).toThrow();
// @ts-expect-error - Testing invalid input
expect(() => interopZodObjectStrict(z3.number())).toThrow();
// @ts-expect-error - Testing invalid input
expect(() => interopZodObjectStrict(z4.number())).toThrow();
// @ts-expect-error - Testing invalid input
expect(() => interopZodObjectStrict(z3.array(z3.string()))).toThrow();
// @ts-expect-error - Testing invalid input
expect(() => interopZodObjectStrict(z4.array(z4.string()))).toThrow();
});

it("should throw error for malformed schemas", () => {
// @ts-expect-error - Testing invalid input
expect(() => interopZodObjectStrict({})).toThrow();
// @ts-expect-error - Testing invalid input
expect(() => interopZodObjectStrict({ _def: "fake" })).toThrow();
// @ts-expect-error - Testing invalid input
expect(() => interopZodObjectStrict({ _zod: "fake" })).toThrow();
});
});
});
Loading
Loading