diff --git a/README.md b/README.md
index 16a22980..43b72426 100644
--- a/README.md
+++ b/README.md
@@ -53,6 +53,7 @@ Install your preferred validation library alongside `@hookform/resolvers`.
- [effect-ts](#effect-ts)
- [VineJS](#vinejs)
- [fluentvalidation-ts](#fluentvalidation-ts)
+ - [standard-schema](#standard-schema)
- [Backers](#backers)
- [Sponsors](#sponsors)
- [Contributors](#contributors)
@@ -779,6 +780,72 @@ const App = () => {
};
```
+### [standard-schema](https://github.com/standard-schema/standard-schema)
+
+A standard interface for TypeScript schema validation libraries
+
+[](https://bundlephobia.com/result?p=@standard-schema/spec)
+
+Example zod
+
+```typescript jsx
+import { useForm } from 'react-hook-form';
+import { standardSchemaResolver } from '@hookform/resolvers/standard-schema';
+import { z } from 'zod';
+
+const schema = z.object({
+ name: z.string().min(1, { message: 'Required' }),
+ age: z.number().min(10),
+});
+
+const App = () => {
+ const {
+ register,
+ handleSubmit,
+ formState: { errors },
+ } = useForm({
+ resolver: standardSchemaResolver(schema),
+ });
+
+ return (
+
+ );
+};
+```
+
+Example arkType
+
+```typescript jsx
+import { useForm } from 'react-hook-form';
+import { standardSchemaResolver } from '@hookform/resolvers/standard-schema';
+import { type } from 'arktype';
+
+const schema = type({
+ username: 'string>1',
+ password: 'string>1',
+});
+
+const App = () => {
+ const { register, handleSubmit } = useForm({
+ resolver: standardSchemaResolver(schema),
+ });
+
+ return (
+
+ );
+};
+```
+
## Backers
Thanks go to all our backers! [[Become a backer](https://opencollective.com/react-hook-form#backer)].
diff --git a/bun.lockb b/bun.lockb
index 9d0994bb..84b7b9bd 100755
Binary files a/bun.lockb and b/bun.lockb differ
diff --git a/config/node-13-exports.js b/config/node-13-exports.js
index c60fd718..efab7b13 100644
--- a/config/node-13-exports.js
+++ b/config/node-13-exports.js
@@ -20,6 +20,7 @@ const subRepositories = [
'effect-ts',
'vine',
'fluentvalidation-ts',
+ 'standard-schema',
];
const copySrc = () => {
diff --git a/effect-ts/src/effect-ts.ts b/effect-ts/src/effect-ts.ts
index b99a7584..91130bed 100644
--- a/effect-ts/src/effect-ts.ts
+++ b/effect-ts/src/effect-ts.ts
@@ -2,7 +2,7 @@ import { toNestErrors, validateFieldsNatively } from '@hookform/resolvers';
import { Effect } from 'effect';
import { ArrayFormatter, decodeUnknown } from 'effect/ParseResult';
-import { appendErrors, type FieldError } from 'react-hook-form';
+import { type FieldError, appendErrors } from 'react-hook-form';
import type { Resolver } from './types';
export const effectTsResolver: Resolver =
diff --git a/package.json b/package.json
index 4463852c..b7f8352c 100644
--- a/package.json
+++ b/package.json
@@ -123,6 +123,12 @@
"import": "./fluentvalidation-ts/dist/fluentvalidation-ts.mjs",
"require": "./fluentvalidation-ts/dist/fluentvalidation-ts.js"
},
+ "./standard-schema": {
+ "types": "./standard-schema/dist/index.d.ts",
+ "umd": "./standard-schema/dist/standard-schema.umd.js",
+ "import": "./standard-schema/dist/standard-schema.mjs",
+ "require": "./standard-schema/dist/standard-schema.js"
+ },
"./package.json": "./package.json",
"./*": "./*"
},
@@ -184,7 +190,10 @@
"vine/dist",
"fluentvalidation-ts/package.json",
"fluentvalidation-ts/src",
- "fluentvalidation-ts/dist"
+ "fluentvalidation-ts/dist",
+ "standard-schema/package.json",
+ "standard-schema/src",
+ "standard-schema/dist"
],
"publishConfig": {
"access": "public"
@@ -211,6 +220,7 @@
"build:effect-ts": "microbundle --cwd effect-ts --globals @hookform/resolvers=hookformResolvers,react-hook-form=ReactHookForm,effect=Effect,effect/SchemaAST=EffectSchemaAST,effect/ParseResult=EffectParseResult",
"build:vine": "microbundle --cwd vine --globals @hookform/resolvers=hookformResolvers,react-hook-form=ReactHookForm,@vinejs/vine=vine",
"build:fluentvalidation-ts": "microbundle --cwd fluentvalidation-ts --globals @hookform/resolvers=hookformResolvers,react-hook-form=ReactHookForm",
+ "build:standard-schema": "microbundle --cwd standard-schema --globals @hookform/resolvers=hookformResolvers,react-hook-form=ReactHookForm,@standard-schema/spec=standardSchema",
"postbuild": "node ./config/node-13-exports.js && check-export-map",
"lint": "bunx @biomejs/biome check --write --vcs-use-ignore-file=true .",
"lint:types": "tsc",
@@ -243,7 +253,8 @@
"arktype",
"typeschema",
"vine",
- "fluentvalidation-ts"
+ "fluentvalidation-ts",
+ "standard-schema"
],
"repository": {
"type": "git",
@@ -257,6 +268,7 @@
"homepage": "https://react-hook-form.com",
"devDependencies": {
"@sinclair/typebox": "^0.34.15",
+ "@standard-schema/spec": "^1.0.0",
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.2.0",
diff --git a/standard-schema/package.json b/standard-schema/package.json
new file mode 100644
index 00000000..de255f3d
--- /dev/null
+++ b/standard-schema/package.json
@@ -0,0 +1,18 @@
+{
+ "name": "@hookform/resolvers/standard-schema",
+ "amdName": "hookformResolversStandardSchema",
+ "version": "1.0.0",
+ "private": true,
+ "description": "React Hook Form validation resolver: standard-schema",
+ "main": "dist/standard-schema.js",
+ "module": "dist/standard-schema.module.js",
+ "umd:main": "dist/standard-schema.umd.js",
+ "source": "src/index.ts",
+ "types": "dist/index.d.ts",
+ "license": "MIT",
+ "peerDependencies": {
+ "react-hook-form": "^7.0.0",
+ "@standard-schema/spec": "^1.0.0",
+ "@hookform/resolvers": "^2.0.0"
+ }
+}
diff --git a/standard-schema/src/__tests__/Form-native-validation.tsx b/standard-schema/src/__tests__/Form-native-validation.tsx
new file mode 100644
index 00000000..2f965099
--- /dev/null
+++ b/standard-schema/src/__tests__/Form-native-validation.tsx
@@ -0,0 +1,82 @@
+import { render, screen } from '@testing-library/react';
+import user from '@testing-library/user-event';
+import { type } from 'arktype';
+import React from 'react';
+import { useForm } from 'react-hook-form';
+import { standardSchemaResolver } from '..';
+
+const schema = type({
+ username: 'string>1',
+ password: 'string>1',
+});
+
+type FormData = typeof schema.infer;
+
+interface Props {
+ onSubmit: (data: FormData) => void;
+}
+
+function TestComponent({ onSubmit }: Props) {
+ const { register, handleSubmit } = useForm({
+ resolver: standardSchemaResolver(schema),
+ shouldUseNativeValidation: true,
+ });
+
+ return (
+
+ );
+}
+
+test("form's native validation with arkType", async () => {
+ const handleSubmit = vi.fn();
+ render();
+
+ // username
+ let usernameField = screen.getByPlaceholderText(
+ /username/i,
+ ) as HTMLInputElement;
+ expect(usernameField.validity.valid).toBe(true);
+ expect(usernameField.validationMessage).toBe('');
+
+ // password
+ let passwordField = screen.getByPlaceholderText(
+ /password/i,
+ ) as HTMLInputElement;
+ expect(passwordField.validity.valid).toBe(true);
+ expect(passwordField.validationMessage).toBe('');
+
+ await user.click(screen.getByText(/submit/i));
+
+ // username
+ usernameField = screen.getByPlaceholderText(/username/i) as HTMLInputElement;
+ expect(usernameField.validity.valid).toBe(false);
+ expect(usernameField.validationMessage).toBe(
+ 'username must be at least length 2',
+ );
+
+ // password
+ passwordField = screen.getByPlaceholderText(/password/i) as HTMLInputElement;
+ expect(passwordField.validity.valid).toBe(false);
+ expect(passwordField.validationMessage).toBe(
+ 'password must be at least length 2',
+ );
+
+ await user.type(screen.getByPlaceholderText(/username/i), 'joe');
+ await user.type(screen.getByPlaceholderText(/password/i), 'password');
+
+ // username
+ usernameField = screen.getByPlaceholderText(/username/i) as HTMLInputElement;
+ expect(usernameField.validity.valid).toBe(true);
+ expect(usernameField.validationMessage).toBe('');
+
+ // password
+ passwordField = screen.getByPlaceholderText(/password/i) as HTMLInputElement;
+ expect(passwordField.validity.valid).toBe(true);
+ expect(passwordField.validationMessage).toBe('');
+});
diff --git a/standard-schema/src/__tests__/Form.tsx b/standard-schema/src/__tests__/Form.tsx
new file mode 100644
index 00000000..500a46d1
--- /dev/null
+++ b/standard-schema/src/__tests__/Form.tsx
@@ -0,0 +1,56 @@
+import { render, screen } from '@testing-library/react';
+import user from '@testing-library/user-event';
+import { type } from 'arktype';
+import React from 'react';
+import { useForm } from 'react-hook-form';
+import { standardSchemaResolver } from '..';
+
+const schema = type({
+ username: 'string>1',
+ password: 'string>1',
+});
+
+type FormData = typeof schema.infer & { unusedProperty: string };
+
+interface Props {
+ onSubmit: (data: FormData) => void;
+}
+
+function TestComponent({ onSubmit }: Props) {
+ const {
+ register,
+ handleSubmit,
+ formState: { errors },
+ } = useForm({
+ resolver: standardSchemaResolver(schema), // Useful to check TypeScript regressions
+ });
+
+ return (
+
+ );
+}
+
+test("form's validation with arkType and TypeScript's integration", async () => {
+ const handleSubmit = vi.fn();
+ render();
+
+ expect(screen.queryAllByRole('alert')).toHaveLength(0);
+
+ await user.click(screen.getByText(/submit/i));
+
+ expect(
+ screen.getByText('username must be at least length 2'),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByText('password must be at least length 2'),
+ ).toBeInTheDocument();
+ expect(handleSubmit).not.toHaveBeenCalled();
+});
diff --git a/standard-schema/src/__tests__/__fixtures__/data.ts b/standard-schema/src/__tests__/__fixtures__/data.ts
new file mode 100644
index 00000000..5f0495e7
--- /dev/null
+++ b/standard-schema/src/__tests__/__fixtures__/data.ts
@@ -0,0 +1,65 @@
+import { type } from 'arktype';
+import { Field, InternalFieldName } from 'react-hook-form';
+
+export const schema = type({
+ username: 'string>2',
+ password: '/.*[A-Za-z].*/>8|/.*\\d.*/',
+ repeatPassword: 'string>1',
+ accessToken: 'string|number',
+ birthYear: '19001',
+ 'like?': type({
+ id: 'number',
+ name: 'string>3',
+ }).array(),
+ dateStr: 'Date',
+});
+
+export const validData: typeof schema.infer = {
+ 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: new Date('2020-01-01'),
+};
+
+export const invalidData = {
+ password: '___',
+ email: '',
+ birthYear: 'birthYear',
+ like: [{ id: 'z' }],
+ url: 'abc',
+};
+
+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/standard-schema/src/__tests__/__snapshots__/standard-schema.ts.snap b/standard-schema/src/__tests__/__snapshots__/standard-schema.ts.snap
new file mode 100644
index 00000000..5000df6c
--- /dev/null
+++ b/standard-schema/src/__tests__/__snapshots__/standard-schema.ts.snap
@@ -0,0 +1,63 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`standardSchemaResolver > should return a single error from standardSchemaResolver when validation fails 1`] = `
+{
+ "errors": {
+ "accessToken": {
+ "message": "accessToken must be a number or a string (was missing)",
+ "ref": undefined,
+ },
+ "birthYear": {
+ "message": "birthYear must be a number (was a string)",
+ "ref": undefined,
+ },
+ "dateStr": {
+ "message": "dateStr must be a Date (was missing)",
+ "ref": undefined,
+ },
+ "email": {
+ "message": "email must be an email address (was "")",
+ "ref": {
+ "name": "email",
+ },
+ },
+ "enabled": {
+ "message": "enabled must be boolean (was missing)",
+ "ref": undefined,
+ },
+ "like": [
+ {
+ "id": {
+ "message": "like[0].id must be a number (was a string)",
+ "ref": undefined,
+ },
+ "name": {
+ "message": "like[0].name must be a string (was missing)",
+ "ref": undefined,
+ },
+ },
+ ],
+ "password": {
+ "message": "password must be matched by .*[A-Za-z].* or matched by .*\\d.* (was "___")",
+ "ref": {
+ "name": "password",
+ },
+ },
+ "repeatPassword": {
+ "message": "repeatPassword must be a string (was missing)",
+ "ref": undefined,
+ },
+ "tags": {
+ "message": "tags must be an array (was missing)",
+ "ref": undefined,
+ },
+ "username": {
+ "message": "username must be a string (was missing)",
+ "ref": {
+ "name": "username",
+ },
+ },
+ },
+ "values": {},
+}
+`;
diff --git a/standard-schema/src/__tests__/standard-schema.ts b/standard-schema/src/__tests__/standard-schema.ts
new file mode 100644
index 00000000..638d5d0d
--- /dev/null
+++ b/standard-schema/src/__tests__/standard-schema.ts
@@ -0,0 +1,28 @@
+import { standardSchemaResolver } from '..';
+import { fields, invalidData, schema, validData } from './__fixtures__/data';
+
+const shouldUseNativeValidation = false;
+
+describe('standardSchemaResolver', () => {
+ it('should return values from standardSchemaResolver when validation pass & raw=true', async () => {
+ const result = await standardSchemaResolver(schema)(validData, undefined, {
+ fields,
+ shouldUseNativeValidation,
+ });
+
+ expect(result).toEqual({ errors: {}, values: validData });
+ });
+
+ it('should return a single error from standardSchemaResolver when validation fails', async () => {
+ const result = await standardSchemaResolver(schema)(
+ invalidData,
+ undefined,
+ {
+ fields,
+ shouldUseNativeValidation,
+ },
+ );
+
+ expect(result).toMatchSnapshot();
+ });
+});
diff --git a/standard-schema/src/index.ts b/standard-schema/src/index.ts
new file mode 100644
index 00000000..5ee0d3cf
--- /dev/null
+++ b/standard-schema/src/index.ts
@@ -0,0 +1,2 @@
+export * from './standard-schema';
+export * from './types';
diff --git a/standard-schema/src/standard-schema.ts b/standard-schema/src/standard-schema.ts
new file mode 100644
index 00000000..12d9a9a1
--- /dev/null
+++ b/standard-schema/src/standard-schema.ts
@@ -0,0 +1,45 @@
+import { toNestErrors, validateFieldsNatively } from '@hookform/resolvers';
+import { StandardSchemaV1 } from '@standard-schema/spec';
+import { FieldError } from 'react-hook-form';
+import type { Resolver } from './types';
+
+const parseIssues = (issues: readonly StandardSchemaV1.Issue[]) => {
+ const errors: Record = {};
+
+ for (let i = 0; i < issues.length; i++) {
+ const issue = issues[i];
+ const path = issue.path?.join('.') ?? '';
+
+ if (path) {
+ if (!errors[path]) {
+ errors[path] = { message: issue.message } as FieldError;
+ }
+ }
+ }
+
+ return errors;
+};
+
+export const standardSchemaResolver: Resolver =
+ (schema) => async (values, _, options) => {
+ let result = schema['~standard'].validate(values);
+ if (result instanceof Promise) {
+ result = await result;
+ }
+
+ if (result.issues) {
+ const errors = parseIssues(result.issues);
+
+ return {
+ values: {},
+ errors: toNestErrors(errors, options),
+ };
+ }
+
+ options.shouldUseNativeValidation && validateFieldsNatively({}, options);
+
+ return {
+ values: values,
+ errors: {},
+ };
+ };
diff --git a/standard-schema/src/types.ts b/standard-schema/src/types.ts
new file mode 100644
index 00000000..c1b8d51e
--- /dev/null
+++ b/standard-schema/src/types.ts
@@ -0,0 +1,10 @@
+import { StandardSchemaV1 } from '@standard-schema/spec';
+import { FieldValues, ResolverOptions, ResolverResult } from 'react-hook-form';
+
+export type Resolver = >(
+ schema: T,
+) => (
+ values: TFieldValues,
+ context: TContext | undefined,
+ options: ResolverOptions,
+) => Promise>;