Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/pull_request.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ jobs:
with:
node-version: '20.9.0'
- name: Load Yarn cache
uses: actions/cache@v2
uses: actions/cache@v4
with:
path: ${{ steps.yarn-cache.outputs.dir }}
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
Expand Down
150 changes: 150 additions & 0 deletions src/zod-resolver-v4-mini.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import { z } from 'zod/v4-mini';
import { act, renderHook } from '@testing-library/react';
import { useForm } from '@mantine/form';
import { ZodResolverOptions, zod4Resolver } from './zod-resolver';

const schema = z.object({
name: z.string().check(z.minLength(2, { message: 'Name should have at least 2 letters' })),
email: z.string().check(z.email({ message: 'Invalid email' })),
age: z.number().check(z.minimum(18, { message: 'You must be at least 18 to create an account' })),
});

it('validates basic fields with given zod schema', () => {
const hook = renderHook(() =>
useForm({
initialValues: {
name: '',
email: '',
age: 16,
},
validate: zod4Resolver(schema),
})
);

expect(hook.result.current.errors).toStrictEqual({});
act(() => hook.result.current.validate());

expect(hook.result.current.errors).toStrictEqual({
name: 'Name should have at least 2 letters',
email: 'Invalid email',
age: 'You must be at least 18 to create an account',
});

act(() => hook.result.current.setValues({ name: 'John', email: '[email protected]', age: 16 }));
act(() => hook.result.current.validate());

expect(hook.result.current.errors).toStrictEqual({
age: 'You must be at least 18 to create an account',
});
});

const nestedSchema = z.object({
nested: z.object({
field: z.string().check(z.minLength(2, { message: 'Field should have at least 2 letters' })),
}),
});

it('validates nested fields with given zod schema', () => {
const hook = renderHook(() =>
useForm({
initialValues: {
nested: {
field: '',
},
},
validate: zod4Resolver(nestedSchema),
})
);

expect(hook.result.current.errors).toStrictEqual({});
act(() => hook.result.current.validate());

expect(hook.result.current.errors).toStrictEqual({
'nested.field': 'Field should have at least 2 letters',
});

act(() => hook.result.current.setValues({ nested: { field: 'John' } }));
act(() => hook.result.current.validate());

expect(hook.result.current.errors).toStrictEqual({});
});

const listSchema = z.object({
list: z.array(
z.object({
name: z.string().check(z.minLength(2, { message: 'Name should have at least 2 letters' })),
})
),
});

it('validates list fields with given zod schema', () => {
const hook = renderHook(() =>
useForm({
initialValues: {
list: [{ name: '' }],
},
validate: zod4Resolver(listSchema),
})
);

expect(hook.result.current.errors).toStrictEqual({});
act(() => hook.result.current.validate());

expect(hook.result.current.errors).toStrictEqual({
'list.0.name': 'Name should have at least 2 letters',
});

act(() => hook.result.current.setValues({ list: [{ name: 'John' }] }));
act(() => hook.result.current.validate());

expect(hook.result.current.errors).toStrictEqual({});
});

const mandatoryHashMessage = 'There must be a # in the hashtag';
const notEmptyMessage = 'Hashtag should not be empty';

const multipleMessagesForAFieldSchema = z.object({
hashtag: z.string().check(
z.refine((value) => value.length > 0, {
message: notEmptyMessage,
}),
z.refine((value) => value.includes('#'), {
message: mandatoryHashMessage,
})
),
});

it.each([
[
{
errorPriority: 'first',
},
notEmptyMessage,
],
[
{
errorPriority: 'last',
},
mandatoryHashMessage,
],
[undefined, mandatoryHashMessage],
])(
`provides the proper error for a schema with multiple messages for a field with resolver option %p`,
(options, expectedErrorMessage) => {
const hook = renderHook(() =>
useForm({
initialValues: {
hashtag: '',
},
validate: zod4Resolver(multipleMessagesForAFieldSchema, options as ZodResolverOptions),
})
);

expect(hook.result.current.errors).toStrictEqual({});
act(() => hook.result.current.validate());

expect(hook.result.current.errors).toStrictEqual({
hashtag: expectedErrorMessage,
});
}
);
33 changes: 17 additions & 16 deletions src/zod-resolver.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { ZodType } from 'zod/v4';
import { parse, $ZodError, type $ZodType } from 'zod/v4/core';
import type { Schema } from 'zod';
import type { FormErrors } from '@mantine/form';

Expand Down Expand Up @@ -29,25 +29,26 @@ export function zodResolver(schema: Schema, options?: ZodResolverOptions) {
};
}

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

if (parsed.success) {
return {};
}

const results: FormErrors = {};

if ('error' in parsed) {
if (options?.errorPriority === 'first') {
parsed.error.issues.reverse();
} catch (error) {
if (error instanceof $ZodError) {
const results: FormErrors = {};

if (options?.errorPriority === 'first') {
error.issues.reverse();
}
Copy link
Preview

Copilot AI Jun 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Avoid mutating the original error.issues array. Instead, create a shallow copy before reversing (e.g. const issues = [...error.issues].reverse()) to prevent side effects on subsequent validations.

Suggested change
error.issues.reverse();
const reversedIssues = [...error.issues].reverse();
error.issues = reversedIssues;

Copilot uses AI. Check for mistakes.

error.issues.forEach((issue) => {
results[issue.path.join('.')] = issue.message;
});

return results;
}
parsed.error.issues.forEach((error) => {
results[error.path.join('.')] = error.message;
});
throw error; // rethrow if it's not a ZodError
}

return results;
};
}
Loading