From 9b793aa51e7ff23b106a285e52b526f2b89af60c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 20:44:04 +0000 Subject: [PATCH] feat: Support Zod v3 and Zod v4 schemas Read schema internals from _zod.def (Zod v4) or _def (Zod v3) and normalize Zod v4 type names to Zod v3 style names. Remove the runtime dependency on ZodFirstPartyTypeKind, which Zod v4 no longer exports. Unwrap effects (Zod v3 refine/transform/preprocess) and pipelines (Zod v4 transform) to the schema describing the parser input, so refined and transformed schemas are now parseable instead of throwing UnparseableSchemaError. Applying the effect is left to the schema. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_015jWzL9LC7Q6bTGaXDoKq4z --- package-lock.json | 24 ++++-- package.json | 7 +- src/lib/schema.test.ts | 30 +++++++ src/lib/schema.ts | 47 ++++++---- src/lib/zod.ts | 170 +++++++++++++++++++++++++----------- test/zod-v4.test.ts | 191 +++++++++++++++++++++++++++++++++++++++++ 6 files changed, 395 insertions(+), 74 deletions(-) create mode 100644 test/zod-v4.test.ts diff --git a/package-lock.json b/package-lock.json index 74958d3..111ed36 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,15 +25,16 @@ "tsc-alias": "^1.8.2", "tsup": "^8.0.1", "tsx": "^4.6.2", - "typescript": "~5.3.3", - "zod": "^3.24.2" + "typescript": "~5.8.3", + "zod": "^3.24.2", + "zod-v4": "npm:zod@^4.0.0" }, "engines": { "node": ">=18.12.0", "npm": ">= 9.0.0" }, "peerDependencies": { - "zod": "^3.0.0" + "zod": "^3.0.0 || ^4.0.0" } }, "node_modules/@babel/code-frame": { @@ -8858,9 +8859,9 @@ } }, "node_modules/typescript": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz", - "integrity": "sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==", + "version": "5.8.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", + "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "dev": true, "license": "Apache-2.0", "bin": { @@ -9423,6 +9424,17 @@ "funding": { "url": "https://github.com/sponsors/colinhacks" } + }, + "node_modules/zod-v4": { + "name": "zod", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/package.json b/package.json index 4b3392e..bbf93c4 100644 --- a/package.json +++ b/package.json @@ -67,7 +67,7 @@ "npm": ">= 9.0.0" }, "peerDependencies": { - "zod": "^3.0.0" + "zod": "^3.0.0 || ^4.0.0" }, "devDependencies": { "@seamapi/url-search-params-serializer": "^3.0.0", @@ -86,7 +86,8 @@ "tsc-alias": "^1.8.2", "tsup": "^8.0.1", "tsx": "^4.6.2", - "typescript": "~5.3.3", - "zod": "^3.24.2" + "typescript": "~5.8.3", + "zod": "^3.24.2", + "zod-v4": "npm:zod@^4.0.0" } } diff --git a/src/lib/schema.test.ts b/src/lib/schema.test.ts index 3d84423..1d615e4 100644 --- a/src/lib/schema.test.ts +++ b/src/lib/schema.test.ts @@ -154,6 +154,36 @@ test('nullable schemas', valueType, z.string().nullable(), 'string') test('nullish schemas', valueType, z.number().nullish(), 'number') test('default schemas', valueType, z.number().default(0), 'number') test('readonly schemas', valueType, z.number().readonly(), 'number') +test( + 'refined schemas', + valueType, + z.number().refine((v) => v > 0), + 'number', +) +test( + 'transformed schemas', + valueType, + z.number().transform((v) => String(v)), + 'number', +) +test( + 'preprocessed schemas', + valueType, + z.preprocess((v) => v, z.number()), + 'number', +) +test('piped schemas', valueType, z.number().pipe(z.number().min(0)), 'number') + +test('zodSchemaToParamSchema: parses refined object schemas', (t) => { + t.deepEqual( + zodSchemaToParamSchema( + z.object({ foo: z.string() }).refine((data) => data.foo !== 'a'), + ), + { + foo: 'string', + }, + ) +}) test('string arrays', valueType, z.array(z.string()), 'string_array') test('number arrays', valueType, z.array(z.number()), 'number_array') diff --git a/src/lib/schema.ts b/src/lib/schema.ts index 2de2ce1..f3b4dbd 100644 --- a/src/lib/schema.ts +++ b/src/lib/schema.ts @@ -18,8 +18,8 @@ import { isZodUnion, unwrapZodSchema, zodArrayElementType, - zodLiteralValue, - zodNativeEnumValues, + zodEnumValues, + zodLiteralValues, zodRecordKeyType, zodRecordValueType, zodSchemaName, @@ -376,8 +376,9 @@ const primitiveToValueType = ( if (isZodDate(schema)) return 'date' if (isZodNull(schema)) return 'null' if (isZodNever(schema)) return 'never' - if (isZodEnum(schema)) return 'string' - if (isZodNativeEnum(schema)) return nativeEnumToValueType(schema, path) + if (isZodEnum(schema) || isZodNativeEnum(schema)) { + return enumToValueType(schema, path) + } if (isZodLiteral(schema)) return literalToValueType(schema, path) throw new UnparseableSchemaError( @@ -386,11 +387,8 @@ const primitiveToValueType = ( ) } -const nativeEnumToValueType = ( - schema: ZodTypeAny, - path: string[], -): ValueType => { - const values = zodNativeEnumValues(schema) +const enumToValueType = (schema: ZodTypeAny, path: string[]): ValueType => { + const values = zodEnumValues(schema) const valueTypes = [ ...new Set( @@ -407,21 +405,36 @@ const nativeEnumToValueType = ( throw new UnparseableSchemaError( path, - 'a native enum schema must have only string values or only number values', + 'an enum schema must have only string values or only number values', ) } const literalToValueType = (schema: ZodTypeAny, path: string[]): ValueType => { - const value = zodLiteralValue(schema) - if (value === null) return 'null' - if (typeof value === 'string') return 'string' - if (typeof value === 'number') return 'number' - if (typeof value === 'boolean') return 'boolean' - if (value instanceof Date) return 'date' + const valueTypes = [ + ...new Set( + zodLiteralValues(schema).map((value) => { + if (value === null) return 'null' + if (typeof value === 'string') return 'string' + if (typeof value === 'number') return 'number' + if (typeof value === 'boolean') return 'boolean' + if (value instanceof Date) return 'date' + throw new UnparseableSchemaError( + path, + `a literal schema of type ${typeof value} is not supported`, + ) + }), + ), + ] + + const nonNullTypes = valueTypes.filter((t) => t !== 'null') + + const [first] = nonNullTypes + if (first == null) return 'null' + if (nonNullTypes.length === 1) return first throw new UnparseableSchemaError( path, - `a literal schema of type ${typeof value} is not supported`, + 'a literal schema must have values of a single type', ) } diff --git a/src/lib/zod.ts b/src/lib/zod.ts index 4d1c9c6..605216a 100644 --- a/src/lib/zod.ts +++ b/src/lib/zod.ts @@ -1,20 +1,66 @@ -import { - type ZodArray, - type ZodDiscriminatedUnion, - type ZodEnum, - ZodFirstPartyTypeKind, - type ZodLiteral, - type ZodNativeEnum, - type ZodObject, - type ZodRecord, - type ZodTypeAny, - type ZodUnion, +import type { + ZodArray, + ZodDiscriminatedUnion, + ZodEnum, + ZodLiteral, + ZodNativeEnum, + ZodObject, + ZodRecord, + ZodTypeAny, + ZodUnion, } from 'zod' -type TypeName = `${ZodFirstPartyTypeKind}` +// Zod v3 style type names, e.g., ZodString. +// Zod v4 type names are normalized to this style. +type TypeName = string + +// Internal schema definition, from _def in Zod v3 or _zod.def in Zod v4. +const defOf = (schema: ZodTypeAny): any => + '_zod' in schema ? (schema as any)._zod.def : (schema as any)._def + +// Maps a Zod v4 def type, e.g., string, +// to the corresponding Zod v3 type name, e.g., ZodString. +// In Zod v4, some distinct Zod v3 types collapse into a single type: +// a discriminated union is a union and a native enum is an enum. +const zodV4TypeNames: Record = { + any: 'ZodAny', + array: 'ZodArray', + bigint: 'ZodBigInt', + boolean: 'ZodBoolean', + date: 'ZodDate', + default: 'ZodDefault', + enum: 'ZodEnum', + literal: 'ZodLiteral', + map: 'ZodMap', + never: 'ZodNever', + null: 'ZodNull', + nullable: 'ZodNullable', + number: 'ZodNumber', + object: 'ZodObject', + optional: 'ZodOptional', + pipe: 'ZodPipeline', + readonly: 'ZodReadonly', + record: 'ZodRecord', + set: 'ZodSet', + string: 'ZodString', + symbol: 'ZodSymbol', + tuple: 'ZodTuple', + undefined: 'ZodUndefined', + union: 'ZodUnion', + unknown: 'ZodUnknown', + void: 'ZodVoid', +} const typeNameOf = (schema: ZodTypeAny): string | null => { - const typeName: unknown = schema._def.typeName + if ('_zod' in schema) { + const type: unknown = defOf(schema)?.type + if (typeof type !== 'string') return null + return ( + zodV4TypeNames[type] ?? + `Zod${type.charAt(0).toUpperCase()}${type.slice(1)}` + ) + } + const typeName: unknown = defOf(schema)?.typeName return typeof typeName === 'string' ? typeName : null } @@ -24,10 +70,10 @@ const isTypeName = (schema: ZodTypeAny, typeName: TypeName): boolean => // Wrapper schemas that do not affect how a value is serialized, // and so may be transparently removed before inspecting a schema. const wrapperTypeNames: string[] = [ - ZodFirstPartyTypeKind.ZodOptional, - ZodFirstPartyTypeKind.ZodNullable, - ZodFirstPartyTypeKind.ZodDefault, - ZodFirstPartyTypeKind.ZodReadonly, + 'ZodOptional', + 'ZodNullable', + 'ZodDefault', + 'ZodReadonly', ] export interface UnwrappedZodSchema { @@ -36,8 +82,16 @@ export interface UnwrappedZodSchema { isNullable: boolean } -// Recursively removes optional, nullable, default, and readonly wrappers, +// Recursively removes optional, nullable, default, readonly, +// effects, and pipeline wrappers, // reporting whether the schema was optional or nullable at any level. +// +// Effects and pipelines, e.g., schemas using refine or transform, +// are unwrapped to the schema describing the parser input: +// applying the effect itself is left to the schema. +// In Zod v3, refinements and transforms wrap the schema in a ZodEffects. +// In Zod v4, refinements are checks on the schema itself, +// while transforms create a pipe. export const unwrapZodSchema = (schema: ZodTypeAny): UnwrappedZodSchema => { let current = schema let isOptional = false @@ -46,12 +100,21 @@ export const unwrapZodSchema = (schema: ZodTypeAny): UnwrappedZodSchema => { for (;;) { const typeName = typeNameOf(current) if (typeName == null) break - if (!wrapperTypeNames.includes(typeName)) break - if (typeName === ZodFirstPartyTypeKind.ZodOptional) isOptional = true - if (typeName === ZodFirstPartyTypeKind.ZodNullable) isNullable = true + let innerType: unknown + + if (wrapperTypeNames.includes(typeName)) { + if (typeName === 'ZodOptional') isOptional = true + if (typeName === 'ZodNullable') isNullable = true + innerType = defOf(current)?.innerType + } else if (typeName === 'ZodEffects') { + innerType = defOf(current)?.schema + } else if (typeName === 'ZodPipeline') { + innerType = defOf(current)?.in + } else { + break + } - const innerType: unknown = current._def.innerType if (!isZodSchema(innerType)) break current = innerType } @@ -61,88 +124,99 @@ export const unwrapZodSchema = (schema: ZodTypeAny): UnwrappedZodSchema => { export const isZodObject = ( schema: ZodTypeAny, -): schema is ZodObject => - isTypeName(schema, ZodFirstPartyTypeKind.ZodObject) +): schema is ZodObject => isTypeName(schema, 'ZodObject') export const isZodArray = (schema: ZodTypeAny): schema is ZodArray => - isTypeName(schema, ZodFirstPartyTypeKind.ZodArray) + isTypeName(schema, 'ZodArray') export const isZodRecord = ( schema: ZodTypeAny, -): schema is ZodRecord => - isTypeName(schema, ZodFirstPartyTypeKind.ZodRecord) +): schema is ZodRecord => isTypeName(schema, 'ZodRecord') export const isZodUnion = (schema: ZodTypeAny): schema is ZodUnion => - isTypeName(schema, ZodFirstPartyTypeKind.ZodUnion) + isTypeName(schema, 'ZodUnion') export const isZodDiscriminatedUnion = ( schema: ZodTypeAny, ): schema is ZodDiscriminatedUnion => - isTypeName(schema, ZodFirstPartyTypeKind.ZodDiscriminatedUnion) + isTypeName(schema, 'ZodDiscriminatedUnion') export const isZodLiteral = (schema: ZodTypeAny): schema is ZodLiteral => - isTypeName(schema, ZodFirstPartyTypeKind.ZodLiteral) + isTypeName(schema, 'ZodLiteral') export const isZodEnum = (schema: ZodTypeAny): schema is ZodEnum => - isTypeName(schema, ZodFirstPartyTypeKind.ZodEnum) + isTypeName(schema, 'ZodEnum') export const isZodNativeEnum = ( schema: ZodTypeAny, -): schema is ZodNativeEnum => - isTypeName(schema, ZodFirstPartyTypeKind.ZodNativeEnum) +): schema is ZodNativeEnum => isTypeName(schema, 'ZodNativeEnum') export const isZodString = (schema: ZodTypeAny): boolean => - isTypeName(schema, ZodFirstPartyTypeKind.ZodString) + isTypeName(schema, 'ZodString') export const isZodNumber = (schema: ZodTypeAny): boolean => - isTypeName(schema, ZodFirstPartyTypeKind.ZodNumber) + isTypeName(schema, 'ZodNumber') export const isZodBoolean = (schema: ZodTypeAny): boolean => - isTypeName(schema, ZodFirstPartyTypeKind.ZodBoolean) + isTypeName(schema, 'ZodBoolean') export const isZodDate = (schema: ZodTypeAny): boolean => - isTypeName(schema, ZodFirstPartyTypeKind.ZodDate) + isTypeName(schema, 'ZodDate') export const isZodNull = (schema: ZodTypeAny): boolean => - isTypeName(schema, ZodFirstPartyTypeKind.ZodNull) + isTypeName(schema, 'ZodNull') export const isZodNever = (schema: ZodTypeAny): boolean => - isTypeName(schema, ZodFirstPartyTypeKind.ZodNever) + isTypeName(schema, 'ZodNever') export const isZodSchema = (schema: unknown): schema is ZodTypeAny => { if (schema == null) return false if (typeof schema !== 'object') return false - return '_def' in schema + return '_def' in schema || '_zod' in schema } export const zodUnionOptions = (schema: ZodTypeAny): ZodTypeAny[] => { const options: unknown = isZodDiscriminatedUnion(schema) ? [...schema.options] - : schema._def.options + : defOf(schema)?.options if (!Array.isArray(options)) return [] return options.filter(isZodSchema) } export const zodRecordKeyType = (schema: ZodTypeAny): ZodTypeAny | null => { - const keyType: unknown = schema._def.keyType + const keyType: unknown = defOf(schema)?.keyType return isZodSchema(keyType) ? keyType : null } export const zodRecordValueType = (schema: ZodTypeAny): ZodTypeAny | null => { - const valueType: unknown = schema._def.valueType + const valueType: unknown = defOf(schema)?.valueType return isZodSchema(valueType) ? valueType : null } export const zodArrayElementType = (schema: ZodTypeAny): ZodTypeAny | null => { - const elementType: unknown = schema._def.type + const def = defOf(schema) + // Zod v3 stores the element schema in type, Zod v4 in element. + const elementType: unknown = def?.element ?? def?.type return isZodSchema(elementType) ? elementType : null } -export const zodLiteralValue = (schema: ZodTypeAny): unknown => - schema._def.value +// The literal values of a schema. +// A Zod v3 literal has a single value, a Zod v4 literal may have many. +export const zodLiteralValues = (schema: ZodTypeAny): unknown[] => { + const def = defOf(schema) + if (Array.isArray(def?.values)) return def.values + return [def?.value] +} + +// The values of an enum or native enum schema. +// Zod v3 enums store an array of values, +// while Zod v3 native enums and all Zod v4 enums +// store an object of enum entries. +export const zodEnumValues = (schema: ZodTypeAny): unknown[] => { + const def = defOf(schema) + const values: unknown = def?.values ?? def?.entries -export const zodNativeEnumValues = (schema: ZodTypeAny): unknown[] => { - const values: unknown = schema._def.values + if (Array.isArray(values)) return values if (values == null || typeof values !== 'object') return [] const obj = values as Record diff --git a/test/zod-v4.test.ts b/test/zod-v4.test.ts new file mode 100644 index 0000000..00facf8 --- /dev/null +++ b/test/zod-v4.test.ts @@ -0,0 +1,191 @@ +import test from 'ava' +import type { ZodSchema } from 'zod' +import { z } from 'zod-v4' + +import { + parseUrlSearchParams, + UnparseableSchemaError, + UnparseableSearchParamError, +} from '@seamapi/url-search-params-parser' + +// The parser inspects Zod internals, which changed between Zod v3 and v4. +// These tests mirror the core parsing behavior using schemas built with Zod v4. +const parse = (query: string, schema: unknown): unknown => + parseUrlSearchParams(query, schema as ZodSchema) + +test('zod-v4: parses primitive types', (t) => { + const schema = z.object({ + name: z.string(), + age: z.number(), + isAdmin: z.boolean(), + createdAt: z.date(), + }) + t.deepEqual( + parse( + 'name=Dax&age=27&isAdmin=true&createdAt=2023-01-01T00:00:00.000Z', + schema, + ), + { + name: 'Dax', + age: 27, + isAdmin: true, + createdAt: new Date('2023-01-01T00:00:00.000Z'), + }, + ) +}) + +test('zod-v4: parses optional, nullable, default, and readonly wrappers', (t) => { + const schema = z.object({ + a: z.string().optional(), + b: z.number().nullable(), + c: z.boolean().default(true), + d: z.string().readonly(), + }) + t.deepEqual(parse('b=&c=false&d=x', schema), { + a: undefined, + b: null, + c: false, + d: 'x', + }) +}) + +test('zod-v4: parses arrays in all three formats', (t) => { + const schema = z.object({ foo: z.array(z.string()) }) + t.deepEqual(parse('foo=a&foo=b', schema), { foo: ['a', 'b'] }) + t.deepEqual(parse('foo[]=a&foo[]=b', schema), { foo: ['a', 'b'] }) + t.deepEqual(parse('foo=a,b', schema), { foo: ['a', 'b'] }) + t.deepEqual(parse('foo=', schema), { foo: [] }) +}) + +test('zod-v4: parses number arrays', (t) => { + const schema = z.object({ foo: z.array(z.number()) }) + t.deepEqual(parse('foo=1&foo=2', schema), { foo: [1, 2] }) +}) + +test('zod-v4: parses nested objects', (t) => { + const schema = z.object({ + foo: z.object({ bar: z.string(), baz: z.number() }), + }) + t.deepEqual(parse('foo.bar=a&foo.baz=1', schema), { + foo: { bar: 'a', baz: 1 }, + }) +}) + +test('zod-v4: parses records', (t) => { + const schema = z.object({ foo: z.record(z.string(), z.number()) }) + t.deepEqual(parse('foo.a=1&foo.b=2', schema), { foo: { a: 1, b: 2 } }) +}) + +test('zod-v4: parses unions of objects', (t) => { + const schema = z.union([ + z.object({ a: z.string() }), + z.object({ b: z.number() }), + ]) + t.deepEqual(parse('a=x&b=2', schema), { a: 'x', b: 2 }) +}) + +test('zod-v4: parses discriminated unions', (t) => { + const schema = z.discriminatedUnion('type', [ + z.object({ type: z.literal('a'), a: z.string() }), + z.object({ type: z.literal('b'), b: z.number() }), + ]) + t.deepEqual(parse('type=b&b=2', schema), { + type: 'b', + a: undefined, + b: 2, + }) +}) + +test('zod-v4: parses string enums', (t) => { + const schema = z.object({ foo: z.enum(['a', 'b']) }) + t.deepEqual(parse('foo=a', schema), { foo: 'a' }) +}) + +test('zod-v4: parses enums of numeric TypeScript enums as numbers', (t) => { + enum Level { + Low = 1, + High = 2, + } + const schema = z.object({ foo: z.enum(Level) }) + t.deepEqual(parse('foo=2', schema), { foo: 2 }) +}) + +test('zod-v4: parses enum arrays', (t) => { + const schema = z.object({ foo: z.array(z.enum(['a', 'b'])) }) + t.deepEqual(parse('foo=a,b', schema), { foo: ['a', 'b'] }) +}) + +test('zod-v4: parses literals', (t) => { + const schema = z.object({ + foo: z.literal('x'), + bar: z.literal(2), + baz: z.literal(['p', 'q']), + }) + t.deepEqual(parse('foo=x&bar=2&baz=q', schema), { + foo: 'x', + bar: 2, + baz: 'q', + }) +}) + +test('zod-v4: parses schemas with refinements', (t) => { + const schema = z + .object({ + foo: z.string().refine((v) => v.length > 0), + bar: z + .boolean() + .default(true) + .refine((v) => v), + }) + .refine((data) => data.foo !== 'nope') + t.deepEqual(parse('foo=a&bar=false', schema), { foo: 'a', bar: false }) +}) + +test('zod-v4: parses schemas with transforms as their input type', (t) => { + const schema = z.object({ + foo: z.number().transform((v) => String(v)), + }) + t.deepEqual(parse('foo=2', schema), { foo: 2 }) +}) + +test('zod-v4: parses null and never properties', (t) => { + const schema = z.object({ foo: z.null(), bar: z.never().optional() }) + t.deepEqual(parse('foo=', schema), { foo: null, bar: undefined }) +}) + +test('zod-v4: throws UnparseableSearchParamError on ambiguous input', (t) => { + const schema = z.object({ foo: z.array(z.string()) }) + t.throws(() => parse('foo=a&foo[]=b', schema), { + instanceOf: UnparseableSearchParamError, + }) + t.throws(() => parse('foo[]=a,b', schema), { + instanceOf: UnparseableSearchParamError, + }) +}) + +test('zod-v4: throws UnparseableSearchParamError on repeated non-array params', (t) => { + const schema = z.object({ foo: z.string() }) + t.throws(() => parse('foo=a&foo=b', schema), { + instanceOf: UnparseableSearchParamError, + }) +}) + +test('zod-v4: throws UnparseableSchemaError on unsupported schemas', (t) => { + t.throws(() => parse('foo=a', z.string()), { + instanceOf: UnparseableSchemaError, + }) + t.throws(() => parse('foo=a', z.object({ foo: z.bigint() })), { + instanceOf: UnparseableSchemaError, + }) + t.throws(() => parse('foo=a', z.object({ foo: z.array(z.boolean()) })), { + instanceOf: UnparseableSchemaError, + }) +}) + +test('zod-v4: generous parsing passes through invalid values as strings', (t) => { + const schema = z.object({ + age: z.number(), + isAdmin: z.boolean(), + }) + t.deepEqual(parse('age=a&isAdmin=b', schema), { age: 'a', isAdmin: 'b' }) +})