Skip to content

Commit b5c70d2

Browse files
committed
Zod 4 adapter passing tests
1 parent 875e9dc commit b5c70d2

File tree

8 files changed

+448
-42
lines changed

8 files changed

+448
-42
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@
122122
"svelte": "3.x || 4.x || >=5.0.0-next.51",
123123
"valibot": ">=1.0.0-rc.3",
124124
"yup": "^1.4.0",
125-
"zod": "^3.24.2"
125+
"zod": "^3.25.0"
126126
},
127127
"peerDependenciesMeta": {
128128
"@exodus/schemasafe": {

src/lib/adapters/adapters.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ export type ValidationLibrary =
3333
| 'valibot'
3434
| 'yup'
3535
| 'zod'
36+
| 'zod4'
3637
| 'vine'
3738
| 'schemasafe'
3839
| 'superstruct'

src/lib/adapters/typeSchema.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import type {
1515
} from 'valibot';
1616
import type { Schema as Schema$2, InferType } from 'yup';
1717
import type { ZodTypeAny, input, output } from 'zod';
18+
import type { $ZodType, input as zod4Input, output as zod4Output } from 'zod/v4/core';
1819
import type { SchemaTypes, Infer as VineInfer } from '@vinejs/vine/types';
1920
import type { FromSchema, JSONSchema } from 'json-schema-to-ts';
2021
import type { Struct, Infer as Infer$2 } from 'superstruct';
@@ -133,6 +134,12 @@ interface ZodResolver extends Resolver {
133134
output: this['schema'] extends ZodTypeAny ? output<this['schema']> : never;
134135
}
135136

137+
interface Zod4Resolver extends Resolver {
138+
base: $ZodType;
139+
input: this['schema'] extends $ZodType ? zod4Input<this['schema']> : never;
140+
output: this['schema'] extends $ZodType ? zod4Output<this['schema']> : never;
141+
}
142+
136143
interface VineResolver extends Resolver {
137144
base: SchemaTypes;
138145
input: this['schema'] extends SchemaTypes
@@ -207,6 +214,7 @@ export type Registry = {
207214
valibot: ValibotResolver;
208215
yup: YupResolver;
209216
zod: ZodResolver;
217+
zod4: Zod4Resolver;
210218
vine: VineResolver;
211219
schemasafe: SchemasafeResolver<JSONSchema>;
212220
superstruct: SuperstructResolver;

src/lib/adapters/zod4.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { type $ZodObject, type $ZodErrorMap, safeParseAsync, toJSONSchema } from 'zod/v4/core';
2+
import type { JSONSchema7 } from 'json-schema';
3+
import {
4+
type AdapterOptions,
5+
type ValidationAdapter,
6+
type Infer,
7+
type InferIn,
8+
createAdapter,
9+
type ValidationResult,
10+
type ClientValidationAdapter
11+
} from './adapters.js';
12+
import { memoize } from '$lib/memoize.js';
13+
14+
type Options = NonNullable<Parameters<typeof toJSONSchema>[1]>;
15+
16+
type ZodObject = $ZodObject;
17+
18+
const defaultJSONSchemaOptions = {
19+
unrepresentable: 'any',
20+
override: (ctx) => {
21+
const def = ctx.zodSchema._zod.def;
22+
if (def.type === 'date') {
23+
ctx.jsonSchema.type = 'string';
24+
ctx.jsonSchema.format = 'date-time';
25+
}
26+
}
27+
} satisfies Options;
28+
29+
/* @__NO_SIDE_EFFECTS__ */
30+
export const zodToJSONSchema = <S extends ZodObject>(schema: S, options?: Options) => {
31+
return toJSONSchema(schema, { ...defaultJSONSchemaOptions, ...options }) as JSONSchema7;
32+
};
33+
34+
async function validate<T extends ZodObject>(
35+
schema: T,
36+
data: unknown,
37+
error: $ZodErrorMap | undefined
38+
): Promise<ValidationResult<Infer<T, 'zod4'>>> {
39+
const result = await safeParseAsync(schema, data, { error });
40+
if (result.success) {
41+
return {
42+
data: result.data as Infer<T, 'zod4'>,
43+
success: true
44+
};
45+
}
46+
47+
return {
48+
issues: result.error.issues.map(({ message, path }) => ({ message, path })),
49+
success: false
50+
};
51+
}
52+
53+
function _zod4<T extends ZodObject>(
54+
schema: T,
55+
options?: AdapterOptions<Infer<T, 'zod4'>> & { error?: $ZodErrorMap; config?: Options }
56+
): ValidationAdapter<Infer<T, 'zod4'>, InferIn<T, 'zod4'>> {
57+
return createAdapter({
58+
superFormValidationLibrary: 'zod4',
59+
validate: async (data) => {
60+
return validate(schema, data, options?.error);
61+
},
62+
jsonSchema: options?.jsonSchema ?? zodToJSONSchema(schema, options?.config),
63+
defaults: options?.defaults
64+
});
65+
}
66+
67+
function _zod4Client<T extends ZodObject>(
68+
schema: T,
69+
options?: { error?: $ZodErrorMap }
70+
): ClientValidationAdapter<Infer<T, 'zod4'>, InferIn<T, 'zod4'>> {
71+
return {
72+
superFormValidationLibrary: 'zod4',
73+
validate: async (data) => validate(schema, data, options?.error)
74+
};
75+
}
76+
77+
export const zod4 = /* @__PURE__ */ memoize(_zod4);
78+
export const zod4Client = /* @__PURE__ */ memoize(_zod4Client);

src/lib/formData.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -199,7 +199,17 @@ function _parseFormData<T extends Record<string, unknown>>(
199199
throw new SchemaError(unionError, key);
200200
}
201201

202-
const [type] = info.types;
202+
let [type] = info.types;
203+
204+
if (!info.types.length && info.schema.enum) {
205+
// Special case for Typescript enums
206+
// If the entry is an integer, parse it as such, otherwise string
207+
if (info.schema.enum.includes(entry)) type = 'string';
208+
else {
209+
type = Number.isInteger(parseInt(entry, 10)) ? 'integer' : 'string';
210+
}
211+
}
212+
203213
return parseFormDataEntry(key, entry, type ?? 'any', info);
204214
}
205215

src/lib/jsonSchema/schemaDefaults.ts

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -97,17 +97,21 @@ function _defaultValues(schema: JSONSchema, isOptional: boolean, path: string[])
9797
}
9898

9999
// Objects must have default values to avoid setting undefined properties on nested data
100-
if (info.union.length && info.types[0] == 'object') {
101-
if (output === undefined) output = {};
102-
output =
103-
info.union.length > 1
104-
? merge.withOptions(
105-
{ allowUndefinedOverrides: true },
106-
...info.union.map(
107-
(s) => _defaultValues(s, isOptional, path) as Record<string, unknown>
100+
if (info.union.length) {
101+
if (info.types[0] == 'object') {
102+
if (output === undefined) output = {};
103+
output =
104+
info.union.length > 1
105+
? merge.withOptions(
106+
{ allowUndefinedOverrides: true },
107+
...info.union.map(
108+
(s) => _defaultValues(s, isOptional, path) as Record<string, unknown>
109+
)
108110
)
109-
)
110-
: (_defaultValues(info.union[0], isOptional, path) as Record<string, unknown>);
111+
: (_defaultValues(info.union[0], isOptional, path) as Record<string, unknown>);
112+
} else {
113+
return _defaultValues(info.union[0], isOptional, path);
114+
}
111115
}
112116
}
113117
}
@@ -149,6 +153,11 @@ function _defaultValues(schema: JSONSchema, isOptional: boolean, path: string[])
149153
return schema.enum[0];
150154
}
151155

156+
// Constants
157+
if ('const' in schema) {
158+
return schema.const;
159+
}
160+
152161
// Basic type
153162
if (isMultiTypeUnion()) {
154163
throw new SchemaError('Default values cannot have more than one type.', path);

src/tests/data.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import { z } from 'zod';
2+
import { z as z4 } from 'zod/v4';
3+
24
import { zodToJSONSchema } from '$lib/adapters/zod.js';
35

46
export enum Foo {
@@ -27,6 +29,27 @@ export const bigZodSchema = z.object({
2729
})
2830
});
2931

32+
export const bigZod4Schema = z4.object({
33+
name: z4.union([z4.string().default('B'), z4.number()]).default('A'),
34+
email: z4.email(),
35+
tags: z4.string().min(2).array().min(2).default(['A']),
36+
foo: z4.enum(Foo).default(Foo.A),
37+
set: z4.set(z4.string()),
38+
reg1: z4.string().regex(/\D/).regex(/p/),
39+
reg: z4.string().regex(/X/).min(3).max(30),
40+
num: z4.number().int().multipleOf(5).min(10).max(100),
41+
date: z4.date().min(new Date('2022-01-01')),
42+
arr: z4
43+
.union([z4.string().min(10), z4.date()])
44+
.array()
45+
.min(3)
46+
.max(10),
47+
nestedTags: z4.object({
48+
id: z4.number().int().positive().optional(),
49+
name: z4.string().min(1)
50+
})
51+
});
52+
3053
export const bigJsonSchema = zodToJSONSchema(bigZodSchema);
3154

3255
///// From legacy tests /////

0 commit comments

Comments
 (0)