From ba6a6f0e1d2541e1f2e7d59d50603e661d64425a Mon Sep 17 00:00:00 2001 From: alexcraviotto Date: Sat, 31 May 2025 14:46:25 +0200 Subject: [PATCH 1/3] feat: zod v4 support --- bun.lock | 3 + package.json | 3 +- typebox/src/__tests__/typebox.ts | 4 +- zod/src/index.ts | 3 +- zod/src/zodv4.ts | 143 +++++++++++++++++++++++++++++++ 5 files changed, 152 insertions(+), 4 deletions(-) create mode 100644 zod/src/zodv4.ts diff --git a/bun.lock b/bun.lock index 529290a5..af1f5d6b 100644 --- a/bun.lock +++ b/bun.lock @@ -5,6 +5,7 @@ "name": "@hookform/resolvers", "dependencies": { "@standard-schema/utils": "^0.3.0", + "zod-v4": "npm:zod@^3.25.0", }, "devDependencies": { "@sinclair/typebox": "^0.34.30", @@ -1446,6 +1447,8 @@ "zod": ["zod@3.24.2", "", {}, "sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ=="], + "zod-v4": ["zod@3.25.42", "", {}, "sha512-PcALTLskaucbeHc41tU/xfjfhcz8z0GdhhDcSgrCTmSazUuqnYqiXO63M0QUBVwpBlsLsNVn5qHSC5Dw3KZvaQ=="], + "@asamuzakjp/css-color/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], "@rollup/plugin-commonjs/magic-string": ["magic-string@0.25.9", "", { "dependencies": { "sourcemap-codec": "^1.4.8" } }, "sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ=="], diff --git a/package.json b/package.json index e6cfb9db..a39bae7c 100644 --- a/package.json +++ b/package.json @@ -320,6 +320,7 @@ "react-hook-form": "^7.55.0" }, "dependencies": { - "@standard-schema/utils": "^0.3.0" + "@standard-schema/utils": "^0.3.0", + "zod-v4": "npm:zod@^3.25.0" } } diff --git a/typebox/src/__tests__/typebox.ts b/typebox/src/__tests__/typebox.ts index 0085949a..7fb7dfa3 100644 --- a/typebox/src/__tests__/typebox.ts +++ b/typebox/src/__tests__/typebox.ts @@ -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; diff --git a/zod/src/index.ts b/zod/src/index.ts index 6748f26c..622ba63b 100644 --- a/zod/src/index.ts +++ b/zod/src/index.ts @@ -1 +1,2 @@ -export * from './zod'; +export { zodResolver } from './zod'; +export { zodResolver as zodResolverV4 } from './zodv4'; diff --git a/zod/src/zodv4.ts b/zod/src/zodv4.ts new file mode 100644 index 00000000..ad0c0090 --- /dev/null +++ b/zod/src/zodv4.ts @@ -0,0 +1,143 @@ +import { toNestErrors, validateFieldsNatively } from '@hookform/resolvers'; +import { + FieldError, + FieldErrors, + FieldValues, + Resolver, + ResolverError, + ResolverSuccess, + appendErrors, +} from 'react-hook-form'; +import { ZodError, z } from 'zod-v4/v4'; + +function parseErrorSchema( + zodErrors: z.core.$ZodIssue[], + validateAllFieldCriteria: boolean, +) { + const errors: Record = {}; + 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( + schema: z.ZodSchema, + schemaOptions?: Partial>, + resolverOptions?: { + mode?: 'async' | 'sync'; + raw?: false; + }, +): Resolver; + +export function zodResolver( + schema: z.ZodSchema, + schemaOptions: Partial> | undefined, + resolverOptions: { + mode?: 'async' | 'sync'; + raw: true; + }, +): Resolver; + +/** + * Creates a resolver function for react-hook-form that validates form data using a Zod schema + * @param {z.ZodSchema} schema - The Zod schema used to validate the form data + * @param {Partial} [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>} A resolver function compatible with react-hook-form + * @throws {Error} Throws if validation fails with a non-Zod error + * @example + * const schema = z.object({ + * name: z.string().min(2), + * age: z.number().min(18) + * }); + * + * useForm({ + * resolver: zodResolver(schema) + * }); + */ +export function zodResolver( + schema: z.ZodSchema, + schemaOptions?: Partial>, + resolverOptions: { + mode?: 'async' | 'sync'; + raw?: boolean; + } = {}, +): Resolver { + return async (values: Input, _, options) => { + try { + const data = await schema[ + resolverOptions.mode === 'sync' ? 'parse' : 'parseAsync' + ](values, schemaOptions); + + options.shouldUseNativeValidation && validateFieldsNatively({}, options); + + return { + errors: {} as FieldErrors, + values: resolverOptions.raw ? Object.assign({}, values) : data, + } satisfies ResolverSuccess; + } catch (error) { + if (error instanceof ZodError) { + return { + values: {}, + errors: toNestErrors( + parseErrorSchema( + (error as { issues: z.core.$ZodIssue[] }).issues, + !options.shouldUseNativeValidation && + options.criteriaMode === 'all', + ), + options, + ), + } satisfies ResolverError; + } + + throw error; + } + }; +} From ac63b9c9af0ff3c9d5b7e4b2e047deb769da8d7f Mon Sep 17 00:00:00 2001 From: alexcraviotto Date: Sat, 31 May 2025 15:13:02 +0200 Subject: [PATCH 2/3] fix: zod version --- bun.lock | 4 ++-- package.json | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/bun.lock b/bun.lock index af1f5d6b..709bcbbe 100644 --- a/bun.lock +++ b/bun.lock @@ -55,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", }, "peerDependencies": { "react-hook-form": "^7.55.0", @@ -1445,7 +1445,7 @@ "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=="], diff --git a/package.json b/package.json index a39bae7c..cf34c466 100644 --- a/package.json +++ b/package.json @@ -314,7 +314,7 @@ "vite-tsconfig-paths": "^5.1.4", "vitest": "^3.0.9", "yup": "^1.6.1", - "zod": "^3.24.2" + "zod": "3.24.4" }, "peerDependencies": { "react-hook-form": "^7.55.0" @@ -323,4 +323,4 @@ "@standard-schema/utils": "^0.3.0", "zod-v4": "npm:zod@^3.25.0" } -} +} \ No newline at end of file From 8d9e1bc5f3e5bc3b9952edff591442c6319c22cd Mon Sep 17 00:00:00 2001 From: alexcraviotto Date: Sun, 1 Jun 2025 11:37:06 +0200 Subject: [PATCH 3/3] fix: align zod integration with library authoring best practices --- package.json | 8 ++++---- zod/src/zodv4.ts | 37 ++++++++++++++----------------------- 2 files changed, 18 insertions(+), 27 deletions(-) diff --git a/package.json b/package.json index cf34c466..acf41351 100644 --- a/package.json +++ b/package.json @@ -314,13 +314,13 @@ "vite-tsconfig-paths": "^5.1.4", "vitest": "^3.0.9", "yup": "^1.6.1", - "zod": "3.24.4" + "zod": "3.24.4", + "zod-v4": "npm:zod@^3.25.0" }, "peerDependencies": { "react-hook-form": "^7.55.0" }, "dependencies": { - "@standard-schema/utils": "^0.3.0", - "zod-v4": "npm:zod@^3.25.0" + "@standard-schema/utils": "^0.3.0" } -} \ No newline at end of file +} diff --git a/zod/src/zodv4.ts b/zod/src/zodv4.ts index ad0c0090..987872a9 100644 --- a/zod/src/zodv4.ts +++ b/zod/src/zodv4.ts @@ -8,10 +8,11 @@ import { ResolverSuccess, appendErrors, } from 'react-hook-form'; -import { ZodError, z } from 'zod-v4/v4'; +import { ZodError } from 'zod-v4/v4'; +import * as core from 'zod-v4/v4/core'; function parseErrorSchema( - zodErrors: z.core.$ZodIssue[], + zodErrors: core.$ZodIssue[], validateAllFieldCriteria: boolean, ) { const errors: Record = {}; @@ -66,23 +67,14 @@ function parseErrorSchema( } export function zodResolver( - schema: z.ZodSchema, - schemaOptions?: Partial>, + schema: core.$ZodType, + schemaOptions?: Partial>, resolverOptions?: { mode?: 'async' | 'sync'; raw?: false; }, ): Resolver; -export function zodResolver( - schema: z.ZodSchema, - schemaOptions: Partial> | undefined, - resolverOptions: { - mode?: 'async' | 'sync'; - raw: true; - }, -): Resolver; - /** * Creates a resolver function for react-hook-form that validates form data using a Zod schema * @param {z.ZodSchema} schema - The Zod schema used to validate the form data @@ -94,8 +86,7 @@ export function zodResolver( * @throws {Error} Throws if validation fails with a non-Zod error * @example * const schema = z.object({ - * name: z.string().min(2), - * age: z.number().min(18) + * @param {z.core.ParseContext} [schemaOptions] - Optional configuration options for Zod parsing * age: z.number().min(18) * }); * * useForm({ @@ -103,8 +94,8 @@ export function zodResolver( * }); */ export function zodResolver( - schema: z.ZodSchema, - schemaOptions?: Partial>, + schema: core.$ZodType, + schemaOptions?: Partial>, resolverOptions: { mode?: 'async' | 'sync'; raw?: boolean; @@ -112,23 +103,23 @@ export function zodResolver( ): Resolver { return async (values: Input, _, options) => { try { - const data = await schema[ - resolverOptions.mode === 'sync' ? 'parse' : 'parseAsync' - ](values, schemaOptions); + 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 ? Object.assign({}, values) : data, + values: resolverOptions.raw ? values : data, } satisfies ResolverSuccess; } catch (error) { if (error instanceof ZodError) { return { - values: {}, + values: {} as Input, errors: toNestErrors( parseErrorSchema( - (error as { issues: z.core.$ZodIssue[] }).issues, + (error as { issues: core.$ZodIssue[] }).issues, !options.shouldUseNativeValidation && options.criteriaMode === 'all', ),