Skip to content

Commit 6a31c92

Browse files
authored
feat(typeboxResolver): add TypeBox resolver (#526)
1 parent 503d973 commit 6a31c92

File tree

13 files changed

+588
-3
lines changed

13 files changed

+588
-3
lines changed

README.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -455,6 +455,37 @@ const App = () => {
455455
};
456456
```
457457

458+
### [TypeBox](https://github.com/sinclairzx81/typebox)
459+
460+
JSON Schema Type Builder with Static Type Resolution for TypeScript
461+
462+
[![npm](https://img.shields.io/bundlephobia/minzip/@sinclair/typebox ?style=for-the-badge)](https://bundlephobia.com/result?p=@sinclair/typebox)
463+
464+
```typescript jsx
465+
import { useForm } from 'react-hook-form';
466+
import { typeboxResolver } from '@hookform/resolvers/typebox';
467+
import { Type } from '@sinclair/typebox';
468+
469+
const schema = Type.Object({
470+
username: Type.String({ minLength: 1 }),
471+
password: Type.String({ minLength: 1 }),
472+
});
473+
474+
const App = () => {
475+
const { register, handleSubmit } = useForm({
476+
resolver: typeboxResolver(schema),
477+
});
478+
479+
return (
480+
<form onSubmit={handleSubmit((d) => console.log(d))}>
481+
<input {...register('name')} />
482+
<input type="number" {...register('age')} />
483+
<input type="submit" />
484+
</form>
485+
);
486+
};
487+
```
488+
458489
## Backers
459490

460491
Thanks goes to all our backers! [[Become a backer](https://opencollective.com/react-hook-form#backer)].

config/node-13-exports.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ const subRepositories = [
1313
'computed-types',
1414
'typanion',
1515
'ajv',
16+
'typebox',
1617
];
1718

1819
const copySrc = () => {

package.json

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"name": "@hookform/resolvers",
33
"amdName": "hookformResolvers",
44
"version": "2.9.1",
5-
"description": "React Hook Form validation resolvers: Yup, Joi, Superstruct, Zod, Vest, Class Validator, io-ts, Nope, computed-types and Typanion",
5+
"description": "React Hook Form validation resolvers: Yup, Joi, Superstruct, Zod, Vest, Class Validator, io-ts, Nope, computed-types, TypeBox and Typanion",
66
"main": "dist/resolvers.js",
77
"module": "dist/resolvers.module.js",
88
"umd:main": "dist/resolvers.umd.js",
@@ -21,6 +21,12 @@
2121
"import": "./zod/dist/zod.mjs",
2222
"require": "./zod/dist/zod.js"
2323
},
24+
"./typebox": {
25+
"types": "./typebox/dist/index.d.ts",
26+
"umd": "./typebox/dist/typebox.umd.js",
27+
"import": "./typebox/dist/typebox.mjs",
28+
"require": "./typebox/dist/typebox.js"
29+
},
2430
"./yup": {
2531
"types": "./yup/dist/index.d.ts",
2632
"umd": "./yup/dist/yup.umd.js",
@@ -118,7 +124,10 @@
118124
"typanion/dist",
119125
"ajv/package.json",
120126
"ajv/src",
121-
"ajv/dist"
127+
"ajv/dist",
128+
"typebox/package.json",
129+
"typebox/src",
130+
"typebox/dist"
122131
],
123132
"publishConfig": {
124133
"access": "public"
@@ -138,6 +147,7 @@
138147
"build:computed-types": "microbundle --cwd computed-types --globals @hookform/resolvers=hookformResolvers,react-hook-form=ReactHookForm",
139148
"build:typanion": "microbundle --cwd typanion --globals @hookform/resolvers=hookformResolvers,react-hook-form=ReactHookForm",
140149
"build:ajv": "microbundle --cwd ajv --globals @hookform/resolvers=hookformResolvers,react-hook-form=ReactHookForm",
150+
"build:typebox": "microbundle --cwd typebox --globals @hookform/resolvers=hookformResolvers,react-hook-form=ReactHookForm",
141151
"postbuild": "node ./config/node-13-exports.js",
142152
"lint": "eslint . --ext .ts,.js --ignore-path .gitignore",
143153
"lint:types": "tsc",
@@ -162,7 +172,8 @@
162172
"nope",
163173
"computed-types",
164174
"typanion",
165-
"ajv"
175+
"ajv",
176+
"TypeBox"
166177
],
167178
"repository": {
168179
"type": "git",
@@ -175,6 +186,7 @@
175186
},
176187
"homepage": "https://react-hook-form.com",
177188
"devDependencies": {
189+
"@sinclair/typebox": "^0.25.24",
178190
"@testing-library/dom": "^9.0.1",
179191
"@testing-library/jest-dom": "^5.16.5",
180192
"@testing-library/react": "^14.0.0",

pnpm-lock.yaml

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

typebox/package.json

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
{
2+
"name": "@hookform/resolvers/typebox",
3+
"amdName": "hookformResolversTypebox",
4+
"version": "1.0.0",
5+
"private": true,
6+
"description": "React Hook Form validation resolver: typebox",
7+
"main": "dist/typebox.js",
8+
"module": "dist/typebox.module.js",
9+
"umd:main": "dist/typebox.umd.js",
10+
"source": "src/index.ts",
11+
"types": "dist/index.d.ts",
12+
"license": "MIT",
13+
"peerDependencies": {
14+
"react-hook-form": "^7.0.0",
15+
"@hookform/resolvers": "^2.0.0",
16+
"@sinclair/typebox": "^0.25.24"
17+
}
18+
}
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import React from 'react';
2+
import { useForm } from 'react-hook-form';
3+
import { render, screen } from '@testing-library/react';
4+
import user from '@testing-library/user-event';
5+
import { typeboxResolver } from '..';
6+
7+
import { Type, Static } from '@sinclair/typebox';
8+
9+
const schema = Type.Object({
10+
username: Type.String({ minLength: 1 }),
11+
password: Type.String({ minLength: 1 }),
12+
});
13+
14+
type FormData = Static<typeof schema>;
15+
16+
interface Props {
17+
onSubmit: (data: FormData) => void;
18+
}
19+
20+
function TestComponent({ onSubmit }: Props) {
21+
const { register, handleSubmit } = useForm<FormData>({
22+
resolver: typeboxResolver(schema),
23+
shouldUseNativeValidation: true,
24+
});
25+
26+
return (
27+
<form onSubmit={handleSubmit(onSubmit)}>
28+
<input {...register('username')} placeholder="username" />
29+
30+
<input {...register('password')} placeholder="password" />
31+
32+
<button type="submit">submit</button>
33+
</form>
34+
);
35+
}
36+
37+
test("form's native validation with Zod", async () => {
38+
const handleSubmit = vi.fn();
39+
render(<TestComponent onSubmit={handleSubmit} />);
40+
41+
// username
42+
let usernameField = screen.getByPlaceholderText(
43+
/username/i,
44+
) as HTMLInputElement;
45+
expect(usernameField.validity.valid).toBe(true);
46+
expect(usernameField.validationMessage).toBe('');
47+
48+
// password
49+
let passwordField = screen.getByPlaceholderText(
50+
/password/i,
51+
) as HTMLInputElement;
52+
expect(passwordField.validity.valid).toBe(true);
53+
expect(passwordField.validationMessage).toBe('');
54+
55+
await user.click(screen.getByText(/submit/i));
56+
57+
// username
58+
usernameField = screen.getByPlaceholderText(/username/i) as HTMLInputElement;
59+
expect(usernameField.validity.valid).toBe(false);
60+
expect(usernameField.validationMessage).toBe(
61+
'Expected string length greater or equal to 1',
62+
);
63+
64+
// password
65+
passwordField = screen.getByPlaceholderText(/password/i) as HTMLInputElement;
66+
expect(passwordField.validity.valid).toBe(false);
67+
expect(passwordField.validationMessage).toBe(
68+
'Expected string length greater or equal to 1',
69+
);
70+
71+
await user.type(screen.getByPlaceholderText(/username/i), 'joe');
72+
await user.type(screen.getByPlaceholderText(/password/i), 'password');
73+
74+
// username
75+
usernameField = screen.getByPlaceholderText(/username/i) as HTMLInputElement;
76+
expect(usernameField.validity.valid).toBe(true);
77+
expect(usernameField.validationMessage).toBe('');
78+
79+
// password
80+
passwordField = screen.getByPlaceholderText(/password/i) as HTMLInputElement;
81+
expect(passwordField.validity.valid).toBe(true);
82+
expect(passwordField.validationMessage).toBe('');
83+
});

typebox/src/__tests__/Form.tsx

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import React from 'react';
2+
import { render, screen } from '@testing-library/react';
3+
import user from '@testing-library/user-event';
4+
import { useForm } from 'react-hook-form';
5+
import { typeboxResolver } from '..';
6+
import { Type, Static } from '@sinclair/typebox';
7+
8+
const schema = Type.Object({
9+
username: Type.String({ minLength: 1 }),
10+
password: Type.String({ minLength: 1 }),
11+
});
12+
13+
type FormData = Static<typeof schema> & { unusedProperty: string };
14+
15+
interface Props {
16+
onSubmit: (data: FormData) => void;
17+
}
18+
19+
function TestComponent({ onSubmit }: Props) {
20+
const {
21+
register,
22+
handleSubmit,
23+
formState: { errors },
24+
} = useForm<FormData>({
25+
resolver: typeboxResolver(schema), // Useful to check TypeScript regressions
26+
});
27+
28+
return (
29+
<form onSubmit={handleSubmit(onSubmit)}>
30+
<input {...register('username')} />
31+
{errors.username && <span role="alert">{errors.username.message}</span>}
32+
33+
<input {...register('password')} />
34+
{errors.password && <span role="alert">{errors.password.message}</span>}
35+
36+
<button type="submit">submit</button>
37+
</form>
38+
);
39+
}
40+
41+
test("form's validation with Zod and TypeScript's integration", async () => {
42+
const handleSubmit = vi.fn();
43+
render(<TestComponent onSubmit={handleSubmit} />);
44+
45+
expect(screen.queryAllByRole('alert')).toHaveLength(0);
46+
47+
await user.click(screen.getByText(/submit/i));
48+
49+
expect(
50+
screen.getAllByText(/Expected string length greater or equal to 1/i),
51+
).toHaveLength(2);
52+
53+
expect(handleSubmit).not.toHaveBeenCalled();
54+
});
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import { Field, InternalFieldName } from 'react-hook-form';
2+
import { Static, Type } from '@sinclair/typebox';
3+
import { TypeSystem } from '@sinclair/typebox/system';
4+
5+
TypeSystem.CreateFormat('email', (value: string) => {
6+
return /^\S+@\S+$/.test(value);
7+
});
8+
9+
export const schema = Type.Object({
10+
username: Type.String({
11+
minLength: 3,
12+
maxLength: 30,
13+
pattern: '^[a-zA-Z0-9]+$',
14+
}),
15+
password: Type.String({
16+
minLength: 8,
17+
pattern: '^(.*[A-Za-z\\d].*)$',
18+
}),
19+
repeatPassword: Type.String(),
20+
accessToken: Type.Union([Type.String(), Type.Number()]),
21+
birthYear: Type.Optional(
22+
Type.Number({
23+
minimum: 1900,
24+
maximum: 2013,
25+
}),
26+
),
27+
email: Type.Optional(Type.String({ format: 'email' })),
28+
tags: Type.Array(Type.String()),
29+
enabled: Type.Boolean(),
30+
like: Type.Optional(
31+
Type.Array(
32+
Type.Object({
33+
id: Type.Number(),
34+
name: Type.String({ minLength: 4, maxLength: 4 }),
35+
}),
36+
),
37+
),
38+
dateStr: Type.Date(),
39+
});
40+
41+
export const validData: Static<typeof schema> = {
42+
username: 'Doe',
43+
password: 'Password123_',
44+
repeatPassword: 'Password123_',
45+
birthYear: 2000,
46+
email: 'tsulatsitamim@gmail.com',
47+
tags: ['tag1', 'tag2'],
48+
enabled: true,
49+
accessToken: 'accessToken',
50+
like: [
51+
{
52+
id: 1,
53+
name: 'name',
54+
},
55+
],
56+
dateStr: new Date(),
57+
};
58+
59+
export const invalidData = {
60+
password: '___',
61+
email: '',
62+
birthYear: 'birthYear',
63+
like: [{ id: 'z' }],
64+
url: 'abc',
65+
};
66+
67+
export const fields: Record<InternalFieldName, Field['_f']> = {
68+
username: {
69+
ref: { name: 'username' },
70+
name: 'username',
71+
},
72+
password: {
73+
ref: { name: 'password' },
74+
name: 'password',
75+
},
76+
email: {
77+
ref: { name: 'email' },
78+
name: 'email',
79+
},
80+
birthday: {
81+
ref: { name: 'birthday' },
82+
name: 'birthday',
83+
},
84+
};

0 commit comments

Comments
 (0)