Skip to content

Commit 068ff56

Browse files
fix(compat): reject Zod v3 fields in raw-shape auto-wrap with actionable error
isZodSchema previously matched on `_def` / `~standard.vendor === 'zod'`, both of which Zod v3 schemas also satisfy. A v3 raw shape would pass isZodRawShape, get wrapped by v4's z.object(), and crash deep inside zod when listing or calling the tool. Now detect v4 via the `_zod` property (absent on v3), and add a dedicated runtime guard in normalizeRawShapeSchema that throws a clear TypeError when v3 fields are seen, telling the user to import from zod/v4 or wrap the shape themselves.
1 parent c75bc88 commit 068ff56

2 files changed

Lines changed: 42 additions & 6 deletions

File tree

packages/core/src/util/zodCompat.ts

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,26 @@
66

77
import * as z from 'zod/v4';
88

9-
import type { StandardSchemaV1, StandardSchemaWithJSON } from './standardSchema.js';
9+
import type { StandardSchemaWithJSON } from './standardSchema.js';
1010
import { isStandardSchema, isStandardSchemaWithJSON } from './standardSchema.js';
1111

12-
function isZodSchema(v: unknown): v is z.ZodType {
13-
if (typeof v !== 'object' || v === null) return false;
14-
if ('_def' in v) return true;
15-
return isStandardSchema(v) && (v as StandardSchemaV1)['~standard'].vendor === 'zod';
12+
function isZodV4Schema(v: unknown): v is z.ZodType {
13+
// `_zod` is the v4 internal namespace property. Zod v3 schemas have `_def`
14+
// and (since 3.24) `~standard.vendor === 'zod'`, but never `_zod`. We require
15+
// v4 because the wrap path below uses v4's `z.object()`, which cannot consume
16+
// v3 field schemas.
17+
return typeof v === 'object' && v !== null && '_zod' in v;
18+
}
19+
20+
function looksLikeZodV3(v: unknown): boolean {
21+
// v3 schemas have `_def.typeName` (e.g. 'ZodString') and no `_zod`.
22+
return (
23+
typeof v === 'object' &&
24+
v !== null &&
25+
!('_zod' in v) &&
26+
'_def' in v &&
27+
typeof (v as { _def?: { typeName?: unknown } })._def?.typeName === 'string'
28+
);
1629
}
1730

1831
/**
@@ -27,7 +40,7 @@ export function isZodRawShape(obj: unknown): obj is Record<string, z.ZodType> {
2740
if (typeof obj !== 'object' || obj === null) return false;
2841
if (isStandardSchema(obj)) return false;
2942
// [].every() is true, so an empty object is a valid raw shape (matches v1).
30-
return Object.values(obj).every(v => isZodSchema(v));
43+
return Object.values(obj).every(v => isZodV4Schema(v));
3144
}
3245

3346
/**
@@ -45,6 +58,11 @@ export function normalizeRawShapeSchema(
4558
if (isZodRawShape(schema)) {
4659
return z.object(schema) as StandardSchemaWithJSON;
4760
}
61+
if (typeof schema === 'object' && !isStandardSchema(schema) && Object.values(schema).some(v => looksLikeZodV3(v))) {
62+
throw new TypeError(
63+
'Raw-shape inputSchema/outputSchema/argsSchema fields must be Zod v4 schemas. Got a Zod v3 field schema. Import from `zod/v4` (or upgrade your zod import), or wrap with `z.object({...})` yourself.'
64+
);
65+
}
4866
if (!isStandardSchemaWithJSON(schema)) {
4967
throw new TypeError(
5068
'inputSchema/outputSchema/argsSchema must be a Standard Schema with JSON Schema export (`~standard.jsonSchema`, e.g. z.object({...}) from zod >=4.2.0) or a raw Zod shape ({ field: z.string() }).'

packages/core/test/util/zodCompat.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,21 @@ describe('isZodRawShape', () => {
1717
const nonZod = { '~standard': { version: 1, vendor: 'arktype', validate: () => ({ value: 'x' }) } };
1818
expect(isZodRawShape({ a: nonZod })).toBe(false);
1919
});
20+
test('rejects a shape with Zod v3 fields (only v4 is wrappable)', () => {
21+
expect(isZodRawShape({ a: mockZodV3String() })).toBe(false);
22+
});
2023
});
2124

25+
// Minimal structural mock of a Zod v3 schema: has `_def.typeName` and
26+
// `~standard.vendor === 'zod'` (zod >=3.24), but no `_zod`.
27+
function mockZodV3String(): unknown {
28+
return {
29+
_def: { typeName: 'ZodString', checks: [], coerce: false },
30+
'~standard': { version: 1, vendor: 'zod', validate: (v: unknown) => ({ value: v }) },
31+
parse: (v: unknown) => v,
32+
};
33+
}
34+
2235
describe('normalizeRawShapeSchema', () => {
2336
test('wraps empty raw shape into z.object({})', () => {
2437
const wrapped = normalizeRawShapeSchema({});
@@ -39,4 +52,9 @@ describe('normalizeRawShapeSchema', () => {
3952
const noJson = { '~standard': { version: 1, vendor: 'x', validate: () => ({ value: {} }) } };
4053
expect(() => normalizeRawShapeSchema(noJson as never)).toThrow(/~standard\.jsonSchema/);
4154
});
55+
test('throws actionable TypeError for a raw shape with Zod v3 fields', () => {
56+
expect(() => normalizeRawShapeSchema({ a: mockZodV3String() } as never)).toThrow(
57+
/Zod v4 schemas.*Got a Zod v3 field schema/
58+
);
59+
});
4260
});

0 commit comments

Comments
 (0)