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

Draft
wants to merge 23 commits into
base: dev
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
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
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
Loading