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' }) +})