Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: implement typed Input/Output interface for resolvers #753

Merged
merged 30 commits into from
Mar 31, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
cf569f3
feat: implement typed Input/Output interface for resolvers
jorisre Feb 26, 2025
14d8095
docs: write some docs
jorisre Feb 27, 2025
b82fd2f
WIP
jorisre Mar 3, 2025
1a4e0d2
doc: update
jorisre Mar 3, 2025
c0a1dbe
test: update
jorisre Mar 6, 2025
d3aa26b
try to handle option
jorisre Mar 6, 2025
06f3b08
fix: applied @controversial's suggested modifications
jorisre Mar 12, 2025
d32ab27
feat: yup type inference
jorisre Mar 14, 2025
9841f67
refactor: zod tests
jorisre Mar 14, 2025
f2f0b0b
chore: move @standard-schema/utils deps to subpackage
jorisre Mar 14, 2025
35e029d
chore: use RHF RC version
jorisre Mar 14, 2025
9ccbee3
chore: bump RHF version
jorisre Mar 14, 2025
a6b75c3
feat(vine): infer types from resolver
jorisre Mar 17, 2025
a091583
feat(valibot): type inference
jorisre Mar 17, 2025
cfcafe2
feat(typanion): types inference
jorisre Mar 19, 2025
0c137d3
feat(superstruct): type inference
jorisre Mar 19, 2025
4e40368
feat(standardSchema): type inference
jorisre Mar 19, 2025
882dac9
feat(io-ts): type inference
jorisre Mar 19, 2025
157d110
feat(effect-ts): type inference
jorisre Mar 19, 2025
c0fdb95
feat(computed-types): type inference
jorisre Mar 19, 2025
c6da66a
feat(arktype): type inference
jorisre Mar 20, 2025
bf30971
chore: clean
jorisre Mar 20, 2025
b49bc81
feat(typebox): type inference
jorisre Mar 20, 2025
a45a85e
typeschema
jorisre Mar 24, 2025
d441f34
fix(typebox): improve type interface (#757)
kotarella1110 Mar 27, 2025
8424fa4
chore: bump RHF
jorisre Mar 27, 2025
c4793f8
ci: temp fix compressed size
jorisre Mar 27, 2025
236bdde
chore: update bun.lock
jorisre Mar 27, 2025
c7378e5
chore: use RHF 7.55.0
jorisre Mar 31, 2025
f403f32
chore: reg bun.lock
jorisre Mar 31, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/compressedSize.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,4 @@ jobs:
- uses: preactjs/compressed-size-action@v2
with:
repo-token: '${{ secrets.GITHUB_TOKEN }}'
install-script: "bun install --frozen-lockfile"
7 changes: 5 additions & 2 deletions .husky/pre-commit
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ call_lefthook()
then
"$dir/node_modules/lefthook/bin/index.js" "$@"

elif go tool lefthook -h >/dev/null 2>&1
then
go tool lefthook "$@"
elif bundle exec lefthook -h >/dev/null 2>&1
then
bundle exec lefthook "$@"
Expand All @@ -42,9 +45,9 @@ call_lefthook()
elif pnpm lefthook -h >/dev/null 2>&1
then
pnpm lefthook "$@"
elif swift package plugin lefthook >/dev/null 2>&1
elif swift package lefthook >/dev/null 2>&1
then
swift package --disable-sandbox plugin lefthook "$@"
swift package --build-path .build/lefthook --disable-sandbox lefthook "$@"
elif command -v mint >/dev/null 2>&1
then
mint run csjones/lefthook-plugin "$@"
Expand Down
7 changes: 5 additions & 2 deletions .husky/prepare-commit-msg
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ call_lefthook()
then
"$dir/node_modules/lefthook/bin/index.js" "$@"

elif go tool lefthook -h >/dev/null 2>&1
then
go tool lefthook "$@"
elif bundle exec lefthook -h >/dev/null 2>&1
then
bundle exec lefthook "$@"
Expand All @@ -42,9 +45,9 @@ call_lefthook()
elif pnpm lefthook -h >/dev/null 2>&1
then
pnpm lefthook "$@"
elif swift package plugin lefthook >/dev/null 2>&1
elif swift package lefthook >/dev/null 2>&1
then
swift package --disable-sandbox plugin lefthook "$@"
swift package --build-path .build/lefthook --disable-sandbox lefthook "$@"
elif command -v mint >/dev/null 2>&1
then
mint run csjones/lefthook-plugin "$@"
Expand Down
30 changes: 30 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,36 @@ Install your preferred validation library alongside `@hookform/resolvers`.
| zod | ✅ | `firstError | all` |
</details>

## TypeScript

Most of the resolvers can infer the output type from the schema. See comparison table for more details.

```tsx
useForm<Input, Context, Output>()
```

Example:

```tsx
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';

const schema = z.object({
id: z.number(),
});

// Automatically infers the output type from the schema
useForm({
resolver: zodResolver(schema),
});

// Force the output type
useForm<z.input<typeof schema>, any, z.output<typeof schema>>({
resolver: zodResolver(schema),
});
```

## Links

- [React-hook-form validation resolver documentation ](https://react-hook-form.com/docs/useform#resolver)
Expand Down
2 changes: 1 addition & 1 deletion ajv/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
"types": "dist/index.d.ts",
"license": "MIT",
"peerDependencies": {
"react-hook-form": "^7.0.0",
"react-hook-form": "7.55.0",
"@hookform/resolvers": "^2.0.0",
"ajv": "^8.12.0",
"ajv-errors": "^3.0.0"
Expand Down
2 changes: 1 addition & 1 deletion arktype/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
"types": "dist/index.d.ts",
"license": "MIT",
"peerDependencies": {
"react-hook-form": "^7.0.0",
"react-hook-form": "7.55.0",
"@hookform/resolvers": "^2.0.0",
"arktype": "^2.0.0"
}
Expand Down
28 changes: 0 additions & 28 deletions arktype/src/__tests__/Form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,6 @@ const schema = type({
password: 'string>1',
});

type FormData = typeof schema.infer & { unusedProperty: string };

function TestComponent({
onSubmit,
}: {
Expand Down Expand Up @@ -54,29 +52,3 @@ test("form's validation with arkType and TypeScript's integration", async () =>
).toBeInTheDocument();
expect(handleSubmit).not.toHaveBeenCalled();
});

export function TestComponentManualType({
onSubmit,
}: {
onSubmit: (data: FormData) => void;
}) {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<typeof schema.infer, undefined, FormData>({
resolver: arktypeResolver(schema), // Useful to check TypeScript regressions
});

return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('username')} />
{errors.username && <span role="alert">{errors.username.message}</span>}

<input {...register('password')} />
{errors.password && <span role="alert">{errors.password.message}</span>}

<button type="submit">submit</button>
</form>
);
}
56 changes: 56 additions & 0 deletions arktype/src/__tests__/arktype.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import { type } from 'arktype';
import { Resolver, useForm } from 'react-hook-form';
import { SubmitHandler } from 'react-hook-form';
import { arktypeResolver } from '..';
import { fields, invalidData, schema, validData } from './__fixtures__/data';

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

expect(result).toMatchSnapshot();
});

/**
* Type inference tests
*/
it('should correctly infer the output type from a arktype schema', () => {
const resolver = arktypeResolver(type({ id: 'number' }));

expectTypeOf(resolver).toEqualTypeOf<
Resolver<{ id: number }, unknown, { id: number }>
>();
});

it('should correctly infer the output type from a arktype schema using a transform', () => {
const resolver = arktypeResolver(
type({ id: type('string').pipe((s) => Number.parseInt(s)) }),
);

expectTypeOf(resolver).toEqualTypeOf<
Resolver<{ id: string }, unknown, { id: number }>
>();
});

it('should correctly infer the output type from a arktype schema for the handleSubmit function in useForm', () => {
const schema = type({ id: 'number' });

const form = useForm({
resolver: arktypeResolver(schema),
});

expectTypeOf(form.watch('id')).toEqualTypeOf<number>();

expectTypeOf(form.handleSubmit).parameter(0).toEqualTypeOf<
SubmitHandler<{
id: number;
}>
>();
});

it('should correctly infer the output type from a arktype schema with a transform for the handleSubmit function in useForm', () => {
const schema = type({ id: type('string').pipe((s) => Number.parseInt(s)) });

const form = useForm({
resolver: arktypeResolver(schema),
});

expectTypeOf(form.watch('id')).toEqualTypeOf<string>();

expectTypeOf(form.handleSubmit).parameter(0).toEqualTypeOf<
SubmitHandler<{
id: number;
}>
>();
});
});
80 changes: 58 additions & 22 deletions arktype/src/arktype.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,53 @@
import { toNestErrors, validateFieldsNatively } from '@hookform/resolvers';
import { ArkErrors, Type } from 'arktype';
import { FieldError, FieldErrors, Resolver } from 'react-hook-form';
import { StandardSchemaV1 } from '@standard-schema/spec';
import { getDotPath } from '@standard-schema/utils';
import { FieldError, FieldValues, Resolver } from 'react-hook-form';

function parseErrorSchema(arkErrors: ArkErrors): Record<string, FieldError> {
const errors = [...arkErrors];
const fieldsErrors: Record<string, FieldError> = {};
function parseErrorSchema(
issues: readonly StandardSchemaV1.Issue[],
validateAllFieldCriteria: boolean,
) {
const errors: Record<string, FieldError> = {};

for (; errors.length; ) {
const error = errors[0];
const _path = error.path.join('.');
for (let i = 0; i < issues.length; i++) {
const error = issues[i];
const path = getDotPath(error);

if (!fieldsErrors[_path]) {
fieldsErrors[_path] = { message: error.message, type: error.code };
}
if (path) {
if (!errors[path]) {
errors[path] = { message: error.message, type: '' };
}

if (validateAllFieldCriteria) {
const types = errors[path].types || {};

errors.shift();
errors[path].types = {
...types,
[Object.keys(types).length]: error.message,
};
}
}
}

return fieldsErrors;
return errors;
}

export function arktypeResolver<Input extends FieldValues, Context, Output>(
schema: StandardSchemaV1<Input, Output>,
_schemaOptions?: never,
resolverOptions?: {
raw?: false;
},
): Resolver<Input, Context, Output>;

export function arktypeResolver<Input extends FieldValues, Context, Output>(
schema: StandardSchemaV1<Input, Output>,
_schemaOptions: never | undefined,
resolverOptions: {
raw: true;
},
): Resolver<Input, Context, Input>;

/**
* Creates a resolver for react-hook-form using Arktype schema validation
* @param {Schema} schema - The Arktype schema to validate against
Expand All @@ -35,28 +63,36 @@ function parseErrorSchema(arkErrors: ArkErrors): Record<string, FieldError> {
* resolver: arktypeResolver(schema)
* });
*/
export function arktypeResolver<Schema extends Type<any, any>>(
schema: Schema,
export function arktypeResolver<Input extends FieldValues, Context, Output>(
schema: StandardSchemaV1<Input, Output>,
_schemaOptions?: never,
resolverOptions: {
raw?: boolean;
} = {},
): Resolver<Schema['inferOut']> {
return (values, _, options) => {
const out = schema(values);
): Resolver<Input, Context, Input | Output> {
return async (values: Input, _, options) => {
let result = schema['~standard'].validate(values);
if (result instanceof Promise) {
result = await result;
}

if (result.issues) {
const errors = parseErrorSchema(
result.issues,
!options.shouldUseNativeValidation && options.criteriaMode === 'all',
);

if (out instanceof ArkErrors) {
return {
values: {},
errors: toNestErrors(parseErrorSchema(out), options),
errors: toNestErrors(errors, options),
};
}

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

return {
errors: {} as FieldErrors,
values: resolverOptions.raw ? Object.assign({}, values) : out,
values: resolverOptions.raw ? Object.assign({}, values) : result.value,
errors: {},
};
};
}
Loading