diff --git a/apps/content/docs/openapi/openapi-specification.md b/apps/content/docs/openapi/openapi-specification.md index c6b2e5f5a..2387e176d 100644 --- a/apps/content/docs/openapi/openapi-specification.md +++ b/apps/content/docs/openapi/openapi-specification.md @@ -66,11 +66,14 @@ export class ValibotToJsonSchemaConverter implements ConditionalSchemaConverter It's recommended to use the built-in converters because the oRPC implementations handle many edge cases and supports every type that oRPC offers. ::: -```ts twoslash -import { contract, router } from './shared/planet' -// ---cut--- +```ts import { OpenAPIGenerator } from '@orpc/openapi' -import { ZodToJsonSchemaConverter } from '@orpc/zod' +import { + ZodToJsonSchemaConverter +} from '@orpc/zod' // <-- zod v3 +import { + experimental_ZodToJsonSchemaConverter as ZodToJsonSchemaConverter +} from '@orpc/zod/zod4' // <-- zod v4 import { experimental_ValibotToJsonSchemaConverter as ValibotToJsonSchemaConverter } from '@orpc/valibot' @@ -163,7 +166,66 @@ The `.spec` helper accepts a callback as its second argument, allowing you to ov ## `@orpc/zod` -### File Schema +### Zod v4 + +#### File Schema + +Zod v4 includes a native `File` schema. oRPC will detect it automatically - no extra setup needed: + +```ts +import * as z from 'zod' + +const InputSchema = z.object({ + file: oz.file(), + image: oz.file().mine(['image/png', 'image/jpeg']), +}) +``` + +#### JSON Schema Customization + +`description` and `examples` metadata are supported out of the box: + +```ts +import * as z from 'zod' + +const InputSchema = z.object({ + name: z.string(), +}).meta({ + description: 'User schema', + examples: [{ name: 'John' }], +}) +``` + +For further customization, you can use the `JSON_SCHEMA_REGISTRY`, `JSON_SCHEMA_INPUT_REGISTRY`, and `JSON_SCHEMA_OUTPUT_REGISTRY`: + +```ts +import * as z from 'zod' +import { + experimental_JSON_SCHEMA_REGISTRY as JSON_SCHEMA_REGISTRY, +} from '@orpc/zod/zod4' + +export const InputSchema = z.object({ + name: z.string(), +}) + +JSON_SCHEMA_REGISTRY.add(InputSchema, { + description: 'User schema', + examples: [{ name: 'John' }], + // other options... +}) + +JSON_SCHEMA_INPUT_REGISTRY.add(InputSchema, { + // only for .input +}) + +JSON_SCHEMA_OUTPUT_REGISTRY.add(InputSchema, { + // only for .output +}) +``` + +### Zod v3 + +#### File Schema In the [File Upload/Download](/docs/file-upload-download) guide, `z.instanceof` is used to describe file/blob schemas. However, this method prevents oRPC from recognizing file/blob schema. Instead, use the enhanced file schema approach: @@ -178,7 +240,7 @@ const InputSchema = z.object({ }) ``` -### JSON Schema Customization +#### JSON Schema Customization If Zod alone does not cover your JSON Schema requirements, you can extend or override the generated schema: diff --git a/apps/content/docs/openapi/plugins/zod-smart-coercion.md b/apps/content/docs/openapi/plugins/zod-smart-coercion.md index a5a8bcd2c..f3b1ee305 100644 --- a/apps/content/docs/openapi/plugins/zod-smart-coercion.md +++ b/apps/content/docs/openapi/plugins/zod-smart-coercion.md @@ -7,6 +7,10 @@ description: A refined alternative to `z.coerce` that automatically converts inp A Plugin refined alternative to `z.coerce` that automatically converts inputs to the expected type without modifying the input schema. +::: warning +In Zod v4, this plugin only supports **discriminated unions**. Regular (non-discriminated) unions are **not** coerced automatically. +::: + ## Installation ::: code-group @@ -37,7 +41,10 @@ deno install npm:@orpc/zod@latest ```ts import { OpenAPIHandler } from '@orpc/openapi/fetch' -import { ZodSmartCoercionPlugin } from '@orpc/zod' +import { ZodSmartCoercionPlugin } from '@orpc/zod' // <-- zod v3 +import { + experimental_ZodSmartCoercionPlugin as ZodSmartCoercionPlugin +} from '@orpc/zod/zod4' // <-- zod v4 const handler = new OpenAPIHandler(router, { plugins: [new ZodSmartCoercionPlugin()] diff --git a/packages/openapi/src/schema.ts b/packages/openapi/src/schema.ts index 7e7a36d85..153f0c8f9 100644 --- a/packages/openapi/src/schema.ts +++ b/packages/openapi/src/schema.ts @@ -1,8 +1,8 @@ /* eslint-disable no-restricted-imports */ import type { JSONSchema, keywords } from 'json-schema-typed/draft-2020-12' -import { Format as JSONSchemaFormat } from 'json-schema-typed/draft-2020-12' +import { ContentEncoding as JSONSchemaContentEncoding, Format as JSONSchemaFormat } from 'json-schema-typed/draft-2020-12' -export { JSONSchemaFormat } +export { JSONSchemaContentEncoding, JSONSchemaFormat } export type { JSONSchema } /** diff --git a/packages/zod/package.json b/packages/zod/package.json index 027f75f22..02a778302 100644 --- a/packages/zod/package.json +++ b/packages/zod/package.json @@ -19,11 +19,17 @@ "types": "./dist/index.d.mts", "import": "./dist/index.mjs", "default": "./dist/index.mjs" + }, + "./zod4": { + "types": "./dist/zod4/index.d.mts", + "import": "./dist/zod4/index.mjs", + "default": "./dist/zod4/index.mjs" } } }, "exports": { - ".": "./src/index.ts" + ".": "./src/index.ts", + "./zod4": "./src/zod4/index.ts" }, "files": [ "dist" @@ -36,7 +42,16 @@ "peerDependencies": { "@orpc/contract": "workspace:*", "@orpc/server": "workspace:*", - "zod": "^3.24.2" + "@zod/core": ">=0.11.4", + "zod": ">=3.24.2" + }, + "peerDependenciesMeta": { + "@zod/core": { + "optional": true + }, + "zod": { + "optional": true + } }, "dependencies": { "@orpc/openapi": "workspace:*", @@ -45,6 +60,10 @@ "wildcard-match": "^5.1.3" }, "devDependencies": { - "zod-to-json-schema": "^3.24.5" + "@zod/core": "^0.11.4", + "@zod/mini": "^4.0.0-beta.20250505T012514", + "zod": "^3.24.2", + "zod-to-json-schema": "^3.24.5", + "zod4": "npm:zod@^4.0.0-beta.20250505T012514" } } diff --git a/packages/zod/src/zod4/coercer.combination.test.ts b/packages/zod/src/zod4/coercer.combination.test.ts new file mode 100644 index 000000000..bf251ace3 --- /dev/null +++ b/packages/zod/src/zod4/coercer.combination.test.ts @@ -0,0 +1,151 @@ +import z from 'zod4' +import { testSchemaSmartCoercion } from '../../tests/shared' + +const InfiniteLazySchema = z.lazy(() => z.object({ boolean: z.boolean(), value: z.lazy(() => InfiniteLazySchema) })) as any + +testSchemaSmartCoercion([ + { + name: 'union - 123 - un-discriminated', + schema: z.union([z.boolean(), z.number()]), + input: '123', + }, + { + name: 'union - object boolean - un-discriminated', + schema: z.union([z.object({ a: z.boolean() }), z.object({ b: z.number() })]), + input: { a: 'true' }, + }, + { + name: 'union - only one option', + schema: z.union([z.boolean()]), + input: 'true', + expected: true, + }, + { + name: 'union - one discriminated', + schema: z.union([z.object({ a: z.literal('type1'), b: z.number() }), z.object({ b: z.number() })]), + input: { a: 'type1', b: '123' }, + expected: { a: 'type1', b: 123 }, + }, + { + name: 'union - discriminated', + schema: z.union([z.object({ a: z.literal('type1'), b: z.number() }), z.object({ a: z.literal('type2'), b: z.bigint() })]), + input: { a: 'type2', b: '123' }, + expected: { a: 'type2', b: 123n }, + }, + { + name: 'union - complex discriminated 1', + schema: z.union([z.object({ a: z.object({ v: z.literal('type1') }), b: z.number() }), z.object({ a: z.literal('type2'), b: z.bigint() })]), + input: { a: { v: 'type1' }, b: '123' }, + expected: { a: { v: 'type1' }, b: 123 }, + }, + { + name: 'union - complex discriminated 2', + schema: z.union([z.object({ a: z.object({ v: z.literal('type1') }), b: z.number() }), z.object({ a: z.literal('type2'), b: z.bigint() })]), + input: { a: 'type1', b: '123' }, + }, + { + name: 'union - complex discriminated 3', + schema: z.union([z.object({ a: z.object({ v: z.literal('type1') }), b: z.number() }), z.object({ a: z.literal('type2'), b: z.bigint() })]), + input: { a: { v: 'type2' }, b: '123' }, + }, + { + name: 'union - not coerce discriminated key', + schema: z.union([z.object({ a: z.literal(true), b: z.number() }), z.object({ a: z.literal(false), b: z.bigint() })]), + input: { a: 'true', b: '123' }, + }, + { + name: 'intersection - 123', + schema: z.object({ a: z.number() }).and(z.object({ b: z.boolean() })), + input: { a: '1234', b: 'true' }, + expected: { a: 1234, b: true }, + }, + { + name: 'boolean - readonly', + schema: z.boolean().readonly(), + input: 'true', + expected: true, + }, + { + name: 'pipe - boolean', + schema: z.boolean().pipe(z.transform(() => '1')).pipe(z.string()), + input: 'true', + expected: true, + }, + { + name: 'transform - boolean', + schema: z.boolean().transform(() => {}), + input: 'true', + expected: true, + }, + { + name: 'brand - boolean', + schema: z.boolean().brand<'CAT'>(), + input: 'true', + expected: true, + }, + { + name: 'catch - boolean', + schema: z.boolean().catch(false), + input: 'true', + expected: true, + }, + { + name: 'default - boolean', + schema: z.boolean().default(false), + input: 'true', + expected: true, + }, + { + name: 'nullable - boolean', + schema: z.boolean().nullable(), + input: 'true', + expected: true, + }, + { + name: 'nullable - null', + schema: z.boolean().nullable(), + input: null, + expected: null, + }, + { + name: 'optional - boolean', + schema: z.boolean().optional(), + input: 'true', + expected: true, + }, + { + name: 'optional - undefined', + schema: z.boolean().optional(), + input: undefined, + expected: undefined, + }, + { + name: 'optional - non optional - undefined', + schema: z.boolean().optional().nonoptional(), + input: undefined, + expected: undefined, + }, + { + name: 'optional - non optional - true', + schema: z.boolean().optional().nonoptional(), + input: 'on', + expected: true, + }, + { + name: 'lazy - true', + schema: z.lazy(() => z.object({ value: z.lazy(() => z.object({ value: z.boolean() })) })), + input: { value: { value: 'true' } }, + expected: { value: { value: true } }, + }, + { + name: 'lazy - invalid', + schema: z.lazy(() => z.object({ value: z.lazy(() => z.object({ value: z.boolean() })) })), + input: { value: { value: 'invalid' } }, + }, + { + name: 'lazy - InfiniteLazySchema', + schema: InfiniteLazySchema, + input: { value: { boolean: 'true' } }, + expected: { value: { boolean: true } }, + }, +]) diff --git a/packages/zod/src/zod4/coercer.native.test.ts b/packages/zod/src/zod4/coercer.native.test.ts new file mode 100644 index 000000000..2ac62eb8a --- /dev/null +++ b/packages/zod/src/zod4/coercer.native.test.ts @@ -0,0 +1,170 @@ +import z from 'zod4' +import { testSchemaSmartCoercion } from '../../tests/shared' + +enum TestEnum { + NUMBER = 123, + STRING = 'string', +} + +testSchemaSmartCoercion([ + { + name: 'number - 12345', + schema: z.number(), + input: '12345', + expected: 12345, + }, + { + name: 'number - -12345', + schema: z.number(), + input: '-12345', + expected: -12345, + }, + { + name: 'number - 12345n', + schema: z.number(), + input: '12345n', + }, + { + name: 'bigint - 12345', + schema: z.bigint(), + input: '12345', + expected: 12345n, + }, + { + name: 'bigint - -12345', + schema: z.bigint(), + input: '-12345', + expected: -12345n, + }, + { + name: 'bigint - 12345n', + schema: z.bigint(), + input: '12345n', + }, + { + name: 'bigint - true', + schema: z.bigint(), + input: true, + }, + { + name: 'boolean - t', + schema: z.boolean(), + input: 't', + expected: true, + }, + { + name: 'boolean - true', + schema: z.boolean(), + input: 'true', + expected: true, + }, + { + name: 'boolean - on', + schema: z.boolean(), + input: 'on', + expected: true, + }, + { + name: 'boolean - ON', + schema: z.boolean(), + input: 'ON', + expected: true, + }, + { + name: 'boolean - f', + schema: z.boolean(), + input: 'f', + expected: false, + }, + { + name: 'boolean - false', + schema: z.boolean(), + input: 'false', + expected: false, + }, + { + name: 'boolean - off', + schema: z.boolean(), + input: 'off', + expected: false, + }, + { + name: 'boolean - OFF', + schema: z.boolean(), + input: 'OFF', + expected: false, + }, + { + name: 'boolean - hi', + schema: z.boolean(), + input: 'hi', + expected: 'hi', + }, + { + name: 'date - iso string', + schema: z.date(), + input: new Date('2023-01-01').toISOString(), + expected: new Date('2023-01-01'), + }, + { + name: 'date - 2023-01-01', + schema: z.date(), + input: '2023-01-01', + expected: new Date('2023-01-01'), + }, + { + name: 'date - 2023-01-01I', + schema: z.date(), + input: '2023-01-01I', + expected: '2023-01-01I', + }, + { + name: 'date - array', + schema: z.date(), + input: [], + expected: [], + }, + { + name: 'literal - 199', + schema: z.literal([199, '199', 200n, undefined]), + input: '199', + }, + { + name: 'literal - 200', + schema: z.literal([199, '199', 200n, undefined, true]), + input: '200', + expected: 200n, + }, + { + name: 'literal - undefined', + schema: z.literal([199, '199', 200n, undefined, true]), + input: undefined, + }, + { + name: 'literal - undefined', + schema: z.literal([199, '199', 200n, undefined, true]), + input: 'true', + expected: true, + }, + { + name: 'nativeEnum - 123', + schema: z.enum(TestEnum), + input: '123', + expected: 123, + }, + { + name: 'nativeEnum - string', + schema: z.enum(TestEnum), + input: 'string', + }, + { + name: 'nativeEnum - 123n', + schema: z.enum(TestEnum), + input: '123n', + }, + { + name: 'enum - 123', + schema: z.enum(['123', '456']), + input: '123', + }, +]) diff --git a/packages/zod/src/zod4/coercer.rest.test.ts b/packages/zod/src/zod4/coercer.rest.test.ts new file mode 100644 index 000000000..97a54971d --- /dev/null +++ b/packages/zod/src/zod4/coercer.rest.test.ts @@ -0,0 +1,15 @@ +import z from 'zod4' +import { testSchemaSmartCoercion } from '../../tests/shared' + +testSchemaSmartCoercion([ + { + name: 'number - 123', + schema: z.number().or(z.string()), + input: '123', + }, + { + name: 'boolean - true', + schema: z.boolean().or(z.string()), + input: 'true', + }, +]) diff --git a/packages/zod/src/zod4/coercer.structure.test.ts b/packages/zod/src/zod4/coercer.structure.test.ts new file mode 100644 index 000000000..0c47f10ec --- /dev/null +++ b/packages/zod/src/zod4/coercer.structure.test.ts @@ -0,0 +1,167 @@ +import z from 'zod4' +import { testSchemaSmartCoercion } from '../../tests/shared' + +testSchemaSmartCoercion([ + { + name: 'array - undefined', + schema: z.array(z.boolean()), + input: undefined, + expected: [], + }, + { + name: 'optional array - undefined', + schema: z.array(z.boolean()).optional(), + input: undefined, + }, + { + name: 'array - boolean', + schema: z.array(z.boolean()), + input: ['true', 'off', 'invalid'], + expected: [true, false, 'invalid'], + }, + { + name: 'array - object', + schema: z.array(z.boolean()), + input: { a: 1 }, + }, + { + name: 'tuple - undefined', + schema: z.tuple([z.number(), z.boolean()]), + input: undefined, + expected: [], + }, + { + name: 'optional tuple - undefined', + schema: z.tuple([z.number(), z.boolean()]).optional(), + input: undefined, + }, + { + name: 'tuple - number, boolean', + schema: z.tuple([z.number(), z.bigint()], z.boolean()), + input: ['123', '123', 'off', 'invalid'], + expected: [123, 123n, false, 'invalid'], + }, + { + name: 'tuple - number', + schema: z.tuple([z.number(), z.bigint()], z.boolean()), + input: 123, + }, + { + name: 'tuple - without rest', + schema: z.tuple([z.number(), z.bigint()]), + input: ['1', '2', '3', '4'], + expected: [1, 2n, '3', '4'], + }, + { + name: 'set - undefined', + schema: z.set(z.number()), + input: undefined, + expected: new Set(), + }, + { + name: 'optional set - undefined', + schema: z.set(z.number()).optional(), + input: undefined, + }, + { + name: 'set - array boolean', + schema: z.set(z.boolean()).optional(), + input: ['true', 'off', 'invalid'], + expected: new Set([true, false, 'invalid']), + }, + { + name: 'set - set boolean', + schema: z.set(z.boolean()).optional(), + input: new Set(['true', 'off', 'invalid']), + expected: new Set([true, false, 'invalid']), + }, + { + name: 'set - map', + schema: z.set(z.number()), + input: new Map([[1, 2]]), + }, + { + name: 'object - undefined', + schema: z.object({ a: z.boolean() }), + input: undefined, + expected: {}, + }, + { + name: 'optional object - undefined', + schema: z.object({ a: z.boolean() }).optional(), + input: undefined, + }, + { + name: 'object - boolean', + schema: z.object({ a: z.boolean() }), + input: { a: 'true' }, + expected: { a: true }, + }, + { + name: 'object - boolean with more fields than needed', + schema: z.object({ a: z.boolean() }), + input: { a: 'true', b: 'off' }, + expected: { a: true, b: 'off' }, + }, + { + name: 'object - boolean with catchall', + schema: z.object({ a: z.boolean() }).catchall(z.number()), + input: { a: 'true', b: 'off', c: '123' }, + expected: { a: true, b: 'off', c: 123 }, + }, + { + name: 'object - array', + schema: z.object({ a: z.boolean() }), + input: [3, 2, 1], + }, + { + name: 'record - undefined', + schema: z.record(z.string(), z.boolean()), + input: undefined, + expected: {}, + }, + { + name: 'optional record - undefined', + schema: z.record(z.string(), z.boolean()).optional(), + input: undefined, + }, + { + name: 'record - undefined', + schema: z.record(z.string(), z.boolean()), + input: { a: 'true', b: 'off' }, + expected: { a: true, b: false }, + }, + { + name: 'record - big int', + schema: z.record(z.string(), z.boolean()), + input: 123n, + }, + { + name: 'map - undefined', + schema: z.map(z.boolean(), z.number()), + input: undefined, + expected: new Map(), + }, + { + name: 'optional map - undefined', + schema: z.map(z.boolean(), z.number()).optional(), + input: undefined, + }, + { + name: 'map - array', + schema: z.map(z.boolean(), z.number()), + input: [['true', '1'], ['off', 2], ['invalid']], + expected: new Map([[true, 1], [false, 2], ['invalid', undefined]]), + }, + { + name: 'map - map boolean', + schema: z.map(z.boolean(), z.number()), + input: new Map([['true', '1'], ['off', 2], ['invalid']] as any), + expected: new Map([[true, 1], [false, 2], ['invalid', undefined]]), + }, + { + name: 'map - invalid array', + schema: z.map(z.boolean(), z.number()), + input: [1, 2, 3], + }, +]) diff --git a/packages/zod/src/zod4/coercer.test.ts b/packages/zod/src/zod4/coercer.test.ts new file mode 100644 index 000000000..679cd10a8 --- /dev/null +++ b/packages/zod/src/zod4/coercer.test.ts @@ -0,0 +1,37 @@ +import * as z from 'zod4' +import { + experimental_ZodSmartCoercionPlugin as ZodSmartCoercionPlugin, +} from './coercer' + +it('zodSmartCoercionPlugin ignore non-zod schemas', async () => { + const plugin = new ZodSmartCoercionPlugin() + const options = {} as any + plugin.init(options) + + const coerce = (schema: any, input: unknown) => { + let coerced: unknown + + options.clientInterceptors[0]({ + procedure: { + '~orpc': { + inputSchema: schema, + }, + }, + input, + next: (options: any) => { + coerced = typeof options === 'object' ? options.input : input + }, + }) + + return coerced + } + + const val = { value: 123 } + + expect(coerce(z.object({}), val)).toEqual(val) + expect(coerce(z.object({}), val)).not.toBe(val) + + const v = await import('valibot') + + expect(coerce(v.object({}), val)).toBe(val) +}) diff --git a/packages/zod/src/zod4/coercer.ts b/packages/zod/src/zod4/coercer.ts new file mode 100644 index 000000000..3005ee421 --- /dev/null +++ b/packages/zod/src/zod4/coercer.ts @@ -0,0 +1,378 @@ +import type { Context } from '@orpc/server' +import type { StandardHandlerOptions, StandardHandlerPlugin } from '@orpc/server/standard' +import type { $ZodArray, $ZodCatch, $ZodDefault, $ZodEnum, $ZodIntersection, $ZodLazy, $ZodLiteral, $ZodMap, $ZodNonOptional, $ZodNullable, $ZodObject, $ZodOptional, $ZodPipe, $ZodReadonly, $ZodRecord, $ZodSet, $ZodTuple, $ZodType, $ZodUnion, util } from '@zod/core' +import { guard, isObject } from '@orpc/shared' + +export class experimental_ZodSmartCoercionPlugin implements StandardHandlerPlugin { + init(options: StandardHandlerOptions): void { + options.clientInterceptors ??= [] + + options.clientInterceptors.unshift((options) => { + const inputSchema = options.procedure['~orpc'].inputSchema + + if (!inputSchema || inputSchema['~standard'].vendor !== 'zod') { + return options.next() + } + + const coercedInput = this.#coerce(inputSchema as $ZodType, options.input) + + return options.next({ ...options, input: coercedInput }) + }) + } + + #coerce(schema: $ZodType, value: unknown): unknown { + switch (schema._zod.def.type) { + case 'number' : { + if (typeof value === 'string') { + return this.#stringToNumber(value) + } + + return value + } + + case 'bigint' : { + if (typeof value === 'string') { + return this.#stringToBigInt(value) + } + + return value + } + + case 'boolean' : + case 'success' : { + if (typeof value === 'string') { + return this.#stringToBoolean(value) + } + + return value + } + + case 'date' : { + if (typeof value === 'string') { + return this.#stringToDate(value) + } + + return value + } + + case 'literal' : + case 'enum': { + const literal = schema as $ZodLiteral | $ZodEnum + + if (!literal._zod.values.has(value as any) && typeof value === 'string') { + const num = this.#stringToNumber(value) + if (literal._zod.values.has(num as any)) { + return num + } + + const bool = this.#stringToBoolean(value) + if (literal._zod.values.has(bool as any)) { + return bool + } + + const bigint = this.#stringToBigInt(value) + if (literal._zod.values.has(bigint as any)) { + return bigint + } + } + + return value + } + + case 'array': { + const array = schema as $ZodArray + + if (value === undefined) { + return [] + } + + if (Array.isArray(value)) { + return value.map(v => this.#coerce(array._zod.def.element, v)) + } + + return value + } + + case 'tuple': { + const tuple = schema as $ZodTuple + + if (value === undefined) { + return [] + } + + if (Array.isArray(value)) { + return value.map((v, i) => { + const s = tuple._zod.def.items[i] ?? tuple._zod.def.rest + return s ? this.#coerce(s, v) : v + }) + } + + return value + } + + case 'set': { + const set = schema as $ZodSet + + if (value === undefined) { + return new Set() + } + + if (Array.isArray(value)) { + return new Set( + value.map(v => this.#coerce(set._zod.def.valueType, v)), + ) + } + + if (value instanceof Set) { + return new Set( + Array.from(value).map(v => this.#coerce(set._zod.def.valueType, v)), + ) + } + + return value + } + + case 'object': + case 'interface': { + const object = schema as $ZodObject + + if (value === undefined) { + return {} + } + + if (isObject(value)) { + const newObj: Record = {} + + const keys = new Set([ + ...Object.keys(value), + ...Object.keys(object._zod.def.shape), + ]) + + for (const k of keys) { + const s = object._zod.def.shape[k] ?? object._zod.def.catchall + newObj[k] = s ? this.#coerce(s, value[k]) : value[k] + } + + return newObj + } + + return value + } + + case 'record': { + const record = schema as $ZodRecord + + if (value === undefined) { + return {} + } + + if (isObject(value)) { + const newObj: Record = {} + + for (const [k, v] of Object.entries(value)) { + const key = this.#coerce(record._zod.def.keyType, k) + const val = this.#coerce(record._zod.def.valueType, v) + newObj[key as any] = val + } + + return newObj + } + + return value + } + + case 'map': { + const map = schema as $ZodMap + + if (value === undefined) { + return new Map() + } + + if (Array.isArray(value) && value.every(i => Array.isArray(i) && i.length <= 2)) { + return new Map( + value.map(([k, v]) => [ + this.#coerce(map._zod.def.keyType, k), + this.#coerce(map._zod.def.valueType, v), + ]), + ) + } + + if (value instanceof Map) { + return new Map( + Array.from(value).map(([k, v]) => [ + this.#coerce(map._zod.def.keyType, k), + this.#coerce(map._zod.def.valueType, v), + ]), + ) + } + + return value + } + + case 'union': { + const union = schema as $ZodUnion + + if (union._zod.def.options.length === 1) { + return this.#coerce(union._zod.def.options[0]!, value) + } + + // support discriminated unions + if (isObject(value)) { + for (const option of union._zod.def.options) { + if (option._zod.disc && this.#matchDiscriminators(value, option._zod.disc)) { + return this.#coerce(option, value) + } + } + } + + return value + } + + case 'intersection': { + const intersection = schema as $ZodIntersection + + return this.#coerce( + intersection._zod.def.right, + this.#coerce(intersection._zod.def.left, value), + ) + } + + case 'optional': { + const optional = schema as $ZodOptional + + if (value === undefined) { + return undefined + } + + return this.#coerce(optional._zod.def.innerType, value) + } + + case 'nonoptional': { + const nonoptional = schema as $ZodNonOptional + return this.#coerce(nonoptional._zod.def.innerType, value) + } + + case 'nullable': { + const nullable = schema as $ZodNullable + + if (value === null) { + return null + } + + return this.#coerce(nullable._zod.def.innerType, value) + } + + case 'readonly': { + const readonly_ = schema as $ZodReadonly + return this.#coerce(readonly_._zod.def.innerType, value) + } + + case 'pipe': { + const pipe = schema as $ZodPipe + return this.#coerce(pipe._zod.def.in, value) + } + + case 'default': + case 'catch': { + const default_ = schema as $ZodDefault | $ZodCatch + return this.#coerce(default_._zod.def.innerType, value) + } + + case 'lazy': { + const lazy = schema as $ZodLazy + + // Prevent infinite loop + if (value !== undefined) { + return this.#coerce(lazy._zod.def.getter(), value) + } + + return value + } + + default: { + const _unsupported: + | 'null' + | 'nan' + | 'transform' + | 'void' + | 'never' + | 'any' + | 'unknown' + | 'file' + | 'undefined' + | 'string' + | 'template_literal' + | 'int' + | 'symbol' + | 'promise' + | 'custom' + = schema._zod.def.type + + return value + } + } + } + + #stringToNumber(value: string): number | string { + const num = Number(value) + return Number.isNaN(num) || num.toString() !== value ? value : num + } + + #stringToBigInt(value: string): bigint | string { + return guard(() => BigInt(value)) ?? value + } + + #stringToBoolean(value: string): boolean | string { + const lower = value.toLowerCase() + + if (lower === 'false' || lower === 'off' || lower === 'f') { + return false + } + + if (lower === 'true' || lower === 'on' || lower === 't') { + return true + } + + return value + } + + #stringToDate(value: string): Date | string { + const date = new Date(value) + + if (!Number.isNaN(date.getTime()) && date.toISOString().startsWith(value)) { + return date + } + + return value + } + + /** + * This function is inspired from Zod, because it's not exported + * https://github.com/colinhacks/zod/blob/v4/packages/core/src/schemas.ts#L1903C1-L1921C2 + */ + #matchDiscriminators(input: Record, discs: util.DiscriminatorMap): boolean { + for (const [key, value] of discs) { + const data = input[key] + + if (value.values.size && !value.values.has(data as any)) { + return false + } + + if (value.maps.length === 0) { + continue + } + + if (!isObject(data)) { + return false + } + + for (const m of value.maps) { + if (!this.#matchDiscriminators(data, m)) { + return false + } + } + } + + return true + } +} diff --git a/packages/zod/src/zod4/converter.combination.test.ts b/packages/zod/src/zod4/converter.combination.test.ts new file mode 100644 index 000000000..dc8907426 --- /dev/null +++ b/packages/zod/src/zod4/converter.combination.test.ts @@ -0,0 +1,35 @@ +import z from 'zod4' +import { testSchemaConverter } from '../../tests/shared' + +testSchemaConverter([ + { + name: 'union([z.string(), z.number()])', + schema: z.union([z.string(), z.number()]), + input: [true, { anyOf: [{ type: 'string' }, { type: 'number' }] }], + }, + { + name: 'union([z.string(), z.number().optional()])', + schema: z.union([z.string(), z.number().optional()]), + input: [false, { anyOf: [{ type: 'string' }, { type: 'number' }] }], + }, + { + name: 'union([z.string(), z.undefined()])', + schema: z.union([z.string(), z.undefined()]), + input: [false, { type: 'string' }], + }, + { + name: 'intersection(z.string(), z.number())', + schema: z.intersection(z.string(), z.number()), + input: [true, { allOf: [{ type: 'string' }, { type: 'number' }] }], + }, + { + name: 'intersection(z.string().optional(), z.number().optional())', + schema: z.intersection(z.string().optional(), z.number().optional()), + input: [false, { allOf: [{ type: 'string' }, { type: 'number' }] }], + }, + { + name: 'intersection(z.string().optional(), z.number().optional())', + schema: z.intersection(z.string().optional(), z.number().optional()), + input: [false, { allOf: [{ type: 'string' }, { type: 'number' }] }], + }, +]) diff --git a/packages/zod/src/zod4/converter.meta.test.ts b/packages/zod/src/zod4/converter.meta.test.ts new file mode 100644 index 000000000..8ffad9d52 --- /dev/null +++ b/packages/zod/src/zod4/converter.meta.test.ts @@ -0,0 +1,77 @@ +import * as z from 'zod4' +import { testSchemaConverter } from '../../tests/shared' +import { + experimental_JSON_SCHEMA_INPUT_REGISTRY as JSON_SCHEMA_INPUT_REGISTRY, + experimental_JSON_SCHEMA_OUTPUT_REGISTRY as JSON_SCHEMA_OUTPUT_REGISTRY, + experimental_JSON_SCHEMA_REGISTRY as JSON_SCHEMA_REGISTRY, +} from './registries' + +const customSchema1 = z.string().meta({ + description: 'description', + examples: ['a', 'b'], +}) + +const customSchema2 = z.object({ + value: z.string(), +}) + +JSON_SCHEMA_REGISTRY.add(customSchema2, { + examples: [{ value: 'a' }], + description: 'objectSchema', +}) + +const customSchema3 = z.number().transform(v => v.toString()).pipe(z.string()) + +JSON_SCHEMA_REGISTRY.add(customSchema3, { + description: 'JSON_SCHEMA_REGISTRY', +}) + +JSON_SCHEMA_INPUT_REGISTRY.add(customSchema3, { + description: 'JSON_SCHEMA_INPUT_REGISTRY', + examples: [1], +}) + +JSON_SCHEMA_OUTPUT_REGISTRY.add(customSchema3, { + description: 'JSON_SCHEMA_OUTPUT_REGISTRY', + examples: ['1'], +}) + +testSchemaConverter([ + { + name: 'customSchema1', + schema: customSchema1, + input: [true, { type: 'string', description: 'description', examples: ['a', 'b'] }], + }, + { + name: 'customSchema2', + schema: customSchema2, + input: [true, { + type: 'object', + properties: { value: { type: 'string' } }, + required: ['value'], + description: 'objectSchema', + examples: [{ value: 'a' }], + }], + }, + { + name: 'customSchema3', + schema: customSchema3, + input: [true, { type: 'number', description: 'JSON_SCHEMA_INPUT_REGISTRY', examples: [1] }], + output: [true, { type: 'string', description: 'JSON_SCHEMA_OUTPUT_REGISTRY', examples: ['1'] }], + }, + { + name: 'string.default("a")', + schema: z.string().default('a'), + input: [false, { default: 'a', type: 'string' }], + }, + { + name: 'string.catch("a")', + schema: z.string().catch('a'), + input: [false, { type: 'string' }], + }, + { + name: 'string.readonly()', + schema: z.string().readonly(), + input: [true, { type: 'string', readOnly: true }], + }, +]) diff --git a/packages/zod/src/zod4/converter.native.test.ts b/packages/zod/src/zod4/converter.native.test.ts new file mode 100644 index 000000000..9d10d18aa --- /dev/null +++ b/packages/zod/src/zod4/converter.native.test.ts @@ -0,0 +1,120 @@ +import * as z from 'zod4' +import { testSchemaConverter } from '../../tests/shared' + +enum ExampleEnum { + A = 'a', + B = 'b', +} + +testSchemaConverter([ + { + name: 'boolean', + schema: z.boolean(), + input: [true, { type: 'boolean' }], + }, + { + name: 'success(z.boolean())', + schema: z.success(z.boolean()), + input: [true, { type: 'boolean' }], + }, + { + name: 'date', + schema: z.date(), + input: [true, { type: 'string', format: 'date-time' }], + }, + { + name: 'null', + schema: z.null(), + input: [true, { type: 'null' }], + }, + { + name: 'any', + schema: z.any(), + input: [false, { }], + }, + { + name: 'unknown', + schema: z.unknown(), + input: [false, {}], + }, + { + name: 'undefined', + schema: z.undefined(), + input: [false, { not: {} }], + }, + { + name: 'string.optional()', + schema: z.string().optional(), + input: [false, { type: 'string' }], + }, + { + name: 'string.optional()', + schema: z.string().optional().nonoptional(), + input: [true, { type: 'string' }], + }, + { + name: 'string.nullable()', + schema: z.string().nullable(), + input: [true, { anyOf: [{ type: 'string' }, { type: 'null' }] }], + }, + { + name: 'void', + schema: z.void(), + input: [false, { not: {} }], + }, + { + name: 'never', + schema: z.never(), + input: [true, { not: {} }], + }, + { + name: 'literal(undefined)', + schema: z.literal(undefined), + input: [false, { not: {} }], + }, + { + name: 'literal(1234)', + schema: z.literal(1234), + input: [true, { const: 1234 }], + }, + { + name: 'literal([1234, 1234n, "abc"])', + schema: z.literal([1234, 1234n, 'abc']), + input: [true, { enum: [1234, '1234', 'abc'] }], + }, + { + name: 'literal([undefined, 1234, 1234n, "abc"])', + schema: z.literal([undefined, 1234, 1234n, 'abc']), + input: [false, { enum: [1234, '1234', 'abc'] }], + }, + { + name: 'enum(["a", "b"])', + schema: z.enum(['a', 'b']), + input: [true, { enum: ['a', 'b'] }], + }, + { + name: 'enum(ExampleEnum)', + schema: z.enum(ExampleEnum), + input: [true, { enum: ['a', 'b'] }], + }, + { + name: 'file().mime(["*/*"])', + schema: z.file(), + input: [true, { type: 'string', contentMediaType: '*/*' }], + }, + { + name: 'file().mime(["image/png"])', + schema: z.file().mime(['image/png']), + input: [true, { type: 'string', contentMediaType: 'image/png' }], + }, + { + name: 'file().mime(["image/png", "image/jpeg"])', + schema: z.file().mime(['image/png', 'image/jpeg']), + input: [true, { + anyOf: [ + { type: 'string', contentMediaType: 'image/png' }, + { type: 'string', contentMediaType: 'image/jpeg' }, + ], + }], + }, +]) diff --git a/packages/zod/src/zod4/converter.number.test.ts b/packages/zod/src/zod4/converter.number.test.ts new file mode 100644 index 000000000..13bca0758 --- /dev/null +++ b/packages/zod/src/zod4/converter.number.test.ts @@ -0,0 +1,46 @@ +import z from 'zod4' +import { testSchemaConverter } from '../../tests/shared' + +testSchemaConverter([ + { + name: 'number', + schema: z.number(), + input: [true, { type: 'number' }], + }, + { + name: 'number.int()', + schema: z.number().int(), + input: [true, { type: 'integer', maximum: 9007199254740991, minimum: -9007199254740991 }], + }, + { + name: 'int.min(0).max(100)', + schema: z.int().min(0).max(100), + input: [true, { type: 'integer', maximum: 100, minimum: 0 }], + }, + { + name: 'number.min(0).max(100)', + schema: z.number().min(0).max(100), + input: [true, { type: 'number', minimum: 0, maximum: 100 }], + }, + { + name: 'number.multipleOf(5)', + schema: z.number().multipleOf(5), + input: [true, { type: 'number', multipleOf: 5 }], + }, + { + name: 'number.gt(6).lt(10)', + schema: z.number().gt(6).lt(10), + input: [true, { type: 'number', exclusiveMinimum: 6, exclusiveMaximum: 10 }], + }, + { + name: 'bigint', + schema: z.bigint(), + input: [true, { type: 'string', pattern: '^-?[0-9]+$' }], + }, + { + name: 'nan', + schema: z.nan(), + input: [true, { not: {} }], + output: [true, { type: 'null' }], + }, +]) diff --git a/packages/zod/src/zod4/converter.processed.test.ts b/packages/zod/src/zod4/converter.processed.test.ts new file mode 100644 index 000000000..d2458d81d --- /dev/null +++ b/packages/zod/src/zod4/converter.processed.test.ts @@ -0,0 +1,21 @@ +import z from 'zod4' +import { testSchemaConverter } from '../../tests/shared' + +testSchemaConverter([ + { + name: 'lazy(() => z.object({ value: z.string() }))', + schema: z.lazy(() => z.object({ value: z.string() })), + input: [true, { type: 'object', properties: { value: { type: 'string' } }, required: ['value'] }], + }, + { + name: 'lazy(() => z.object({ value: z.lazy(() => z.string()) }))', + schema: z.lazy(() => z.object({ value: z.lazy(() => z.string()) })), + input: [true, { type: 'object', properties: { value: { } } }], + }, + { + name: 'string().transform(x => x)', + schema: z.string().transform(x => x), + input: [true, { type: 'string' }], + output: [false, {}], + }, +]) diff --git a/packages/zod/src/zod4/converter.rest.test.ts b/packages/zod/src/zod4/converter.rest.test.ts new file mode 100644 index 000000000..a8693abf8 --- /dev/null +++ b/packages/zod/src/zod4/converter.rest.test.ts @@ -0,0 +1,20 @@ +import z from 'zod4' +import { testSchemaConverter } from '../../tests/shared' + +testSchemaConverter([ + { + name: 'z.symbol()', + schema: z.symbol(), + input: [true, { not: {} }], + }, + { + name: 'z.promise(z.string())', + schema: z.promise(z.string()), + input: [true, { not: {} }], + }, + { + name: 'z.custom(() => false)', + schema: z.custom(() => false), + input: [true, { not: {} }], + }, +]) diff --git a/packages/zod/src/zod4/converter.string.test.ts b/packages/zod/src/zod4/converter.string.test.ts new file mode 100644 index 000000000..46a535108 --- /dev/null +++ b/packages/zod/src/zod4/converter.string.test.ts @@ -0,0 +1,146 @@ +import z from 'zod4' +import { testSchemaConverter } from '../../tests/shared' + +testSchemaConverter([ + { + name: 'string', + schema: z.string(), + input: [true, { type: 'string' }], + }, + { + name: 'string.min(5).max(10).regex(/^[a-z\\]+$/)', + schema: z.string().min(5).max(10).regex(/^[a-z\\]+$/), + input: [true, { type: 'string', maxLength: 10, minLength: 5, pattern: '^[a-z\\\\]+$' }], + }, + { + name: 'base64', + schema: z.base64(), + input: [true, { type: 'string', contentEncoding: 'base64' }], + }, + { + name: 'cuid', + schema: z.cuid(), + input: [true, { type: 'string', pattern: '^[cC][^\\s-]{8,}$' }], + }, + { + name: 'email', + schema: z.email(), + input: [true, { type: 'string', format: 'email' }], + }, + { + name: 'url', + schema: z.url(), + input: [true, { type: 'string', format: 'uri' }], + }, + { + name: 'uuid', + schema: z.uuid(), + input: [true, { type: 'string', format: 'uuid' }], + }, + { + name: 'string.length(6)', + schema: z.string().length(6), + input: [true, { type: 'string', minLength: 6, maxLength: 6 }], + }, + { + name: 'string.includes("a\\")', + schema: z.string().includes('a\\'), + input: [true, { type: 'string', pattern: 'a\\\\' }], + }, + { + name: 'string.startsWith("a\\")', + schema: z.string().startsWith('a\\'), + input: [true, { type: 'string', pattern: '^a\\\\.*' }], + }, + { + name: 'string.endsWith("a\\")', + schema: z.string().endsWith('a\\'), + input: [true, { type: 'string', pattern: '.*a\\\\$' }], + }, + { + name: 'emoji', + schema: z.emoji(), + input: [true, { type: 'string', pattern: '^(\\p{Extended_Pictographic}|\\p{Emoji_Component})+$' }], + }, + { + name: 'uuid', + schema: z.uuid(), + input: [true, { type: 'string', format: 'uuid' }], + }, + { + name: 'guid', + schema: z.guid(), + input: [true, { type: 'string', format: 'uuid' }], + }, + { + name: 'nanoid', + schema: z.nanoid(), + input: [true, { type: 'string', pattern: '^[a-zA-Z0-9_-]{21}$' }], + }, + { + name: 'cuid2', + schema: z.cuid2(), + input: [true, { type: 'string', pattern: '^[0-9a-z]+$' }], + }, + { + name: 'ulid', + schema: z.ulid(), + input: [true, { + type: 'string', + pattern: '^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$', + }], + }, + { + name: 'iso.datetime', + schema: z.iso.datetime(), + input: [true, { type: 'string', format: 'date-time' }], + }, + { + name: 'iso.date', + schema: z.iso.date(), + input: [true, { type: 'string', format: 'date' }], + }, + { + name: 'iso.time', + schema: z.iso.time(), + input: [true, { type: 'string', format: 'time' }], + }, + { + name: 'iso.duration', + schema: z.iso.duration(), + input: [true, { type: 'string', format: 'duration' }], + }, + { + name: 'ipv4', + schema: z.ipv4(), + input: [true, { type: 'string', format: 'ipv4' }], + }, + { + name: 'ipv6', + schema: z.ipv6(), + input: [true, { + type: 'string', + format: 'ipv6', + }], + }, + { + name: 'jwt', + schema: z.jwt(), + input: [true, { type: 'string', pattern: '^[\\w-]+\\.[\\w-]+\\.[\\w-]+$' }], + }, + { + name: 'base64url', + schema: z.base64url(), + input: [true, { type: 'string', pattern: '^([0-9a-zA-Z-_]{4})*(([0-9a-zA-Z-_]{2}(==)?)|([0-9a-zA-Z-_]{3}(=)?))?$' }], + }, + { + name: 'string.trim()', + schema: z.string().trim(), + input: [true, { type: 'string' }], + }, + { + name: 'templateLiteral(z.number(), z.enum(["px", "em", "rem", "%"]))', + schema: z.templateLiteral([z.number(), z.enum(['px', 'em', 'rem', '%'])]) as any, + input: [true, { type: 'string', pattern: '^-?\\d+(?:\\.\\d+)?(px|em|rem|%)$' }], + }, +]) diff --git a/packages/zod/src/zod4/converter.structure.test.ts b/packages/zod/src/zod4/converter.structure.test.ts new file mode 100644 index 000000000..7c8cccbc4 --- /dev/null +++ b/packages/zod/src/zod4/converter.structure.test.ts @@ -0,0 +1,115 @@ +import * as zm from '@zod/mini' +import z from 'zod4' +import { testSchemaConverter } from '../../tests/shared' + +testSchemaConverter([ + { + name: 'array(z.string())', + schema: z.array(z.string()), + input: [true, { type: 'array', items: { type: 'string' } }], + }, + { + name: 'array(z.string()).nonempty()', + schema: z.array(z.string()).nonempty(), + input: [true, { type: 'array', items: { type: 'string' }, minItems: 1 }], + }, + { + name: 'array(z.string()).min(10).max(20)', + schema: z.array(z.string()).min(10).max(20), + input: [true, { type: 'array', items: { type: 'string' }, minItems: 10, maxItems: 20 }], + }, + { + name: 'array(z.string()).length(10)', + schema: z.array(z.string()).length(10), + input: [true, { type: 'array', items: { type: 'string' }, minItems: 10, maxItems: 10 }], + }, + { + name: 'array(z.string().optional())', + schema: z.array(z.string().optional()), + input: [true, { type: 'array', items: { type: 'string' } }], + output: [true, { type: 'array', items: { anyOf: [{ type: 'string' }, { type: 'null' }] } }], + }, + { + name: 'array(z.undefined())', + schema: z.array(z.undefined()), + input: [true, { type: 'array', items: { not: {} } }], + output: [true, { type: 'array', items: { type: 'null' } }], + }, + { + name: 'tuple([z.enum(["a", "b"])])', + schema: z.tuple([z.enum(['a', 'b'])]), + input: [true, { type: 'array', prefixItems: [{ enum: ['a', 'b'] }] }], + }, + { + name: 'tuple([z.enum(["a", "b"])], z.string())', + schema: z.tuple([z.enum(['a', 'b'])], z.string()), + input: [true, { type: 'array', prefixItems: [{ enum: ['a', 'b'] }], items: { type: 'string' } }], + }, + { + name: 'zm.tuple([zm.enum(["a", "b"])], zm.string()).check(zm.minLength(4), zm.maxLength(10))', + schema: zm.tuple([zm.enum(['a', 'b'])], zm.string()).check(zm.minLength(4), zm.maxLength(10)), + input: [true, { type: 'array', prefixItems: [{ enum: ['a', 'b'] }], items: { type: 'string' }, minItems: 4, maxItems: 10 }], + }, + { + name: 'set(z.string())', + schema: z.set(z.string()), + input: [true, { type: 'array', uniqueItems: true, items: { type: 'string' } }], + }, + { + name: 'set(z.string().optional())', + schema: z.set(z.string().optional()), + input: [true, { type: 'array', uniqueItems: true, items: { type: 'string' } }], + output: [true, { type: 'array', uniqueItems: true, items: { anyOf: [{ type: 'string' }, { type: 'null' }] } }], + }, + { + name: 'object({ value: z.string() })', + schema: z.object({ value: z.string() }), + input: [true, { type: 'object', properties: { value: { type: 'string' } }, required: ['value'] }], + }, + { + name: 'object({ value: z.string().optional() })', + schema: z.object({ value: z.string().optional() }), + input: [true, { type: 'object', properties: { value: { type: 'string' } } }], + }, + { + name: 'object({ value: z.undefined() })', + schema: z.object({ value: z.undefined() }), + input: [true, { type: 'object', properties: { value: { not: {} } } }], + }, + { + name: 'object({ value: z.string() }).strict()', + schema: z.object({ value: z.string() }).strict(), + input: [true, { type: 'object', properties: { value: { type: 'string' } }, required: ['value'], additionalProperties: false }], + }, + { + name: 'object({ value: z.string() }).catchall(z.number())', + schema: z.object({ value: z.string() }).catchall(z.number()), + input: [true, { type: 'object', properties: { value: { type: 'string' } }, required: ['value'], additionalProperties: { type: 'number' } }], + }, + { + name: 'record(z.number(), z.string())', + schema: z.record(z.number(), z.string()), + input: [true, { type: 'object', additionalProperties: { type: 'string' }, propertyNames: { type: 'number' } }], + }, + { + name: 'record(z.iso.date(), z.string())', + schema: z.record(z.iso.date(), z.string()), + input: [true, { type: 'object', additionalProperties: { type: 'string' }, propertyNames: { type: 'string', format: 'date' } }], + }, + { + name: 'record(z.string(), z.number().optional())', + schema: z.record(z.string(), z.number().optional()), + input: [true, { type: 'object', additionalProperties: { type: 'number' }, propertyNames: { type: 'string' } }], + }, + { + name: 'map(z.string(), z.number())', + schema: z.map(z.string(), z.number()), + input: [true, { type: 'array', items: { type: 'array', prefixItems: [{ type: 'string' }, { type: 'number' }], maxItems: 2, minItems: 2 } }], + }, + { + name: 'map(z.string().optional(), z.number().optional())', + schema: z.map(z.string().optional(), z.number().optional()), + input: [true, { type: 'array', items: { type: 'array', prefixItems: [{ type: 'string' }, { type: 'number' }], maxItems: 2, minItems: 2 } }], + output: [true, { type: 'array', items: { type: 'array', prefixItems: [{ anyOf: [{ type: 'string' }, { type: 'null' }] }, { anyOf: [{ type: 'number' }, { type: 'null' }] }], maxItems: 2, minItems: 2 } }], + }, +]) diff --git a/packages/zod/src/zod4/converter.test.ts b/packages/zod/src/zod4/converter.test.ts new file mode 100644 index 000000000..12ed06ecf --- /dev/null +++ b/packages/zod/src/zod4/converter.test.ts @@ -0,0 +1,42 @@ +import * as zm from '@zod/mini' +import * as z from 'zod4' +import { + experimental_ZodToJsonSchemaConverter as ZodToJsonSchemaConverter, +} from './converter' + +describe('zodToJsonSchemaConverter', () => { + const converter = new ZodToJsonSchemaConverter() + + it('.condition', async () => { + expect(converter.condition(z.string())).toBe(true) + expect(converter.condition(z.string().optional())).toBe(true) + + const v = await import('valibot') + + expect(converter.condition(v.string())).toBe(false) + }) + + it('@zod/mini', async () => { + const schema = zm.object({ + value: zm.string().check(zm.minLength(5), zm.maxLength(10), zm.regex(/^[a-z\\]+$/)), + }) + + expect(converter.condition(schema)).toBe(true) + + const [required, json] = await converter.convert(schema, { strategy: 'input' }) + + expect(required).toEqual(true) + expect(json).toEqual({ + type: 'object', + properties: { + value: { + type: 'string', + maxLength: 10, + minLength: 5, + pattern: '^[a-z\\\\]+$', + }, + }, + required: ['value'], + }) + }) +}) diff --git a/packages/zod/src/zod4/converter.ts b/packages/zod/src/zod4/converter.ts new file mode 100644 index 000000000..44de01057 --- /dev/null +++ b/packages/zod/src/zod4/converter.ts @@ -0,0 +1,604 @@ +import type { AnySchema } from '@orpc/contract' +import type { ConditionalSchemaConverter, JSONSchema, SchemaConvertOptions } from '@orpc/openapi' +import type { Interceptor, Promisable, ThrowableError } from '@orpc/shared' +import type { + $ZodArray, + $ZodCatch, + $ZodDefault, + $ZodEnum, + $ZodFile, + $ZodIntersection, + $ZodLazy, + $ZodLiteral, + $ZodMap, + $ZodNonOptional, + $ZodNullable, + $ZodNumber, + $ZodNumberFormats, + $ZodObject, + $ZodOptional, + $ZodPipe, + $ZodReadonly, + $ZodRecord, + $ZodSet, + $ZodString, + $ZodStringFormats, + $ZodTemplateLiteral, + $ZodTuple, + $ZodType, + $ZodUnion, +} from '@zod/core' +import { JSONSchemaContentEncoding, JSONSchemaFormat } from '@orpc/openapi' +import { intercept } from '@orpc/shared' +import { + globalRegistry, +} from '@zod/core' +import { + experimental_JSON_SCHEMA_INPUT_REGISTRY as JSON_SCHEMA_INPUT_REGISTRY, + experimental_JSON_SCHEMA_OUTPUT_REGISTRY as JSON_SCHEMA_OUTPUT_REGISTRY, + experimental_JSON_SCHEMA_REGISTRY as JSON_SCHEMA_REGISTRY, +} from './registries' + +export interface experimental_ZodToJsonSchemaOptions { + /** + * Max depth of lazy type, if it exceeds. + * + * Used anyJsonSchema (`{}`) when reach max depth + * + * @default 2 + */ + maxLazyDepth?: number + + /** + * The schema to be used to represent the any | unknown type. + * + * @default { } + */ + anyJsonSchema?: Exclude + + /** + * The schema to be used when the Zod schema is unsupported. + * + * @default { not: {} } + */ + unsupportedJsonSchema?: Exclude + + /** + * The schema to be used to represent the undefined type. + * + * @default { not: {} } + */ + undefinedJsonSchema?: Exclude + + interceptors?: Interceptor< + { schema: $ZodType, options: SchemaConvertOptions, lazyDepth: number, isHandledCustomJSONSchema: boolean }, + [required: boolean, jsonSchema: Exclude], + ThrowableError + >[] +} + +export class experimental_ZodToJsonSchemaConverter implements ConditionalSchemaConverter { + private readonly maxLazyDepth: Exclude + private readonly anyJsonSchema: Exclude + private readonly unsupportedJsonSchema: Exclude + private readonly undefinedJsonSchema: Exclude + private readonly interceptors: Exclude + + constructor(options: experimental_ZodToJsonSchemaOptions = {}) { + this.maxLazyDepth = options.maxLazyDepth ?? 2 + this.anyJsonSchema = options.anyJsonSchema ?? {} + this.unsupportedJsonSchema = options.unsupportedJsonSchema ?? { not: {} } + this.undefinedJsonSchema = options.undefinedJsonSchema ?? { not: {} } + this.interceptors = options.interceptors ?? [] + } + + condition(schema: AnySchema | undefined): boolean { + return schema !== undefined && schema['~standard'].vendor === 'zod' + } + + convert(schema: AnySchema | undefined, options: SchemaConvertOptions): Promisable<[required: boolean, jsonSchema: Exclude]> { + return this.#convert(schema as $ZodType, options, 0) + } + + #convert( + schema: $ZodType, + options: SchemaConvertOptions, + lazyDepth: number, + isHandledCustomJSONSchema: boolean = false, + ): Promise<[required: boolean, jsonSchema: Exclude]> { + return intercept( + this.interceptors, + { schema, options, lazyDepth, isHandledCustomJSONSchema }, + async ({ schema, options, lazyDepth, isHandledCustomJSONSchema }) => { + if (!isHandledCustomJSONSchema) { + const customJSONSchema = this.#getCustomJsonSchema(schema, options) + + if (customJSONSchema) { + const [required, json] = await this.#convert(schema, options, lazyDepth, true) + + return [required, { ...json, ...customJSONSchema }] + } + } + + switch (schema._zod.def.type) { + case 'string': { + const string = schema as $ZodString + const json: JSONSchema = { type: 'string' } + + const { minimum, maximum, format, pattern, contentEncoding } = string._zod.computed as { + minimum?: number + maximum?: number + format?: $ZodStringFormats + pattern?: RegExp + contentEncoding?: string + } + + if (minimum !== undefined) { + json.minLength = minimum + } + + if (maximum !== undefined) { + json.maxLength = maximum + } + + if (contentEncoding !== undefined) { + json.contentEncoding = this.#handleContentEncoding(contentEncoding) + } + + /** + * JSON Schema's "regex" format means the string _is_ a regex pattern. + * Zod’s regex expects the string _to match_ a pattern. + * These differ, so we ignore the "regex" format here. + */ + if (format !== undefined && format !== 'regex' && json.contentEncoding === undefined) { + json.format = this.#handleStringFormat(format) + } + + if (pattern !== undefined && json.contentEncoding === undefined && json.format === undefined) { + json.pattern = pattern.source + } + + // Add a pattern for JWT if it's missing (acts as a polyfill for Zod v4) + if (format === 'jwt' && json.contentEncoding === undefined && json.format === undefined && json.pattern === undefined) { + json.pattern = /^[\w-]+\.[\w-]+\.[\w-]+$/.source + } + + return [true, json] + } + + case 'number': { + const number = schema as $ZodNumber + const json: JSONSchema = { type: 'number' } + + const { minimum, maximum, format, multipleOf, inclusive } = number._zod.computed as { + minimum?: number + maximum?: number + format?: $ZodNumberFormats + multipleOf?: number + inclusive?: boolean + } + + if (format?.includes('int')) { + json.type = 'integer' + } + + if (minimum !== undefined) { + if (inclusive) { + json.minimum = minimum + } + else { + json.exclusiveMinimum = minimum + } + } + + if (maximum !== undefined) { + if (inclusive) { + json.maximum = maximum + } + else { + json.exclusiveMaximum = maximum + } + } + + if (multipleOf !== undefined) { + json.multipleOf = multipleOf + } + + return [true, json] + } + + case 'boolean': { + return [true, { type: 'boolean' }] + } + + case 'bigint': { + return [true, { type: 'string', pattern: '^-?[0-9]+$' }] + } + + case 'date': { + return [true, { type: 'string', format: JSONSchemaFormat.DateTime }] + } + + case 'null': { + return [true, { type: 'null' }] + } + + case 'undefined': + case 'void': { + return [false, this.undefinedJsonSchema] + } + + case 'any': { + return [false, this.anyJsonSchema] + } + + case 'unknown': { + return [false, this.anyJsonSchema] + } + + case 'never': { + return [true, this.unsupportedJsonSchema] + } + + case 'array': { + const array = schema as $ZodArray + const json: JSONSchema = { type: 'array' } + + const { minimum, maximum } = array._zod.computed as { + minimum?: number + maximum?: number + } + + if (minimum !== undefined) { + json.minItems = minimum + } + + if (maximum !== undefined) { + json.maxItems = maximum + } + + json.items = this.#handleArrayItemJsonSchema(await this.#convert(array._zod.def.element, options, lazyDepth), options) + + return [true, json] + } + + case 'object': { + const object = schema as $ZodObject + const json: JSONSchema & { required?: string[] } = { type: 'object' } + + for (const [key, value] of Object.entries(object._zod.def.shape)) { + const [itemRequired, itemJson] = await this.#convert(value, options, lazyDepth) + + json.properties ??= {} + json.properties[key] = itemJson + + if (itemRequired) { + json.required ??= [] + json.required.push(key) + } + } + + if (object._zod.def.catchall) { + if (object._zod.def.catchall._zod.def.type === 'never') { + json.additionalProperties = false + } + else { + const [_, addJson] = await this.#convert(object._zod.def.catchall, options, lazyDepth) + json.additionalProperties = addJson + } + } + + return [true, json] + } + + case 'union': { + const union = schema as $ZodUnion + const anyOf: Exclude[] = [] + + let required = true + + for (const item of union._zod.def.options) { + const [itemRequired, itemJson] = await this.#convert(item, options, lazyDepth) + + if (!itemRequired) { + required = false + } + + if (options.strategy === 'input') { + if (itemJson !== this.undefinedJsonSchema && itemJson !== this.unsupportedJsonSchema) { + anyOf.push(itemJson) + } + } + else { + if (itemJson !== this.undefinedJsonSchema) { + anyOf.push(itemJson) + } + } + } + + return [required, anyOf.length === 1 ? anyOf[0]! : { anyOf }] + } + + case 'intersection': { + const intersection = schema as $ZodIntersection + const json: JSONSchema & { allOf: Exclude[] } = { allOf: [] } + + let required = false + + for (const item of [intersection._zod.def.left, intersection._zod.def.right]) { + const [itemRequired, itemJson] = await this.#convert(item, options, lazyDepth) + + json.allOf.push(itemJson) + + if (itemRequired) { + required = true + } + } + + return [required, json] + } + + case 'tuple': { + const tuple = schema as $ZodTuple + const json: JSONSchema & { prefixItems: JSONSchema[] } = { type: 'array', prefixItems: [] } + + for (const item of tuple._zod.def.items) { + json.prefixItems.push(this.#handleArrayItemJsonSchema(await this.#convert(item, options, lazyDepth), options)) + } + + if (tuple._zod.def.rest) { + json.items = this.#handleArrayItemJsonSchema(await this.#convert(tuple._zod.def.rest, options, lazyDepth), options) + } + + const { minimum, maximum } = tuple._zod.computed as { + minimum?: number + maximum?: number + } + + if (minimum !== undefined) { + json.minItems = minimum + } + + if (maximum !== undefined) { + json.maxItems = maximum + } + + return [true, json] + } + + case 'record': { + const record = schema as $ZodRecord + const json: JSONSchema = { type: 'object' } + + json.propertyNames = (await this.#convert(record._zod.def.keyType, options, lazyDepth))[1] + json.additionalProperties = (await this.#convert(record._zod.def.valueType, options, lazyDepth))[1] + + return [true, json] + } + + case 'map': { + const map = schema as $ZodMap + + return [true, { + type: 'array', + items: { + type: 'array', + prefixItems: [ + this.#handleArrayItemJsonSchema(await this.#convert(map._zod.def.keyType, options, lazyDepth), options), + this.#handleArrayItemJsonSchema(await this.#convert(map._zod.def.valueType, options, lazyDepth), options), + ], + maxItems: 2, + minItems: 2, + }, + }] + } + + case 'set': { + const set = schema as $ZodSet + return [true, { + type: 'array', + uniqueItems: true, + items: this.#handleArrayItemJsonSchema(await this.#convert(set._zod.def.valueType, options, lazyDepth), options), + }] + } + + case 'enum': { + const enum_ = schema as $ZodEnum + return [true, { enum: Object.values(enum_._zod.def.entries) }] + } + + case 'literal': { + const literal = schema as $ZodLiteral + + let required = true + const values = new Set() + + for (const value of literal._zod.def.values) { + if (value === undefined) { + required = false + } + else { + values.add(typeof value === 'bigint' ? value.toString() : value) + } + } + + const json: JSONSchema = values.size === 0 + ? this.undefinedJsonSchema + : values.size === 1 + ? { const: values.values().next().value } + : { enum: Array.from(values) } + + return [required, json] + } + + case 'file': { + const file = schema as $ZodFile + const oneOf: Exclude[] = [] + + const { mime } = file._zod.computed as { + mime?: string[] + minimum?: number // WARN: ignore + maximum?: number // WARN: ignore + } + + for (const type of mime ?? ['*/*']) { + oneOf.push({ + type: 'string', + contentMediaType: type, + }) + } + + return [true, oneOf.length === 1 ? oneOf[0]! : { anyOf: oneOf }] + } + + case 'transform': { + return [false, this.anyJsonSchema] + } + + case 'nullable': { + const nullable = schema as $ZodNullable + + const [required, json] = await this.#convert(nullable._zod.def.innerType, options, lazyDepth) + + return [required, { anyOf: [json, { type: 'null' }] }] + } + + case 'nonoptional': { + const nonoptional = schema as $ZodNonOptional + const [, json] = await this.#convert(nonoptional._zod.def.innerType, options, lazyDepth) + return [true, json] + } + + case 'success': { + return [true, { type: 'boolean' }] + } + + case 'default': { + const default_ = schema as $ZodDefault + const [, json] = await this.#convert(default_._zod.def.innerType, options, lazyDepth) + + return [false, { + ...json, + default: default_._zod.def.defaultValue(), + }] + } + + case 'catch': { + const catch_ = schema as $ZodCatch + const [,json] = await this.#convert(catch_._zod.def.innerType, options, lazyDepth) + return [false, json] + } + + case 'nan': { + return [true, options.strategy === 'input' ? this.unsupportedJsonSchema : { type: 'null' }] + } + + case 'pipe': { + const pipe = schema as $ZodPipe + return await this.#convert(options.strategy === 'input' ? pipe._zod.def.in : pipe._zod.def.out, options, lazyDepth) + } + + case 'readonly': { + const readonly_ = schema as $ZodReadonly + const [required, json] = await this.#convert(readonly_._zod.def.innerType, options, lazyDepth) + return [required, { ...json, readOnly: true }] + } + + case 'template_literal': { + const templateLiteral = schema as $ZodTemplateLiteral + + return [true, { + type: 'string', + pattern: templateLiteral._zod.pattern.source, + }] + } + + case 'optional': { + const optional = schema as $ZodOptional + const [, json] = await this.#convert(optional._zod.def.innerType, options, lazyDepth) + return [false, json] + } + + case 'lazy': { + const lazy = schema as $ZodLazy + + if (lazyDepth >= this.maxLazyDepth) { + return [false, this.anyJsonSchema] + } + + return await this.#convert(lazy._zod.def.getter(), options, lazyDepth + 1) + } + + default: { + const _unsupported: 'interface' | 'int' | 'symbol' | 'promise' | 'custom' = schema._zod.def.type + return [true, this.unsupportedJsonSchema] + } + } + }, + ) + } + + #getCustomJsonSchema(schema: $ZodType, options: SchemaConvertOptions): Exclude | undefined { + if (options.strategy === 'input' && JSON_SCHEMA_INPUT_REGISTRY.has(schema)) { + return JSON_SCHEMA_INPUT_REGISTRY.get(schema) as Exclude | undefined + } + + if (options.strategy === 'output' && JSON_SCHEMA_OUTPUT_REGISTRY.has(schema)) { + return JSON_SCHEMA_OUTPUT_REGISTRY.get(schema) as Exclude | undefined + } + + if (JSON_SCHEMA_REGISTRY.has(schema)) { + return JSON_SCHEMA_REGISTRY.get(schema) as Exclude | undefined + } + + const global = globalRegistry.get(schema) + + if (global) { + return { + description: global.description, + examples: global.examples, + } + } + } + + #handleArrayItemJsonSchema([required, schema]: [required: boolean, jsonSchema: Exclude], options: SchemaConvertOptions): Exclude { + if (required || options.strategy === 'input') { + return schema + } + + if (schema === this.undefinedJsonSchema) { + return { type: 'null' } + } + + return { + anyOf: [ // schema can contain { type: 'null' } so we should use anyOf instead of oneOf + schema, + { type: 'null' }, + ], + } + } + + #handleStringFormat(format: string): string | undefined { + if (format === 'guid') { + return JSONSchemaFormat.UUID + } + + if (format === 'url') { + return JSONSchemaFormat.URI + } + + if (format === 'datetime') { + return JSONSchemaFormat.DateTime + } + + return Object.values(JSONSchemaFormat).includes(format as any) + ? format + : undefined + } + + #handleContentEncoding(contentEncoding: string): Exclude['contentEncoding'] | undefined { + return Object.values(JSONSchemaContentEncoding).includes(contentEncoding as any) + ? contentEncoding as any + : undefined + } +} diff --git a/packages/zod/src/zod4/index.ts b/packages/zod/src/zod4/index.ts new file mode 100644 index 000000000..f38d05eee --- /dev/null +++ b/packages/zod/src/zod4/index.ts @@ -0,0 +1,3 @@ +export * from './coercer' +export * from './converter' +export * from './registries' diff --git a/packages/zod/src/zod4/registries.ts b/packages/zod/src/zod4/registries.ts new file mode 100644 index 000000000..53e0e77eb --- /dev/null +++ b/packages/zod/src/zod4/registries.ts @@ -0,0 +1,60 @@ +import type { JSONSchema } from '@orpc/openapi' +import type { $input, $output } from '@zod/core' +import { registry } from '@zod/core' + +/** + * Zod registry for customizing generated JSON schema, can use both for .input and .output + * + * @example + * ```ts + * import { JSON_SCHEMA_REGISTRY } from '@orpc/zod/zod4' + * + * const user = z.object({ + * name: z.string(), + * age: z.number(), + * }) + * + * JSON_SCHEMA_REGISTRY.add(user, { + * examples: [{ name: 'John', age: 20 }], + * }) + * ``` + */ +export const experimental_JSON_SCHEMA_REGISTRY = registry, boolean>>() + +/** + * Zod registry for customizing generated JSON schema, only useful for .input + * + * @example + * ```ts + * import { JSON_SCHEMA_INPUT_REGISTRY } from '@orpc/zod/zod4' + * + * const user = z.object({ + * name: z.string(), + * age: z.string().transform(v => Number(v)), + * }) + * + * JSON_SCHEMA_REGISTRY.add(user, { + * examples: [{ name: 'John', age: "20" }], + * }) + * ``` + */ +export const experimental_JSON_SCHEMA_INPUT_REGISTRY = registry, boolean>>() + +/** + * Zod registry for customizing generated JSON schema, only useful for .input + * + * @example + * ```ts + * import { JSON_SCHEMA_OUTPUT_REGISTRY } from '@orpc/zod/zod4' + * + * const user = z.object({ + * name: z.string(), + * age: z.string().transform(v => Number(v)), + * }) + * + * JSON_SCHEMA_OUTPUT_REGISTRY.add(user, { + * examples: [{ name: 'John', age: 20 }], + * }) + * ``` + */ +export const experimental_JSON_SCHEMA_OUTPUT_REGISTRY = registry, boolean>>() diff --git a/packages/zod/tests/shared.ts b/packages/zod/tests/shared.ts new file mode 100644 index 000000000..a059b7ba8 --- /dev/null +++ b/packages/zod/tests/shared.ts @@ -0,0 +1,60 @@ +import type { AnySchema } from '@orpc/contract' +import type { JSONSchema } from '@orpc/openapi' +import { + experimental_ZodSmartCoercionPlugin as ZodSmartCoercionPlugin, + experimental_ZodToJsonSchemaConverter as ZodToJsonSchemaConverter, +} from '../src/zod4' + +export interface SchemaConverterTestCase { + name: string + schema: AnySchema + input: [boolean, JSONSchema] + output?: [boolean, JSONSchema] +} + +export function testSchemaConverter(cases: SchemaConverterTestCase[]) { + const converter = new ZodToJsonSchemaConverter({ maxLazyDepth: 1 }) + describe.each([['input'], ['output']] as const)('ZodToJsonSchemaConverter.converter: strategy = %s', (strategy) => { + it.each(cases)('$name', async ({ schema, input, output = input }) => { + const [expectedRequired, expectedJson] = strategy === 'input' ? input : output + const [required, json] = await converter.convert(schema, { strategy }) + expect(json).toEqual(expectedJson) + expect(required).toEqual(expectedRequired) + }) + }) +} + +export interface SchemaSmartCoercionTestCase { + name: string + schema: AnySchema + input: unknown + expected?: unknown +} + +export function testSchemaSmartCoercion(cases: SchemaSmartCoercionTestCase[]) { + const plugin = new ZodSmartCoercionPlugin() + const options = {} as any + plugin.init(options) + + const coerce = (schema: AnySchema, input: unknown) => { + let coerced: unknown + + options.clientInterceptors[0]({ + procedure: { + '~orpc': { + inputSchema: schema, + }, + }, + input, + next: ({ input }: any) => { + coerced = input + }, + }) + + return coerced + } + + it.each(cases)('$name', ({ schema, input, expected = input }) => { + expect(coerce(schema, input)).toEqual(expected) + }) +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 625ec3d9c..9ad8188d0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -506,13 +506,22 @@ importers: wildcard-match: specifier: ^5.1.3 version: 5.1.4 + devDependencies: + '@zod/core': + specifier: ^0.11.4 + version: 0.11.4 + '@zod/mini': + specifier: ^4.0.0-beta.20250505T012514 + version: 4.0.0-beta.20250505T012514 zod: specifier: ^3.24.2 version: 3.24.2 - devDependencies: zod-to-json-schema: specifier: ^3.24.5 version: 3.24.5(zod@3.24.2) + zod4: + specifier: npm:zod@^4.0.0-beta.20250505T012514 + version: zod@4.0.0-beta.20250505T012514 playgrounds/contract-first: devDependencies: @@ -3139,6 +3148,12 @@ packages: '@vueuse/shared@12.8.2': resolution: {integrity: sha512-dznP38YzxZoNloI0qpEfpkms8knDtaoQ6Y/sfS0L7Yki4zh40LFHEhur0odJC6xTHG5dxWVPiUWBXn+wCG2s5w==} + '@zod/core@0.11.4': + resolution: {integrity: sha512-ezfAaaxgjSXZw9sH5QJ4/uqFmg8PbwBFtdSlzz1OoXWcSUR4fj4meS491+lk9ZGxCymjJ/pbOSu7nzcxvHtG0g==} + + '@zod/mini@4.0.0-beta.20250505T012514': + resolution: {integrity: sha512-BxGk6wZsfi0uJ70Mty7pChMyvawl5qb9KqyvZFez2l/ypI5fPSHZF2sAWKPOd3oM0u3LXPbE3f68dMlLhTGm9A==} + abbrev@2.0.0: resolution: {integrity: sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} @@ -7284,6 +7299,9 @@ packages: zod@3.24.2: resolution: {integrity: sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ==} + zod@4.0.0-beta.20250505T012514: + resolution: {integrity: sha512-b9Oif/j2uIFuimTO3xqTZP71cfNcv49G7sSDF8wp4+MH2tSCDgRDy5RKEMbLtD0LTrmGznL/gYqqDW7U80PudA==} + zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} @@ -9841,6 +9859,12 @@ snapshots: transitivePeerDependencies: - typescript + '@zod/core@0.11.4': {} + + '@zod/mini@4.0.0-beta.20250505T012514': + dependencies: + '@zod/core': 0.11.4 + abbrev@2.0.0: {} abbrev@3.0.1: {} @@ -14732,4 +14756,8 @@ snapshots: zod@3.24.2: {} + zod@4.0.0-beta.20250505T012514: + dependencies: + '@zod/core': 0.11.4 + zwitch@2.0.4: {}