Skip to content

Commit 237239f

Browse files
committed
feat: add support for zod/v4-mini
1 parent db02c6d commit 237239f

File tree

2 files changed

+167
-16
lines changed

2 files changed

+167
-16
lines changed

src/zod-resolver-v4-mini.test.ts

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
import { z } from 'zod/v4-mini';
2+
import { act, renderHook } from '@testing-library/react';
3+
import { useForm } from '@mantine/form';
4+
import { ZodResolverOptions, zod4Resolver } from './zod-resolver';
5+
6+
const schema = z.object({
7+
name: z.string().check(z.minLength(2, { message: 'Name should have at least 2 letters' })),
8+
email: z.string().check(z.email({ message: 'Invalid email' })),
9+
age: z.number().check(z.minimum(18, { message: 'You must be at least 18 to create an account' })),
10+
});
11+
12+
it('validates basic fields with given zod schema', () => {
13+
const hook = renderHook(() =>
14+
useForm({
15+
initialValues: {
16+
name: '',
17+
email: '',
18+
age: 16,
19+
},
20+
validate: zod4Resolver(schema),
21+
})
22+
);
23+
24+
expect(hook.result.current.errors).toStrictEqual({});
25+
act(() => hook.result.current.validate());
26+
27+
expect(hook.result.current.errors).toStrictEqual({
28+
name: 'Name should have at least 2 letters',
29+
email: 'Invalid email',
30+
age: 'You must be at least 18 to create an account',
31+
});
32+
33+
act(() => hook.result.current.setValues({ name: 'John', email: '[email protected]', age: 16 }));
34+
act(() => hook.result.current.validate());
35+
36+
expect(hook.result.current.errors).toStrictEqual({
37+
age: 'You must be at least 18 to create an account',
38+
});
39+
});
40+
41+
const nestedSchema = z.object({
42+
nested: z.object({
43+
field: z.string().check(z.minLength(2, { message: 'Field should have at least 2 letters' })),
44+
}),
45+
});
46+
47+
it('validates nested fields with given zod schema', () => {
48+
const hook = renderHook(() =>
49+
useForm({
50+
initialValues: {
51+
nested: {
52+
field: '',
53+
},
54+
},
55+
validate: zod4Resolver(nestedSchema),
56+
})
57+
);
58+
59+
expect(hook.result.current.errors).toStrictEqual({});
60+
act(() => hook.result.current.validate());
61+
62+
expect(hook.result.current.errors).toStrictEqual({
63+
'nested.field': 'Field should have at least 2 letters',
64+
});
65+
66+
act(() => hook.result.current.setValues({ nested: { field: 'John' } }));
67+
act(() => hook.result.current.validate());
68+
69+
expect(hook.result.current.errors).toStrictEqual({});
70+
});
71+
72+
const listSchema = z.object({
73+
list: z.array(
74+
z.object({
75+
name: z.string().check(z.minLength(2, { message: 'Name should have at least 2 letters' })),
76+
})
77+
),
78+
});
79+
80+
it('validates list fields with given zod schema', () => {
81+
const hook = renderHook(() =>
82+
useForm({
83+
initialValues: {
84+
list: [{ name: '' }],
85+
},
86+
validate: zod4Resolver(listSchema),
87+
})
88+
);
89+
90+
expect(hook.result.current.errors).toStrictEqual({});
91+
act(() => hook.result.current.validate());
92+
93+
expect(hook.result.current.errors).toStrictEqual({
94+
'list.0.name': 'Name should have at least 2 letters',
95+
});
96+
97+
act(() => hook.result.current.setValues({ list: [{ name: 'John' }] }));
98+
act(() => hook.result.current.validate());
99+
100+
expect(hook.result.current.errors).toStrictEqual({});
101+
});
102+
103+
const mandatoryHashMessage = 'There must be a # in the hashtag';
104+
const notEmptyMessage = 'Hashtag should not be empty';
105+
106+
const multipleMessagesForAFieldSchema = z.object({
107+
hashtag: z.string().check(
108+
z.refine((value) => value.length > 0, {
109+
message: notEmptyMessage,
110+
}),
111+
z.refine((value) => value.includes('#'), {
112+
message: mandatoryHashMessage,
113+
})
114+
),
115+
});
116+
117+
it.each([
118+
[
119+
{
120+
errorPriority: 'first',
121+
},
122+
notEmptyMessage,
123+
],
124+
[
125+
{
126+
errorPriority: 'last',
127+
},
128+
mandatoryHashMessage,
129+
],
130+
[undefined, mandatoryHashMessage],
131+
])(
132+
`provides the proper error for a schema with multiple messages for a field with resolver option %p`,
133+
(options, expectedErrorMessage) => {
134+
const hook = renderHook(() =>
135+
useForm({
136+
initialValues: {
137+
hashtag: '',
138+
},
139+
validate: zod4Resolver(multipleMessagesForAFieldSchema, options as ZodResolverOptions),
140+
})
141+
);
142+
143+
expect(hook.result.current.errors).toStrictEqual({});
144+
act(() => hook.result.current.validate());
145+
146+
expect(hook.result.current.errors).toStrictEqual({
147+
hashtag: expectedErrorMessage,
148+
});
149+
}
150+
);

src/zod-resolver.ts

Lines changed: 17 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { ZodType } from 'zod/v4';
1+
import { parse, $ZodError, type $ZodType } from 'zod/v4/core';
22
import type { Schema } from 'zod';
33
import type { FormErrors } from '@mantine/form';
44

@@ -29,25 +29,26 @@ export function zodResolver(schema: Schema, options?: ZodResolverOptions) {
2929
};
3030
}
3131

32-
export function zod4Resolver(schema: ZodType, options?: ZodResolverOptions) {
32+
export function zod4Resolver(schema: $ZodType, options?: ZodResolverOptions) {
3333
return (values: Record<string, unknown>): FormErrors => {
34-
const parsed = schema.safeParse(values);
34+
try {
35+
parse(schema, values);
3536

36-
if (parsed.success) {
3737
return {};
38-
}
39-
40-
const results: FormErrors = {};
41-
42-
if ('error' in parsed) {
43-
if (options?.errorPriority === 'first') {
44-
parsed.error.issues.reverse();
38+
} catch (error) {
39+
if (error instanceof $ZodError) {
40+
const results: FormErrors = {};
41+
42+
if (options?.errorPriority === 'first') {
43+
error.issues.reverse();
44+
}
45+
error.issues.forEach((issue) => {
46+
results[issue.path.join('.')] = issue.message;
47+
});
48+
49+
return results;
4550
}
46-
parsed.error.issues.forEach((error) => {
47-
results[error.path.join('.')] = error.message;
48-
});
51+
throw error; // rethrow if it's not a ZodError
4952
}
50-
51-
return results;
5253
};
5354
}

0 commit comments

Comments
 (0)