diff --git a/README.md b/README.md index 9ae0fd4f..9fa6f4df 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,7 @@ Example: ```tsx import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; -import { z } from 'zod'; +import { z } from 'zod'; // or 'zod/v4' const schema = z.object({ id: z.number(), @@ -175,7 +175,7 @@ const App = () => { }; ``` -### [Zod](https://github.com/vriad/zod) +### [Zod](https://github.com/colinhacks/zod) TypeScript-first schema validation with static type inference @@ -186,7 +186,7 @@ TypeScript-first schema validation with static type inference ```tsx import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; -import { z } from 'zod'; +import { z } from 'zod'; // or 'zod/v4' const schema = z.object({ name: z.string().min(1, { message: 'Required' }), diff --git a/bun.lock b/bun.lock index 529290a5..6fbd1cf3 100644 --- a/bun.lock +++ b/bun.lock @@ -54,7 +54,7 @@ "vite-tsconfig-paths": "^5.1.4", "vitest": "^3.0.9", "yup": "^1.6.1", - "zod": "^3.24.2", + "zod": "^3.25.0", }, "peerDependencies": { "react-hook-form": "^7.55.0", @@ -1444,7 +1444,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.25.51", "", {}, "sha512-TQSnBldh+XSGL+opiSIq0575wvDPqu09AqWe1F7JhUMKY+M91/aGlK4MhpVNO7MgYfHcVCB1ffwAUTJzllKJqg=="], "@asamuzakjp/css-color/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], diff --git a/package.json b/package.json index e6cfb9db..7319445c 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.25.0" }, "peerDependencies": { "react-hook-form": "^7.55.0" diff --git a/standard-schema/src/__tests__/__fixtures__/data.ts b/standard-schema/src/__tests__/__fixtures__/data.ts index fc72250d..7236799f 100644 --- a/standard-schema/src/__tests__/__fixtures__/data.ts +++ b/standard-schema/src/__tests__/__fixtures__/data.ts @@ -1,6 +1,6 @@ import { StandardSchemaV1 } from '@standard-schema/spec'; import { Field, InternalFieldName } from 'react-hook-form'; -import { z } from 'zod'; +import { z } from 'zod/v3'; export const schema = z .object({ diff --git a/standard-schema/src/__tests__/standard-schema.ts b/standard-schema/src/__tests__/standard-schema.ts index b5c5391f..b3c319d7 100644 --- a/standard-schema/src/__tests__/standard-schema.ts +++ b/standard-schema/src/__tests__/standard-schema.ts @@ -1,5 +1,5 @@ import { Resolver, SubmitHandler, useForm } from 'react-hook-form'; -import { z } from 'zod'; +import { z } from 'zod/v3'; import { standardSchemaResolver } from '..'; import { customSchema, 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/typeschema/src/__tests__/Form-native-validation.tsx b/typeschema/src/__tests__/Form-native-validation.tsx index a1b87327..55c70bb3 100644 --- a/typeschema/src/__tests__/Form-native-validation.tsx +++ b/typeschema/src/__tests__/Form-native-validation.tsx @@ -3,7 +3,7 @@ import user from '@testing-library/user-event'; import type { Infer } from '@typeschema/main'; import React from 'react'; import { useForm } from 'react-hook-form'; -import { z } from 'zod'; +import { z } from 'zod/v3'; import { typeschemaResolver } from '..'; const USERNAME_REQUIRED_MESSAGE = 'username field is required'; diff --git a/typeschema/src/__tests__/Form.tsx b/typeschema/src/__tests__/Form.tsx index 173e7995..51312a60 100644 --- a/typeschema/src/__tests__/Form.tsx +++ b/typeschema/src/__tests__/Form.tsx @@ -3,7 +3,7 @@ import user from '@testing-library/user-event'; import type { Infer } from '@typeschema/main'; import React from 'react'; import { useForm } from 'react-hook-form'; -import { z } from 'zod'; +import { z } from 'zod/v3'; import { typeschemaResolver } from '..'; const schema = z.object({ diff --git a/typeschema/src/__tests__/__fixtures__/data.ts b/typeschema/src/__tests__/__fixtures__/data.ts index 31310674..d206eee7 100644 --- a/typeschema/src/__tests__/__fixtures__/data.ts +++ b/typeschema/src/__tests__/__fixtures__/data.ts @@ -1,5 +1,5 @@ import { Field, InternalFieldName } from 'react-hook-form'; -import { z } from 'zod'; +import { z } from 'zod/v3'; export const schema = z .object({ diff --git a/typeschema/src/__tests__/typeschema.ts b/typeschema/src/__tests__/typeschema.ts index 5261fbda..a60ab076 100644 --- a/typeschema/src/__tests__/typeschema.ts +++ b/typeschema/src/__tests__/typeschema.ts @@ -1,6 +1,6 @@ import * as typeschema from '@typeschema/main'; import { Resolver, SubmitHandler, useForm } from 'react-hook-form'; -import { z } from 'zod'; +import { z } from 'zod/v3'; import { typeschemaResolver } from '..'; import { fields, invalidData, schema, validData } from './__fixtures__/data'; diff --git a/typeschema/src/typeschema.ts b/typeschema/src/typeschema.ts index 0891a57a..bba859bb 100644 --- a/typeschema/src/typeschema.ts +++ b/typeschema/src/typeschema.ts @@ -1,4 +1,5 @@ import { toNestErrors, validateFieldsNatively } from '@hookform/resolvers'; +import { StandardSchemaV1 } from '@standard-schema/spec'; import { FieldError, FieldErrors, @@ -6,7 +7,6 @@ import { Resolver, appendErrors, } from 'react-hook-form'; -import { StandardSchemaV1 } from 'zod/lib/standard-schema'; const parseErrorSchema = ( typeschemaErrors: readonly StandardSchemaV1.Issue[], diff --git a/zod/src/__tests__/Form-native-validation.tsx b/zod/src/__tests__/Form-native-validation.tsx index 3ac296f9..68957de0 100644 --- a/zod/src/__tests__/Form-native-validation.tsx +++ b/zod/src/__tests__/Form-native-validation.tsx @@ -2,7 +2,7 @@ import { render, screen } from '@testing-library/react'; import user from '@testing-library/user-event'; import React from 'react'; import { useForm } from 'react-hook-form'; -import { z } from 'zod'; +import { z } from 'zod/v3'; import { zodResolver } from '..'; const USERNAME_REQUIRED_MESSAGE = 'username field is required'; diff --git a/zod/src/__tests__/Form.tsx b/zod/src/__tests__/Form.tsx index ad78b48b..d766924c 100644 --- a/zod/src/__tests__/Form.tsx +++ b/zod/src/__tests__/Form.tsx @@ -2,7 +2,7 @@ import { render, screen } from '@testing-library/react'; import user from '@testing-library/user-event'; import React from 'react'; import { useForm } from 'react-hook-form'; -import { z } from 'zod'; +import { z } from 'zod/v3'; import { zodResolver } from '..'; const schema = z.object({ diff --git a/zod/src/__tests__/__fixtures__/data.ts b/zod/src/__tests__/__fixtures__/data-v3.ts similarity index 98% rename from zod/src/__tests__/__fixtures__/data.ts rename to zod/src/__tests__/__fixtures__/data-v3.ts index f338f1d1..4904ce2c 100644 --- a/zod/src/__tests__/__fixtures__/data.ts +++ b/zod/src/__tests__/__fixtures__/data-v3.ts @@ -1,5 +1,5 @@ import { Field, InternalFieldName } from 'react-hook-form'; -import { z } from 'zod'; +import { z } from 'zod/v3'; export const schema = z .object({ diff --git a/zod/src/__tests__/__fixtures__/data-v4-mini.ts b/zod/src/__tests__/__fixtures__/data-v4-mini.ts new file mode 100644 index 00000000..11bab770 --- /dev/null +++ b/zod/src/__tests__/__fixtures__/data-v4-mini.ts @@ -0,0 +1,98 @@ +import { Field, InternalFieldName } from 'react-hook-form'; +import { z } from 'zod/v4-mini'; + +export const schema = z + .object({ + username: z + .string() + .check(z.regex(/^\w+$/), z.minLength(3), z.maxLength(30)), + password: z + .string() + .check( + z.regex(new RegExp('.*[A-Z].*'), 'One uppercase character'), + z.regex(new RegExp('.*[a-z].*'), 'One lowercase character'), + z.regex(new RegExp('.*\\d.*'), 'One number'), + z.regex( + new RegExp('.*[`~<>?,./!@#$%^&*()\\-_+="\'|{}\\[\\];:\\\\].*'), + 'One special character', + ), + z.minLength(8, 'Must be at least 8 characters in length'), + ), + repeatPassword: z.string(), + accessToken: z.union([z.string(), z.number()]), + birthYear: z.optional(z.number().check(z.minimum(1900), z.maximum(2013))), + email: z.optional(z.email()), + tags: z.array(z.string()), + enabled: z.boolean(), + url: z.union([z.url('Custom error url'), z.literal('')]), + like: z.optional( + z.array( + z.object({ + id: z.number(), + name: z.string().check(z.length(4)), + }), + ), + ), + dateStr: z + .pipe( + z.string(), + z.transform((value) => new Date(value)), + ) + .check( + z.refine((value) => !isNaN(value.getTime()), { + message: 'Invalid date', + }), + ), + }) + .check( + z.refine((obj) => obj.password === obj.repeatPassword, { + message: 'Passwords do not match', + path: ['confirm'], + }), + ); + +export const validData = { + username: 'Doe', + password: 'Password123_', + repeatPassword: 'Password123_', + birthYear: 2000, + email: 'john@doe.com', + tags: ['tag1', 'tag2'], + enabled: true, + accessToken: 'accessToken', + url: 'https://react-hook-form.com/', + like: [ + { + id: 1, + name: 'name', + }, + ], + dateStr: '2020-01-01', +} satisfies z.input; + +export const invalidData = { + password: '___', + email: '', + birthYear: 'birthYear', + like: [{ id: 'z' }], + url: 'abc', +} as unknown as z.input; + +export const fields: Record = { + username: { + ref: { name: 'username' }, + name: 'username', + }, + password: { + ref: { name: 'password' }, + name: 'password', + }, + email: { + ref: { name: 'email' }, + name: 'email', + }, + birthday: { + ref: { name: 'birthday' }, + name: 'birthday', + }, +}; diff --git a/zod/src/__tests__/__fixtures__/data-v4.ts b/zod/src/__tests__/__fixtures__/data-v4.ts new file mode 100644 index 00000000..7c6f55f0 --- /dev/null +++ b/zod/src/__tests__/__fixtures__/data-v4.ts @@ -0,0 +1,89 @@ +import { Field, InternalFieldName } from 'react-hook-form'; +import { z } from 'zod/v4'; + +export const schema = z + .object({ + username: z.string().regex(/^\w+$/).min(3).max(30), + password: z + .string() + .regex(new RegExp('.*[A-Z].*'), 'One uppercase character') + .regex(new RegExp('.*[a-z].*'), 'One lowercase character') + .regex(new RegExp('.*\\d.*'), 'One number') + .regex( + new RegExp('.*[`~<>?,./!@#$%^&*()\\-_+="\'|{}\\[\\];:\\\\].*'), + 'One special character', + ) + .min(8, 'Must be at least 8 characters in length'), + repeatPassword: z.string(), + accessToken: z.union([z.string(), z.number()]), + birthYear: z.number().min(1900).max(2013).optional(), + email: z.string().email().optional(), + tags: z.array(z.string()), + + enabled: z.boolean(), + url: z.string().url('Custom error url').or(z.literal('')), + like: z + .array( + z.object({ + id: z.number(), + name: z.string().length(4), + }), + ) + .optional(), + dateStr: z + .string() + .transform((value) => new Date(value)) + .refine((value) => !isNaN(value.getTime()), { + message: 'Invalid date', + }), + }) + .refine((obj) => obj.password === obj.repeatPassword, { + message: 'Passwords do not match', + path: ['confirm'], + }); + +export const validData = { + username: 'Doe', + password: 'Password123_', + repeatPassword: 'Password123_', + birthYear: 2000, + email: 'john@doe.com', + tags: ['tag1', 'tag2'], + enabled: true, + accessToken: 'accessToken', + url: 'https://react-hook-form.com/', + like: [ + { + id: 1, + name: 'name', + }, + ], + dateStr: '2020-01-01', +} satisfies z.input; + +export const invalidData = { + password: '___', + email: '', + birthYear: 'birthYear', + like: [{ id: 'z' }], + url: 'abc', +} as unknown as z.input; + +export const fields: Record = { + username: { + ref: { name: 'username' }, + name: 'username', + }, + password: { + ref: { name: 'password' }, + name: 'password', + }, + email: { + ref: { name: 'email' }, + name: 'email', + }, + birthday: { + ref: { name: 'birthday' }, + name: 'birthday', + }, +}; diff --git a/zod/src/__tests__/zod-v3.ts b/zod/src/__tests__/zod-v3.ts new file mode 100644 index 00000000..8e040ba6 --- /dev/null +++ b/zod/src/__tests__/zod-v3.ts @@ -0,0 +1,178 @@ +import { Resolver, SubmitHandler, useForm } from 'react-hook-form'; +import { z } from 'zod/v3'; +import { zodResolver } from '..'; +import { fields, invalidData, schema, validData } from './__fixtures__/data-v3'; + +const shouldUseNativeValidation = false; + +describe('zodResolver', () => { + it('should return values from zodResolver when validation pass & raw=true', async () => { + const parseAsyncSpy = vi.spyOn(schema, 'parseAsync'); + + const result = await zodResolver(schema, undefined, { + raw: true, + })(validData, undefined, { + fields, + shouldUseNativeValidation, + }); + + expect(parseAsyncSpy).toHaveBeenCalledTimes(1); + expect(result).toEqual({ errors: {}, values: validData }); + }); + + it('should return parsed values from zodResolver with `mode: sync` when validation pass', async () => { + const parseSpy = vi.spyOn(schema, 'parse'); + const parseAsyncSpy = vi.spyOn(schema, 'parseAsync'); + + const result = await zodResolver(schema, undefined, { + mode: 'sync', + })(validData, undefined, { fields, shouldUseNativeValidation }); + + expect(parseSpy).toHaveBeenCalledTimes(1); + expect(parseAsyncSpy).not.toHaveBeenCalled(); + expect(result.errors).toEqual({}); + expect(result).toMatchSnapshot(); + }); + + it('should return a single error from zodResolver when validation fails', async () => { + const result = await zodResolver(schema)(invalidData, undefined, { + fields, + shouldUseNativeValidation, + }); + + expect(result).toMatchSnapshot(); + }); + + it('should return a single error from zodResolver with `mode: sync` when validation fails', async () => { + const parseSpy = vi.spyOn(schema, 'parse'); + const parseAsyncSpy = vi.spyOn(schema, 'parseAsync'); + + const result = await zodResolver(schema, undefined, { + mode: 'sync', + })(invalidData, undefined, { fields, shouldUseNativeValidation }); + + expect(parseSpy).toHaveBeenCalledTimes(1); + expect(parseAsyncSpy).not.toHaveBeenCalled(); + expect(result).toMatchSnapshot(); + }); + + it('should return all the errors from zodResolver when validation fails with `validateAllFieldCriteria` set to true', async () => { + const result = await zodResolver(schema)(invalidData, undefined, { + fields, + criteriaMode: 'all', + shouldUseNativeValidation, + }); + + expect(result).toMatchSnapshot(); + }); + + it('should return all the errors from zodResolver when validation fails with `validateAllFieldCriteria` set to true and `mode: sync`', async () => { + const result = await zodResolver(schema, undefined, { mode: 'sync' })( + invalidData, + undefined, + { + fields, + criteriaMode: 'all', + shouldUseNativeValidation, + }, + ); + + expect(result).toMatchSnapshot(); + }); + + it('should throw any error unrelated to Zod', async () => { + const schemaWithCustomError = schema.refine(() => { + throw Error('custom error'); + }); + const promise = zodResolver(schemaWithCustomError)(validData, undefined, { + fields, + shouldUseNativeValidation, + }); + + await expect(promise).rejects.toThrow('custom error'); + }); + + it('should enforce parse params type signature', async () => { + const resolver = zodResolver(schema, { + async: true, + path: ['asdf', 1234], + errorMap(iss, ctx) { + iss.path; + iss.code; + iss.path; + ctx.data; + ctx.defaultError; + return { message: 'asdf' }; + }, + }); + + resolver; + }); + + /** + * Type inference tests + */ + it('should correctly infer the output type from a zod schema', () => { + const resolver = zodResolver(z.object({ id: z.number() })); + + expectTypeOf(resolver).toEqualTypeOf< + Resolver<{ id: number }, unknown, { id: number }> + >(); + }); + + it('should correctly infer the output type from a zod schema using a transform', () => { + const resolver = zodResolver( + z.object({ id: z.number().transform((val) => String(val)) }), + ); + + expectTypeOf(resolver).toEqualTypeOf< + Resolver<{ id: number }, unknown, { id: string }> + >(); + }); + + it('should correctly infer the output type from a zod schema when a different input type is specified', () => { + const schema = z.object({ id: z.number() }).transform(({ id }) => { + return { id: String(id) }; + }); + + const resolver = zodResolver<{ id: number }, any, z.output>( + schema, + ); + + expectTypeOf(resolver).toEqualTypeOf< + Resolver<{ id: number }, any, { id: string }> + >(); + }); + + it('should correctly infer the output type from a Zod schema for the handleSubmit function in useForm', () => { + const schema = z.object({ id: z.number() }); + + const form = useForm({ + resolver: zodResolver(schema), + }); + + expectTypeOf(form.watch('id')).toEqualTypeOf(); + + expectTypeOf(form.handleSubmit).parameter(0).toEqualTypeOf< + SubmitHandler<{ + id: number; + }> + >(); + }); + + it('should correctly infer the output type from a Zod schema with a transform for the handleSubmit function in useForm', () => { + const schema = z.object({ id: z.number().transform((val) => String(val)) }); + + const form = useForm({ + resolver: zodResolver(schema), + }); + + expectTypeOf(form.watch('id')).toEqualTypeOf(); + + expectTypeOf(form.handleSubmit).parameter(0).toEqualTypeOf< + SubmitHandler<{ + id: string; + }> + >(); + }); +}); diff --git a/zod/src/__tests__/zod-v4-mini.ts b/zod/src/__tests__/zod-v4-mini.ts new file mode 100644 index 00000000..54c5681c --- /dev/null +++ b/zod/src/__tests__/zod-v4-mini.ts @@ -0,0 +1,182 @@ +import { Resolver, SubmitHandler, useForm } from 'react-hook-form'; +import { z } from 'zod/v4-mini'; +import { zodResolver } from '..'; +import { + fields, + invalidData, + schema, + validData, +} from './__fixtures__/data-v4-mini'; + +const shouldUseNativeValidation = false; + +describe('zodResolver', () => { + it('should return values from zodResolver when validation pass & raw=true', async () => { + const parseAsyncSpy = vi.spyOn(schema, 'parseAsync'); + + const result = await zodResolver(schema, undefined, { + raw: true, + })(validData, undefined, { + fields, + shouldUseNativeValidation, + }); + result; + + expect(parseAsyncSpy).toHaveBeenCalledTimes(1); + expect(result).toEqual({ errors: {}, values: validData }); + expectTypeOf(result.values); + }); + + it('should return parsed values from zodResolver with `mode: sync` when validation pass', async () => { + const parseSpy = vi.spyOn(schema, 'parse'); + const parseAsyncSpy = vi.spyOn(schema, 'parseAsync'); + + const result = await zodResolver(schema, undefined, { + mode: 'sync', + })(validData, undefined, { fields, shouldUseNativeValidation }); + expect(parseSpy).toHaveBeenCalledTimes(1); + expect(parseAsyncSpy).not.toHaveBeenCalled(); + expect(result.errors).toEqual({}); + expect(result).toMatchSnapshot(); + }); + + it('should return a single error from zodResolver when validation fails', async () => { + const result = await zodResolver(schema)(invalidData, undefined, { + fields, + shouldUseNativeValidation, + }); + + expect(result).toMatchSnapshot(); + }); + + it('should return a single error from zodResolver with `mode: sync` when validation fails', async () => { + const parseSpy = vi.spyOn(schema, 'parse'); + const parseAsyncSpy = vi.spyOn(schema, 'parseAsync'); + + const result = await zodResolver(schema, undefined, { + mode: 'sync', + })(invalidData, undefined, { fields, shouldUseNativeValidation }); + + expect(parseSpy).toHaveBeenCalledTimes(1); + expect(parseAsyncSpy).not.toHaveBeenCalled(); + expect(result).toMatchSnapshot(); + }); + + it('should return all the errors from zodResolver when validation fails with `validateAllFieldCriteria` set to true', async () => { + const result = await zodResolver(schema)(invalidData, undefined, { + fields, + criteriaMode: 'all', + shouldUseNativeValidation, + }); + + expect(result).toMatchSnapshot(); + }); + + it('should return all the errors from zodResolver when validation fails with `validateAllFieldCriteria` set to true and `mode: sync`', async () => { + const result = await zodResolver(schema, undefined, { mode: 'sync' })( + invalidData, + undefined, + { + fields, + criteriaMode: 'all', + shouldUseNativeValidation, + }, + ); + + expect(result).toMatchSnapshot(); + }); + + it('should throw any error unrelated to Zod', async () => { + const schemaWithCustomError = schema.check( + z.refine(() => { + throw Error('custom error'); + }), + ); + const promise = zodResolver(schemaWithCustomError)(validData, undefined, { + fields, + shouldUseNativeValidation, + }); + + await expect(promise).rejects.toThrow('custom error'); + }); + + /** + * Type inference tests + */ + it('should correctly infer the output type from a zod schema', () => { + const resolver = zodResolver(z.object({ id: z.number() })); + + expectTypeOf(resolver).toEqualTypeOf< + Resolver<{ id: number }, unknown, { id: number }> + >(); + }); + + it('should correctly infer the output type from a zod schema using a transform', () => { + const resolver = zodResolver( + z.object({ + id: z.pipe( + z.number(), + z.transform((val) => String(val)), + ), + }), + ); + + expectTypeOf(resolver).toEqualTypeOf< + Resolver<{ id: number }, unknown, { id: string }> + >(); + }); + + it('should correctly infer the output type from a zod schema when a different input type is specified', () => { + const schema = z.pipe( + z.object({ id: z.number() }), + z.transform(({ id }) => { + return { id: String(id) }; + }), + ); + + const resolver = zodResolver<{ id: number }, any, z.output>( + schema, + ); + + expectTypeOf(resolver).toEqualTypeOf< + Resolver<{ id: number }, any, { id: string }> + >(); + }); + + it('should correctly infer the output type from a Zod schema for the handleSubmit function in useForm', () => { + const schema = z.object({ id: z.number() }); + + const form = useForm({ + resolver: zodResolver(schema), + }); + + expectTypeOf(form.watch('id')).toEqualTypeOf(); + + expectTypeOf(form.handleSubmit).parameter(0).toEqualTypeOf< + SubmitHandler<{ + id: number; + }> + >(); + }); + + it('should correctly infer the output type from a Zod schema with a transform for the handleSubmit function in useForm', () => { + const schema = z.object({ + id: z.pipe( + z.number(), + z.transform((val) => String(val)), + ), + }); + + const form = useForm({ + resolver: zodResolver(schema), + }); + + expectTypeOf(form.watch('id')).toEqualTypeOf(); + + expectTypeOf(form.handleSubmit).parameter(0).toEqualTypeOf< + SubmitHandler<{ + id: string; + }> + >(); + }); +}); diff --git a/zod/src/__tests__/zod.ts b/zod/src/__tests__/zod-v4.ts similarity index 93% rename from zod/src/__tests__/zod.ts rename to zod/src/__tests__/zod-v4.ts index 4b2f75b3..0a043bc0 100644 --- a/zod/src/__tests__/zod.ts +++ b/zod/src/__tests__/zod-v4.ts @@ -1,7 +1,7 @@ import { Resolver, SubmitHandler, useForm } from 'react-hook-form'; -import { z } from 'zod'; +import { z } from 'zod/v4'; import { zodResolver } from '..'; -import { fields, invalidData, schema, validData } from './__fixtures__/data'; +import { fields, invalidData, schema, validData } from './__fixtures__/data-v4'; const shouldUseNativeValidation = false; @@ -92,6 +92,21 @@ describe('zodResolver', () => { await expect(promise).rejects.toThrow('custom error'); }); + it('should enforce parse params type signature', async () => { + const resolver = zodResolver(schema, { + jitless: true, + reportInput: true, + error(iss) { + iss.path; + iss.code; + iss.path; + return { message: 'asdf' }; + }, + }); + + resolver; + }); + /** * Type inference tests */ diff --git a/zod/src/zod.ts b/zod/src/zod.ts index 064dcfde..0e227eb6 100644 --- a/zod/src/zod.ts +++ b/zod/src/zod.ts @@ -8,13 +8,29 @@ import { ResolverSuccess, appendErrors, } from 'react-hook-form'; -import { ZodError, z } from 'zod'; +import * as z3 from 'zod/v3'; +import * as z4 from 'zod/v4/core'; -const isZodError = (error: any): error is ZodError => - Array.isArray(error?.errors); +const isZod3Error = (error: any): error is z3.ZodError => { + return Array.isArray(error?.issues); +}; +const isZod3Schema = (schema: any): schema is z3.ZodSchema => { + return ( + '_def' in schema && + typeof schema._def === 'object' && + 'typeName' in schema._def + ); +}; +const isZod4Error = (error: any): error is z4.$ZodError => { + // instanceof is safe in Zod 4 (uses Symbol.hasInstance) + return error instanceof z4.$ZodError; +}; +const isZod4Schema = (schema: any): schema is z4.$ZodType => { + return '_zod' in schema && typeof schema._zod === 'object'; +}; -function parseErrorSchema( - zodErrors: z.ZodIssue[], +function parseZod3Issues( + zodErrors: z3.ZodIssue[], validateAllFieldCriteria: boolean, ) { const errors: Record = {}; @@ -63,37 +79,156 @@ function parseErrorSchema( return errors; } +function parseZod4Issues( + zodErrors: z4.$ZodIssue[], + validateAllFieldCriteria: boolean, +) { + const errors: Record = {}; + // const _zodErrors = zodErrors as z4.$ZodISsue; // + 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) => + unionError.forEach((e) => zodErrors.push(e)), + ); + } + + 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; +} + +type RawResolverOptions = { + mode?: 'async' | 'sync'; + raw: true; +}; +type NonRawResolverOptions = { + mode?: 'async' | 'sync'; + raw?: false; +}; + +// minimal interfaces to avoid asssignability issues between versions +interface Zod3Type { + _output: O; + _input: I; + _def: { + typeName: string; + }; +} + +// some type magic to make versions pre-3.25.0 still work +type IsUnresolved = PropertyKey extends keyof T ? true : false; +type UnresolvedFallback = IsUnresolved extends true + ? Fallback + : T; +type FallbackIssue = { + code: string; + message: string; + path: (string | number)[]; +}; +type Zod3ParseParams = UnresolvedFallback< + z3.ParseParams, + // fallback if user is on <3.25.0 + { + path?: (string | number)[]; + errorMap?: ( + iss: FallbackIssue, + ctx: { + defaultError: string; + data: any; + }, + ) => { message: string }; + async?: boolean; + } +>; +type Zod4ParseParams = UnresolvedFallback< + z4.ParseContext, + // fallback if user is on <3.25.0 + { + readonly error?: ( + iss: FallbackIssue, + ) => null | undefined | string | { message: string }; + readonly reportInput?: boolean; + readonly jitless?: boolean; + } +>; + export function zodResolver( - schema: z.ZodSchema, - schemaOptions?: Partial, - resolverOptions?: { - mode?: 'async' | 'sync'; - raw?: false; - }, + schema: Zod3Type, + schemaOptions?: Zod3ParseParams, + resolverOptions?: NonRawResolverOptions, ): Resolver; - export function zodResolver( - schema: z.ZodSchema, - schemaOptions: Partial | undefined, - resolverOptions: { - mode?: 'async' | 'sync'; - raw: true; - }, + schema: Zod3Type, + schemaOptions: Zod3ParseParams | undefined, + resolverOptions: RawResolverOptions, ): Resolver; - +// the Zod 4 overloads need to be generic for complicated reasons +export function zodResolver< + Input extends FieldValues, + Context, + Output, + T extends z4.$ZodType = z4.$ZodType, +>( + schema: T, + schemaOptions?: Zod4ParseParams, // already partial + resolverOptions?: NonRawResolverOptions, +): Resolver, Context, z4.output>; +export function zodResolver< + Input extends FieldValues, + Context, + Output, + T extends z4.$ZodType = z4.$ZodType, +>( + schema: z4.$ZodType, + schemaOptions: Zod4ParseParams | undefined, // already partial + resolverOptions: RawResolverOptions, +): Resolver, Context, z4.input>; /** * 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 {z3.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 + * @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) + * const schema = z3.object({ + * name: z3.string().min(2), + * age: z3.number().min(18) * }); * * useForm({ @@ -101,41 +236,80 @@ export function zodResolver( * }); */ export function zodResolver( - schema: z.ZodSchema, - schemaOptions?: Partial, + schema: object, + schemaOptions?: object, 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 (isZodError(error)) { + if (isZod3Schema(schema)) { + return async (values: Input, _, options) => { + try { + const data = await schema[ + resolverOptions.mode === 'sync' ? 'parse' : 'parseAsync' + ](values, schemaOptions); + + options.shouldUseNativeValidation && + validateFieldsNatively({}, options); + return { - values: {}, - errors: toNestErrors( - parseErrorSchema( - error.errors, - !options.shouldUseNativeValidation && - options.criteriaMode === 'all', + errors: {} as FieldErrors, + values: resolverOptions.raw ? Object.assign({}, values) : data, + } satisfies ResolverSuccess; + } catch (error) { + if (isZod3Error(error)) { + return { + values: {}, + errors: toNestErrors( + parseZod3Issues( + error.errors, + !options.shouldUseNativeValidation && + options.criteriaMode === 'all', + ), + options, ), - options, - ), - } satisfies ResolverError; + } satisfies ResolverError; + } + + throw error; } + }; + } - throw error; - } - }; + if (isZod4Schema(schema)) { + return async (values: Input, _, options) => { + try { + const parseFn = + resolverOptions.mode === 'sync' ? z4.parse : z4.parseAsync; + const data: any = await parseFn(schema, values, schemaOptions); + + options.shouldUseNativeValidation && + validateFieldsNatively({}, options); + + return { + errors: {} as FieldErrors, + values: resolverOptions.raw ? Object.assign({}, values) : data, + } satisfies ResolverSuccess; + } catch (error) { + if (isZod4Error(error)) { + return { + values: {}, + errors: toNestErrors( + parseZod4Issues( + error.issues, + !options.shouldUseNativeValidation && + options.criteriaMode === 'all', + ), + options, + ), + } satisfies ResolverError; + } + + throw error; + } + }; + } + + throw new Error('Invalid input: not a Zod schema'); }