diff --git a/bun.lock b/bun.lock index 529290a5..709bcbbe 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", @@ -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", }, "peerDependencies": { "react-hook-form": "^7.55.0", @@ -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=="], diff --git a/package.json b/package.json index e6cfb9db..acf41351 100644 --- a/package.json +++ b/package.json @@ -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" 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..987872a9 --- /dev/null +++ b/zod/src/zodv4.ts @@ -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'; +import * as core from 'zod-v4/v4/core'; + +function parseErrorSchema( + zodErrors: 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: core.$ZodType, + schemaOptions?: Partial>, + resolverOptions?: { + mode?: 'async' | 'sync'; + raw?: false; + }, +): 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({ + * @param {z.core.ParseContext} [schemaOptions] - Optional configuration options for Zod parsing * age: z.number().min(18) + * }); + * + * useForm({ + * resolver: zodResolver(schema) + * }); + */ +export function zodResolver( + schema: core.$ZodType, + schemaOptions?: Partial>, + resolverOptions: { + mode?: 'async' | 'sync'; + raw?: boolean; + } = {}, +): Resolver { + 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; + } 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; + } + + throw error; + } + }; +}