Skip to content

Commit f0ff0ff

Browse files
authored
fix(langchain/zod): Support optionals in zod transform interop (#9124)
1 parent ca32dd7 commit f0ff0ff

File tree

2 files changed

+201
-0
lines changed

2 files changed

+201
-0
lines changed

libs/langchain-core/src/utils/types/tests/zod.test.ts

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1527,6 +1527,142 @@ describe("Zod utility functions", () => {
15271527
expect(elementShape.age).toBeInstanceOf(z4.ZodNumber);
15281528
});
15291529

1530+
it("should handle transforms in optional schemas", () => {
1531+
const inputSchema = z4.object({
1532+
name: z4
1533+
.string()
1534+
.transform((s) => s.toUpperCase())
1535+
.optional(),
1536+
age: z4.number().optional(),
1537+
});
1538+
1539+
const result = interopZodTransformInputSchema(inputSchema, true);
1540+
1541+
expect(result).toBeInstanceOf(z4.ZodObject);
1542+
const resultShape = getInteropZodObjectShape(result as any);
1543+
expect(Object.keys(resultShape)).toEqual(["name", "age"]);
1544+
1545+
// The name property should be optional string (transform removed)
1546+
expect(resultShape.name).toBeInstanceOf(z4.ZodOptional);
1547+
const nameInner = (resultShape.name as any)._zod.def.innerType;
1548+
expect(nameInner).toBeInstanceOf(z4.ZodString);
1549+
1550+
// The age property should remain optional number
1551+
expect(resultShape.age).toBeInstanceOf(z4.ZodOptional);
1552+
const ageInner = (resultShape.age as any)._zod.def.innerType;
1553+
expect(ageInner).toBeInstanceOf(z4.ZodNumber);
1554+
});
1555+
1556+
it("should handle transforms in nullable schemas", () => {
1557+
const inputSchema = z4.object({
1558+
name: z4
1559+
.string()
1560+
.transform((s) => s.toLowerCase())
1561+
.nullable(),
1562+
count: z4.number().nullable(),
1563+
});
1564+
1565+
const result = interopZodTransformInputSchema(inputSchema, true);
1566+
1567+
expect(result).toBeInstanceOf(z4.ZodObject);
1568+
const resultShape = getInteropZodObjectShape(result as any);
1569+
expect(Object.keys(resultShape)).toEqual(["name", "count"]);
1570+
1571+
// The name property should be nullable string (transform removed)
1572+
expect(resultShape.name).toBeInstanceOf(z4.ZodNullable);
1573+
const nameInner = (resultShape.name as any)._zod.def.innerType;
1574+
expect(nameInner).toBeInstanceOf(z4.ZodString);
1575+
1576+
// The count property should remain nullable number
1577+
expect(resultShape.count).toBeInstanceOf(z4.ZodNullable);
1578+
const countInner = (resultShape.count as any)._zod.def.innerType;
1579+
expect(countInner).toBeInstanceOf(z4.ZodNumber);
1580+
});
1581+
1582+
it("should handle transforms in nullish (optional + nullable) schemas", () => {
1583+
const inputSchema = z4.object({
1584+
name: z4
1585+
.string()
1586+
.transform((s) => s.trim())
1587+
.nullish(),
1588+
value: z4.number().nullish(),
1589+
});
1590+
1591+
const result = interopZodTransformInputSchema(inputSchema, true);
1592+
1593+
expect(result).toBeInstanceOf(z4.ZodObject);
1594+
const resultShape = getInteropZodObjectShape(result as any);
1595+
expect(Object.keys(resultShape)).toEqual(["name", "value"]);
1596+
1597+
// The name property should be optional(nullable(string)) with transform removed
1598+
expect(resultShape.name).toBeInstanceOf(z4.ZodOptional);
1599+
const nameOptionalInner = (resultShape.name as any)._zod.def.innerType;
1600+
expect(nameOptionalInner).toBeInstanceOf(z4.ZodNullable);
1601+
const nameNullableInner = (nameOptionalInner as any)._zod.def.innerType;
1602+
expect(nameNullableInner).toBeInstanceOf(z4.ZodString);
1603+
1604+
// The value property should remain nullish number
1605+
expect(resultShape.value).toBeInstanceOf(z4.ZodOptional);
1606+
const valueOptionalInner = (resultShape.value as any)._zod.def
1607+
.innerType;
1608+
expect(valueOptionalInner).toBeInstanceOf(z4.ZodNullable);
1609+
const valueNullableInner = (valueOptionalInner as any)._zod.def
1610+
.innerType;
1611+
expect(valueNullableInner).toBeInstanceOf(z4.ZodNumber);
1612+
});
1613+
1614+
it("should handle transforms in optional objects", () => {
1615+
const userSchema = z4.object({
1616+
name: z4.string().transform((s) => s.toUpperCase()),
1617+
age: z4.number(),
1618+
});
1619+
1620+
const inputSchema = z4.object({
1621+
user: userSchema.optional(),
1622+
metadata: z4.string(),
1623+
});
1624+
1625+
const result = interopZodTransformInputSchema(inputSchema, true);
1626+
1627+
expect(result).toBeInstanceOf(z4.ZodObject);
1628+
const resultShape = getInteropZodObjectShape(result as any);
1629+
expect(Object.keys(resultShape)).toEqual(["user", "metadata"]);
1630+
1631+
// The user property should be optional(object) with transform removed from inner properties
1632+
expect(resultShape.user).toBeInstanceOf(z4.ZodOptional);
1633+
const userInner = (resultShape.user as any)._zod.def.innerType;
1634+
expect(userInner).toBeInstanceOf(z4.ZodObject);
1635+
1636+
const userShape = getInteropZodObjectShape(userInner as any);
1637+
expect(Object.keys(userShape)).toEqual(["name", "age"]);
1638+
expect(userShape.name).toBeInstanceOf(z4.ZodString);
1639+
expect(userShape.age).toBeInstanceOf(z4.ZodNumber);
1640+
});
1641+
1642+
it("should handle transforms in arrays of optional schemas", () => {
1643+
const inputSchema = z4.object({
1644+
items: z4.array(
1645+
z4
1646+
.string()
1647+
.transform((s) => s.toUpperCase())
1648+
.optional()
1649+
),
1650+
});
1651+
1652+
const result = interopZodTransformInputSchema(inputSchema, true);
1653+
1654+
expect(result).toBeInstanceOf(z4.ZodObject);
1655+
const resultShape = getInteropZodObjectShape(result as any);
1656+
1657+
// The items property should be an array of optional strings
1658+
expect(resultShape.items).toBeInstanceOf(z4.ZodArray);
1659+
const arrayElement = (resultShape.items as any)._zod.def.element;
1660+
expect(arrayElement).toBeInstanceOf(z4.ZodOptional);
1661+
1662+
const elementInner = (arrayElement as any)._zod.def.innerType;
1663+
expect(elementInner).toBeInstanceOf(z4.ZodString);
1664+
});
1665+
15301666
it("should cache and reuse sanitized sub-schemas when same schema is used in multiple properties", () => {
15311667
// Create a shared sub-schema that will be used in multiple places
15321668
const addressSchema = z4.object({

libs/langchain-core/src/utils/types/zod.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ export type ZodDefaultV3<T extends z3.ZodTypeAny> = z3.ZodDefault<T>;
2626
export type ZodDefaultV4<T extends z4.SomeType> = z4.$ZodDefault<T>;
2727
export type ZodOptionalV3<T extends z3.ZodTypeAny> = z3.ZodOptional<T>;
2828
export type ZodOptionalV4<T extends z4.SomeType> = z4.$ZodOptional<T>;
29+
export type ZodNullableV4<T extends z4.SomeType> = z4.$ZodNullable<T>;
2930

3031
// eslint-disable-next-line @typescript-eslint/no-explicit-any
3132
export type InteropZodType<Output = any, Input = Output> =
@@ -513,6 +514,46 @@ export function isZodArrayV4(obj: unknown): obj is z4.$ZodArray {
513514
return false;
514515
}
515516

517+
export function isZodOptionalV4(obj: unknown): obj is z4.$ZodOptional {
518+
if (!isZodSchemaV4(obj)) return false;
519+
// Zod v4 optional schemas have _zod.def.type === "optional"
520+
if (
521+
typeof obj === "object" &&
522+
obj !== null &&
523+
"_zod" in obj &&
524+
typeof obj._zod === "object" &&
525+
obj._zod !== null &&
526+
"def" in obj._zod &&
527+
typeof obj._zod.def === "object" &&
528+
obj._zod.def !== null &&
529+
"type" in obj._zod.def &&
530+
obj._zod.def.type === "optional"
531+
) {
532+
return true;
533+
}
534+
return false;
535+
}
536+
537+
export function isZodNullableV4(obj: unknown): obj is z4.$ZodNullable {
538+
if (!isZodSchemaV4(obj)) return false;
539+
// Zod v4 nullable schemas have _zod.def.type === "nullable"
540+
if (
541+
typeof obj === "object" &&
542+
obj !== null &&
543+
"_zod" in obj &&
544+
typeof obj._zod === "object" &&
545+
obj._zod !== null &&
546+
"def" in obj._zod &&
547+
typeof obj._zod.def === "object" &&
548+
obj._zod.def !== null &&
549+
"type" in obj._zod.def &&
550+
obj._zod.def.type === "nullable"
551+
) {
552+
return true;
553+
}
554+
return false;
555+
}
556+
516557
/**
517558
* Determines if the provided value is an InteropZodObject (Zod v3 or v4 object schema).
518559
*
@@ -843,6 +884,30 @@ function interopZodTransformInputSchemaImpl(
843884
element: elementSchema as z4.$ZodType,
844885
});
845886
}
887+
// Handle optional schemas
888+
else if (isZodOptionalV4(outputSchema)) {
889+
const innerSchema = interopZodTransformInputSchemaImpl(
890+
outputSchema._zod.def.innerType as InteropZodType,
891+
recursive,
892+
cache
893+
);
894+
outputSchema = clone<z4.$ZodOptional>(outputSchema, {
895+
...outputSchema._zod.def,
896+
innerType: innerSchema as z4.$ZodType,
897+
});
898+
}
899+
// Handle nullable schemas
900+
else if (isZodNullableV4(outputSchema)) {
901+
const innerSchema = interopZodTransformInputSchemaImpl(
902+
outputSchema._zod.def.innerType as InteropZodType,
903+
recursive,
904+
cache
905+
);
906+
outputSchema = clone<z4.$ZodNullable>(outputSchema, {
907+
...outputSchema._zod.def,
908+
innerType: innerSchema as z4.$ZodType,
909+
});
910+
}
846911
}
847912
const meta = globalRegistry.get(schema);
848913
if (meta) globalRegistry.add(outputSchema as z4.$ZodType, meta);

0 commit comments

Comments
 (0)