Skip to content

Commit 6124c59

Browse files
feat: infer input/output types from schema (#753)
BREAKING CHANGE: * Requires [email protected] or higher --------- Co-authored-by: Kotaro Sugawara <[email protected]>
1 parent ded1746 commit 6124c59

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

68 files changed

+1471
-581
lines changed

.github/workflows/compressedSize.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,4 @@ jobs:
1616
- uses: preactjs/compressed-size-action@v2
1717
with:
1818
repo-token: '${{ secrets.GITHUB_TOKEN }}'
19+
install-script: "bun install --frozen-lockfile"

.husky/pre-commit

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@ call_lefthook()
3333
then
3434
"$dir/node_modules/lefthook/bin/index.js" "$@"
3535

36+
elif go tool lefthook -h >/dev/null 2>&1
37+
then
38+
go tool lefthook "$@"
3639
elif bundle exec lefthook -h >/dev/null 2>&1
3740
then
3841
bundle exec lefthook "$@"
@@ -42,9 +45,9 @@ call_lefthook()
4245
elif pnpm lefthook -h >/dev/null 2>&1
4346
then
4447
pnpm lefthook "$@"
45-
elif swift package plugin lefthook >/dev/null 2>&1
48+
elif swift package lefthook >/dev/null 2>&1
4649
then
47-
swift package --disable-sandbox plugin lefthook "$@"
50+
swift package --build-path .build/lefthook --disable-sandbox lefthook "$@"
4851
elif command -v mint >/dev/null 2>&1
4952
then
5053
mint run csjones/lefthook-plugin "$@"

.husky/prepare-commit-msg

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@ call_lefthook()
3333
then
3434
"$dir/node_modules/lefthook/bin/index.js" "$@"
3535

36+
elif go tool lefthook -h >/dev/null 2>&1
37+
then
38+
go tool lefthook "$@"
3639
elif bundle exec lefthook -h >/dev/null 2>&1
3740
then
3841
bundle exec lefthook "$@"
@@ -42,9 +45,9 @@ call_lefthook()
4245
elif pnpm lefthook -h >/dev/null 2>&1
4346
then
4447
pnpm lefthook "$@"
45-
elif swift package plugin lefthook >/dev/null 2>&1
48+
elif swift package lefthook >/dev/null 2>&1
4649
then
47-
swift package --disable-sandbox plugin lefthook "$@"
50+
swift package --build-path .build/lefthook --disable-sandbox lefthook "$@"
4851
elif command -v mint >/dev/null 2>&1
4952
then
5053
mint run csjones/lefthook-plugin "$@"

README.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,36 @@ Install your preferred validation library alongside `@hookform/resolvers`.
5555
| zod || `firstError | all` |
5656
</details>
5757

58+
## TypeScript
59+
60+
Most of the resolvers can infer the output type from the schema. See comparison table for more details.
61+
62+
```tsx
63+
useForm<Input, Context, Output>()
64+
```
65+
66+
Example:
67+
68+
```tsx
69+
import { useForm } from 'react-hook-form';
70+
import { zodResolver } from '@hookform/resolvers/zod';
71+
import { z } from 'zod';
72+
73+
const schema = z.object({
74+
id: z.number(),
75+
});
76+
77+
// Automatically infers the output type from the schema
78+
useForm({
79+
resolver: zodResolver(schema),
80+
});
81+
82+
// Force the output type
83+
useForm<z.input<typeof schema>, any, z.output<typeof schema>>({
84+
resolver: zodResolver(schema),
85+
});
86+
```
87+
5888
## Links
5989

6090
- [React-hook-form validation resolver documentation ](https://react-hook-form.com/docs/useform#resolver)

ajv/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
"types": "dist/index.d.ts",
1212
"license": "MIT",
1313
"peerDependencies": {
14-
"react-hook-form": "^7.0.0",
14+
"react-hook-form": "7.55.0",
1515
"@hookform/resolvers": "^2.0.0",
1616
"ajv": "^8.12.0",
1717
"ajv-errors": "^3.0.0"

arktype/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
"types": "dist/index.d.ts",
1212
"license": "MIT",
1313
"peerDependencies": {
14-
"react-hook-form": "^7.0.0",
14+
"react-hook-form": "7.55.0",
1515
"@hookform/resolvers": "^2.0.0",
1616
"arktype": "^2.0.0"
1717
}

arktype/src/__tests__/Form.tsx

Lines changed: 0 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,6 @@ const schema = type({
1010
password: 'string>1',
1111
});
1212

13-
type FormData = typeof schema.infer & { unusedProperty: string };
14-
1513
function TestComponent({
1614
onSubmit,
1715
}: {
@@ -54,29 +52,3 @@ test("form's validation with arkType and TypeScript's integration", async () =>
5452
).toBeInTheDocument();
5553
expect(handleSubmit).not.toHaveBeenCalled();
5654
});
57-
58-
export function TestComponentManualType({
59-
onSubmit,
60-
}: {
61-
onSubmit: (data: FormData) => void;
62-
}) {
63-
const {
64-
register,
65-
handleSubmit,
66-
formState: { errors },
67-
} = useForm<typeof schema.infer, undefined, FormData>({
68-
resolver: arktypeResolver(schema), // Useful to check TypeScript regressions
69-
});
70-
71-
return (
72-
<form onSubmit={handleSubmit(onSubmit)}>
73-
<input {...register('username')} />
74-
{errors.username && <span role="alert">{errors.username.message}</span>}
75-
76-
<input {...register('password')} />
77-
{errors.password && <span role="alert">{errors.password.message}</span>}
78-
79-
<button type="submit">submit</button>
80-
</form>
81-
);
82-
}

arktype/src/__tests__/arktype.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import { type } from 'arktype';
2+
import { Resolver, useForm } from 'react-hook-form';
3+
import { SubmitHandler } from 'react-hook-form';
14
import { arktypeResolver } from '..';
25
import { fields, invalidData, schema, validData } from './__fixtures__/data';
36

@@ -23,4 +26,57 @@ describe('arktypeResolver', () => {
2326

2427
expect(result).toMatchSnapshot();
2528
});
29+
30+
/**
31+
* Type inference tests
32+
*/
33+
it('should correctly infer the output type from a arktype schema', () => {
34+
const resolver = arktypeResolver(type({ id: 'number' }));
35+
36+
expectTypeOf(resolver).toEqualTypeOf<
37+
Resolver<{ id: number }, unknown, { id: number }>
38+
>();
39+
});
40+
41+
it('should correctly infer the output type from a arktype schema using a transform', () => {
42+
const resolver = arktypeResolver(
43+
type({ id: type('string').pipe((s) => Number.parseInt(s)) }),
44+
);
45+
46+
expectTypeOf(resolver).toEqualTypeOf<
47+
Resolver<{ id: string }, unknown, { id: number }>
48+
>();
49+
});
50+
51+
it('should correctly infer the output type from a arktype schema for the handleSubmit function in useForm', () => {
52+
const schema = type({ id: 'number' });
53+
54+
const form = useForm({
55+
resolver: arktypeResolver(schema),
56+
});
57+
58+
expectTypeOf(form.watch('id')).toEqualTypeOf<number>();
59+
60+
expectTypeOf(form.handleSubmit).parameter(0).toEqualTypeOf<
61+
SubmitHandler<{
62+
id: number;
63+
}>
64+
>();
65+
});
66+
67+
it('should correctly infer the output type from a arktype schema with a transform for the handleSubmit function in useForm', () => {
68+
const schema = type({ id: type('string').pipe((s) => Number.parseInt(s)) });
69+
70+
const form = useForm({
71+
resolver: arktypeResolver(schema),
72+
});
73+
74+
expectTypeOf(form.watch('id')).toEqualTypeOf<string>();
75+
76+
expectTypeOf(form.handleSubmit).parameter(0).toEqualTypeOf<
77+
SubmitHandler<{
78+
id: number;
79+
}>
80+
>();
81+
});
2682
});

arktype/src/arktype.ts

Lines changed: 58 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,53 @@
11
import { toNestErrors, validateFieldsNatively } from '@hookform/resolvers';
2-
import { ArkErrors, Type } from 'arktype';
3-
import { FieldError, FieldErrors, Resolver } from 'react-hook-form';
2+
import { StandardSchemaV1 } from '@standard-schema/spec';
3+
import { getDotPath } from '@standard-schema/utils';
4+
import { FieldError, FieldValues, Resolver } from 'react-hook-form';
45

5-
function parseErrorSchema(arkErrors: ArkErrors): Record<string, FieldError> {
6-
const errors = [...arkErrors];
7-
const fieldsErrors: Record<string, FieldError> = {};
6+
function parseErrorSchema(
7+
issues: readonly StandardSchemaV1.Issue[],
8+
validateAllFieldCriteria: boolean,
9+
) {
10+
const errors: Record<string, FieldError> = {};
811

9-
for (; errors.length; ) {
10-
const error = errors[0];
11-
const _path = error.path.join('.');
12+
for (let i = 0; i < issues.length; i++) {
13+
const error = issues[i];
14+
const path = getDotPath(error);
1215

13-
if (!fieldsErrors[_path]) {
14-
fieldsErrors[_path] = { message: error.message, type: error.code };
15-
}
16+
if (path) {
17+
if (!errors[path]) {
18+
errors[path] = { message: error.message, type: '' };
19+
}
20+
21+
if (validateAllFieldCriteria) {
22+
const types = errors[path].types || {};
1623

17-
errors.shift();
24+
errors[path].types = {
25+
...types,
26+
[Object.keys(types).length]: error.message,
27+
};
28+
}
29+
}
1830
}
1931

20-
return fieldsErrors;
32+
return errors;
2133
}
2234

35+
export function arktypeResolver<Input extends FieldValues, Context, Output>(
36+
schema: StandardSchemaV1<Input, Output>,
37+
_schemaOptions?: never,
38+
resolverOptions?: {
39+
raw?: false;
40+
},
41+
): Resolver<Input, Context, Output>;
42+
43+
export function arktypeResolver<Input extends FieldValues, Context, Output>(
44+
schema: StandardSchemaV1<Input, Output>,
45+
_schemaOptions: never | undefined,
46+
resolverOptions: {
47+
raw: true;
48+
},
49+
): Resolver<Input, Context, Input>;
50+
2351
/**
2452
* Creates a resolver for react-hook-form using Arktype schema validation
2553
* @param {Schema} schema - The Arktype schema to validate against
@@ -35,28 +63,36 @@ function parseErrorSchema(arkErrors: ArkErrors): Record<string, FieldError> {
3563
* resolver: arktypeResolver(schema)
3664
* });
3765
*/
38-
export function arktypeResolver<Schema extends Type<any, any>>(
39-
schema: Schema,
66+
export function arktypeResolver<Input extends FieldValues, Context, Output>(
67+
schema: StandardSchemaV1<Input, Output>,
4068
_schemaOptions?: never,
4169
resolverOptions: {
4270
raw?: boolean;
4371
} = {},
44-
): Resolver<Schema['inferOut']> {
45-
return (values, _, options) => {
46-
const out = schema(values);
72+
): Resolver<Input, Context, Input | Output> {
73+
return async (values: Input, _, options) => {
74+
let result = schema['~standard'].validate(values);
75+
if (result instanceof Promise) {
76+
result = await result;
77+
}
78+
79+
if (result.issues) {
80+
const errors = parseErrorSchema(
81+
result.issues,
82+
!options.shouldUseNativeValidation && options.criteriaMode === 'all',
83+
);
4784

48-
if (out instanceof ArkErrors) {
4985
return {
5086
values: {},
51-
errors: toNestErrors(parseErrorSchema(out), options),
87+
errors: toNestErrors(errors, options),
5288
};
5389
}
5490

5591
options.shouldUseNativeValidation && validateFieldsNatively({}, options);
5692

5793
return {
58-
errors: {} as FieldErrors,
59-
values: resolverOptions.raw ? Object.assign({}, values) : out,
94+
values: resolverOptions.raw ? Object.assign({}, values) : result.value,
95+
errors: {},
6096
};
6197
};
6298
}

0 commit comments

Comments
 (0)