Skip to content

feat: zod v4 support #768 #776

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 3 commits into from
Closed
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
7 changes: 5 additions & 2 deletions bun.lock
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"name": "@hookform/resolvers",
"dependencies": {
"@standard-schema/utils": "^0.3.0",
"zod-v4": "npm:zod@^3.25.0",
Copy link

Choose a reason for hiding this comment

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

The lock file also needs to be updated after moving zod-v4 to devDependencies

Copy link

Choose a reason for hiding this comment

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

The lock file also needs to be updated after moving zod-v4 to devDependencies

Shouldn't zod-v4 be under devDependencies? Only the standard schema is included as a dependency.

},
"devDependencies": {
"@sinclair/typebox": "^0.34.30",
Expand Down Expand Up @@ -54,7 +55,7 @@
"vite-tsconfig-paths": "^5.1.4",
"vitest": "^3.0.9",
"yup": "^1.6.1",
"zod": "^3.24.2",
"zod": "3.24.4",
Copy link

Choose a reason for hiding this comment

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

Fixing @CHC383 's suggestion by moving zod-v4 to devDependencies

Suggested change
"zod": "3.24.4",
"zod": "3.24.4",
"zod-v4": "npm:zod@^3.25.0",

},
"peerDependencies": {
"react-hook-form": "^7.55.0",
Expand Down Expand Up @@ -1444,7 +1445,9 @@

"yup": ["yup@1.6.1", "", { "dependencies": { "property-expr": "^2.0.5", "tiny-case": "^1.0.3", "toposort": "^2.0.2", "type-fest": "^2.19.0" } }, "sha512-JED8pB50qbA4FOkDol0bYF/p60qSEDQqBD0/qeIrUCG1KbPBIQ776fCUNb9ldbPcSTxA69g/47XTo4TqWiuXOA=="],

"zod": ["zod@3.24.2", "", {}, "sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ=="],
"zod": ["zod@3.24.4", "", {}, "sha512-OdqJE9UDRPwWsrHjLN2F8bPxvwJBK22EHLWtanu0LSYr5YqzsaaW3RMgmjwr8Rypg5k+meEJdSPXJZXE/yqOMg=="],

"zod-v4": ["zod@3.25.42", "", {}, "sha512-PcALTLskaucbeHc41tU/xfjfhcz8z0GdhhDcSgrCTmSazUuqnYqiXO63M0QUBVwpBlsLsNVn5qHSC5Dw3KZvaQ=="],

"@asamuzakjp/css-color/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],

Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -314,7 +314,8 @@
"vite-tsconfig-paths": "^5.1.4",
"vitest": "^3.0.9",
"yup": "^1.6.1",
"zod": "^3.24.2"
"zod": "3.24.4",
"zod-v4": "npm:zod@^3.25.0"
},
"peerDependencies": {
"react-hook-form": "^7.55.0"
Expand Down
4 changes: 2 additions & 2 deletions typebox/src/__tests__/typebox.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { Type } from '@sinclair/typebox';
import { TypeCompiler } from '@sinclair/typebox/compiler';
import { Resolver, SubmitHandler, useForm } from 'react-hook-form';
import { typeboxResolver } from '..';
import { fields, invalidData, schema, validData } from './__fixtures__/data';
import { Type } from '@sinclair/typebox';
import { TypeCompiler } from '@sinclair/typebox/compiler';

const shouldUseNativeValidation = false;

Expand Down
3 changes: 2 additions & 1 deletion zod/src/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from './zod';
export { zodResolver } from './zod';
Copy link
Preview

Copilot AI Jun 1, 2025

Choose a reason for hiding this comment

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

Replacing export * with named exports removes other exports from ./zod. If consumers rely on additional types or utilities, re-export them too or restore export *.

Copilot uses AI. Check for mistakes.

Copy link
Author

Choose a reason for hiding this comment

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

We do it this way to be able to export both resolvers of both versions.

export { zodResolver as zodResolverV4 } from './zodv4';
134 changes: 134 additions & 0 deletions zod/src/zodv4.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import { toNestErrors, validateFieldsNatively } from '@hookform/resolvers';
import {
FieldError,
FieldErrors,
FieldValues,
Resolver,
ResolverError,
ResolverSuccess,
appendErrors,
} from 'react-hook-form';
import { ZodError } from 'zod-v4/v4';
Copy link

Choose a reason for hiding this comment

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

I think for v4-mini compatibility, the code should use core.$ZodError instead.

https://zod.dev/packages/core?id=errors#errors

import * as core from 'zod-v4/v4/core';

function parseErrorSchema(
zodErrors: core.$ZodIssue[],
validateAllFieldCriteria: boolean,
) {
const errors: Record<string, FieldError> = {};
for (; zodErrors.length; ) {
const error = zodErrors[0];
const { code, message, path } = error;
const _path = path.join('.');

if (!errors[_path]) {
if (error.code === 'invalid_union') {
const unionError = error.errors[0][0];

errors[_path] = {
message: unionError.message,
type: unionError.code,
};
} else {
errors[_path] = { message, type: code };
}
}

if (error.code === 'invalid_union') {
error.errors.forEach((unionError: any[]) =>
unionError.forEach((e) =>
zodErrors.push({
...e,
path: [...path, ...e.path],
}),
),
);
}

if (validateAllFieldCriteria) {
const types = errors[_path].types;
const messages = types && types[error.code];

errors[_path] = appendErrors(
_path,
validateAllFieldCriteria,
errors,
code,
messages
? ([] as string[]).concat(messages as string[], error.message)
: error.message,
) as FieldError;
}

zodErrors.shift();
}

return errors;
}

export function zodResolver<Input extends FieldValues, Context, Output>(
schema: core.$ZodType<Output, Input>,
schemaOptions?: Partial<core.ParseContext<core.$ZodIssue>>,
resolverOptions?: {
mode?: 'async' | 'sync';
raw?: false;
},
): Resolver<Input, Context, Output>;
Comment on lines +69 to +76

Choose a reason for hiding this comment

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

it seems like the overload for raw: true was incorrectly removed, which breaks the types when this option is passed @alexcraviotto

Choose a reason for hiding this comment

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


/**
* Creates a resolver function for react-hook-form that validates form data using a Zod schema
* @param {z.ZodSchema<Input>} schema - The Zod schema used to validate the form data
* @param {Partial<z.ParseParams>} [schemaOptions] - Optional configuration options for Zod parsing
Copy link
Contributor

Choose a reason for hiding this comment

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

Update

Suggested change
* @param {Partial<z.ParseParams>} [schemaOptions] - Optional configuration options for Zod parsing
* @param {z.core.ParseContext<z.core.$ZodIssue>} [schemaOptions] - Optional configuration options for Zod parsing

* @param {Object} [resolverOptions] - Optional resolver-specific configuration
* @param {('async'|'sync')} [resolverOptions.mode='async'] - Validation mode. Use 'sync' for synchronous validation
* @param {boolean} [resolverOptions.raw=false] - If true, returns the raw form values instead of the parsed data
* @returns {Resolver<z.output<typeof schema>>} A resolver function compatible with react-hook-form
* @throws {Error} Throws if validation fails with a non-Zod error
* @example
* const schema = z.object({
* @param {z.core.ParseContext<z.core.$ZodIssue>} [schemaOptions] - Optional configuration options for Zod parsing * age: z.number().min(18)
* });
*
* useForm({
* resolver: zodResolver(schema)
* });
*/
export function zodResolver<Input extends FieldValues, Context, Output>(
schema: core.$ZodType<Output, Input>,
schemaOptions?: Partial<core.ParseContext<core.$ZodIssue>>,
resolverOptions: {
mode?: 'async' | 'sync';
raw?: boolean;
} = {},
): Resolver<Input, Context, Output | Input> {
return async (values: Input, _, options) => {
try {
const data = await (resolverOptions.mode === 'sync'
? core.parse(schema, values, schemaOptions)
: core.parseAsync(schema, values, schemaOptions));

options.shouldUseNativeValidation && validateFieldsNatively({}, options);

return {
errors: {} as FieldErrors,
values: resolverOptions.raw ? values : data,
} satisfies ResolverSuccess<Output | Input>;
} catch (error) {
if (error instanceof ZodError) {
return {
values: {} as Input,
errors: toNestErrors(
parseErrorSchema(
(error as { issues: core.$ZodIssue[] }).issues,
!options.shouldUseNativeValidation &&
options.criteriaMode === 'all',
),
options,
),
} satisfies ResolverError<Input>;
}

throw error;
}
};
}