Skip to content

Commit

Permalink
feat: io-ts resolver (#152)
Browse files Browse the repository at this point in the history
* feat: add io-ts resolver

* doc: add io-ts example

* feat: comply with validateAllFieldCriteria

* feat: use io-ts@^2.0.0 fp-ts@^2.7.0

* doc: io-ts badge without version
  • Loading branch information
Nicolas Baptiste authored Apr 13, 2021
1 parent 31918c2 commit 73dfad7
Show file tree
Hide file tree
Showing 14 changed files with 607 additions and 2 deletions.
36 changes: 36 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,42 @@ const App = () => {
export default App;
```

### [io-ts](https://github.com/gcanti/io-ts)

Validate your data with powerful decoders.

[![npm](https://img.shields.io/bundlephobia/minzip/io-ts?style=for-the-badge)](https://bundlephobia.com/result?p=io-ts)

```typescript jsx
import React from 'react';
import { useForm } from 'react-hook-form';
import { ioTsResolver } from '@hookform/resolvers/io-ts';
import t from 'io-ts';
// you don't have to use io-ts-types but it's very useful
import tt from 'io-ts-types';

const schema = t.type({
username: t.string,
age: tt.NumberFromString,
});

const App = () => {
const { register, handleSubmit } = useForm({
resolver: ioTsResolver(schema),
});

return (
<form onSubmit={handleSubmit((d) => console.log(d))}>
<input name="username" ref={register} />
<input name="age" type="number" ref={register} />
<input type="submit" />
</form>
);
};

export default App;
```

## Backers

Thanks goes to all our backers! [[Become a backer](https://opencollective.com/react-hook-form#backer)].
Expand Down
1 change: 1 addition & 0 deletions config/node-13-exports.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ const subRepositories = [
'yup',
'superstruct',
'class-validator',
'io-ts',
];

const copySrc = () => {
Expand Down
65 changes: 65 additions & 0 deletions io-ts/__tests__/Form.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import React from 'react';
import { render, screen, act } from '@testing-library/react';
import user from '@testing-library/user-event';
import { useForm } from 'react-hook-form';
import * as t from 'io-ts';
import * as tt from 'io-ts-types';
import { ioTsResolver } from '../src';

const schema = t.type({
username: tt.withMessage(
tt.NonEmptyString,
() => 'username is a required field',
),
password: tt.withMessage(
tt.NonEmptyString,
() => 'password is a required field',
),
});

interface FormData {
username: string;
password: string;
}

interface Props {
onSubmit: (data: FormData) => void;
}

function TestComponent({ onSubmit }: Props) {
const {
register,
formState: { errors },
handleSubmit,
} = useForm<FormData>({
resolver: ioTsResolver(schema),
criteriaMode: 'all',
});

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>
);
}

test("form's validation with io-ts and TypeScript's integration", async () => {
const handleSubmit = jest.fn();
render(<TestComponent onSubmit={handleSubmit} />);

expect(screen.queryAllByRole(/alert/i)).toHaveLength(0);

await act(async () => {
user.click(screen.getByText(/submit/i));
});

expect(screen.getByText(/username is a required field/i)).toBeInTheDocument();
expect(screen.getByText(/password is a required field/i)).toBeInTheDocument();
expect(handleSubmit).not.toHaveBeenCalled();
});
129 changes: 129 additions & 0 deletions io-ts/__tests__/__fixtures__/data.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import * as t from 'io-ts';
import * as tt from 'io-ts-types';

import { Field, InternalFieldName } from 'react-hook-form';

export const schema = t.intersection([
t.type({
username: tt.NonEmptyString,
password: tt.NonEmptyString,
accessToken: tt.UUID,
birthYear: t.number,
email: t.string,
tags: t.array(
t.type({
name: t.string,
}),
),
luckyNumbers: t.array(t.number),
enabled: t.boolean,
animal: t.union([
t.string,
t.number,
t.literal('bird'),
t.literal('snake'),
]),
vehicles: t.array(
t.union([
t.type({
type: t.literal('car'),
brand: t.string,
horsepower: t.number,
}),
t.type({
type: t.literal('bike'),
speed: t.number,
}),
]),
),
}),
t.partial({
like: t.array(
t.type({
id: tt.withMessage(
t.number,
(i) => `this id is very important but you passed: ${typeof i}(${i})`,
),
name: t.string,
}),
),
}),
]);

interface Data {
username: string;
password: string;
accessToken: string;
birthYear?: number;
luckyNumbers: number[];
email?: string;
animal: string | number;
tags: { name: string }[];
enabled: boolean;
like: { id: number; name: string }[];
vehicles: Array<
| { type: 'car'; brand: string; horsepower: number }
| { type: 'bike'; speed: number }
>;
}

export const validData: Data = {
username: 'Doe',
password: 'Password123',
accessToken: 'c2883927-5178-4ad1-bbee-07ba33a5de19',
birthYear: 2000,
email: '[email protected]',
tags: [{ name: 'test' }],
enabled: true,
luckyNumbers: [17, 5],
animal: 'cat',
like: [
{
id: 1,
name: 'name',
},
],
vehicles: [{ type: 'car', brand: 'BMW', horsepower: 150 }],
};

export const invalidData = {
username: 'test',
password: 'Password123',
repeatPassword: 'Password123',
birthYear: 2000,
accessToken: '1015d809-e99d-41ec-b161-981a3c243df8',
email: '[email protected]',
tags: [{ name: 'test' }],
enabled: true,
animal: ['dog'],
luckyNumbers: [1, 2, '3'],
like: [
{
id: '1',
name: 'name',
},
],
vehicles: [
{ type: 'car', brand: 'BMW', horsepower: 150 },
{ type: 'car', brand: 'Mercedes' },
],
};

export const fields: Record<InternalFieldName, Field['_f']> = {
username: {
ref: { name: 'username' },
name: 'username',
},
password: {
ref: { name: 'password' },
name: 'password',
},
email: {
ref: { name: 'email' },
name: 'email',
},
birthday: {
ref: { name: 'birthday' },
name: 'birthday',
},
};
89 changes: 89 additions & 0 deletions io-ts/__tests__/__snapshots__/io-ts.ts.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`ioTsResolver should return a single error from ioTsResolver when validation fails 1`] = `
Object {
"errors": Object {
"animal": Object {
"message": "expected string but got [\\"dog\\"]",
"ref": undefined,
"type": "string",
},
"like": Array [
Object {
"id": Object {
"message": "this id is very important but you passed: string(1)",
"ref": undefined,
"type": "number",
},
},
],
"luckyNumbers": Array [
undefined,
undefined,
Object {
"message": "expected number but got \\"3\\"",
"ref": undefined,
"type": "number",
},
],
"vehicles": Array [
undefined,
Object {
"horsepower": Object {
"message": "expected number but got undefined",
"ref": undefined,
"type": "number",
},
},
],
},
"values": Object {},
}
`;

exports[`ioTsResolver should return all the errors from ioTsResolver when validation fails with \`validateAllFieldCriteria\` set to true 1`] = `
Object {
"errors": Object {
"animal": Object {
"message": "expected \\"snake\\" but got [\\"dog\\"]",
"ref": undefined,
"type": "\\"snake\\"",
"types": Object {
"\\"bird\\"": "expected \\"bird\\" but got [\\"dog\\"]",
"\\"snake\\"": "expected \\"snake\\" but got [\\"dog\\"]",
"number": "expected number but got [\\"dog\\"]",
"string": "expected string but got [\\"dog\\"]",
},
},
"like": Array [
Object {
"id": Object {
"message": "this id is very important but you passed: string(1)",
"ref": undefined,
"type": "number",
},
},
],
"luckyNumbers": Array [
undefined,
undefined,
Object {
"message": "expected number but got \\"3\\"",
"ref": undefined,
"type": "number",
},
],
"vehicles": Array [
undefined,
Object {
"horsepower": Object {
"message": "expected number but got undefined",
"ref": undefined,
"type": "number",
},
},
],
},
"values": Object {},
}
`;
32 changes: 32 additions & 0 deletions io-ts/__tests__/io-ts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { ioTsResolver } from '../src';
import { schema, validData, fields, invalidData } from './__fixtures__/data';

describe('ioTsResolver', () => {
it('should return values from ioTsResolver when validation pass', async () => {
const validateSpy = jest.spyOn(schema, 'decode');

const result = ioTsResolver(schema)(validData, undefined, {
fields,
});

expect(validateSpy).toHaveBeenCalled();
expect(result).toEqual({ errors: {}, values: validData });
});

it('should return a single error from ioTsResolver when validation fails', () => {
const result = ioTsResolver(schema)(invalidData, undefined, {
fields,
});

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

it('should return all the errors from ioTsResolver when validation fails with `validateAllFieldCriteria` set to true', () => {
const result = ioTsResolver(schema)(invalidData, undefined, {
fields,
criteriaMode: 'all',
});

expect(result).toMatchSnapshot();
});
});
19 changes: 19 additions & 0 deletions io-ts/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{
"name": "io-ts",
"amdName": "hookformResolversIoTs",
"version": "1.0.0",
"private": true,
"description": "React Hook Form validation resolver: io-ts",
"main": "dist/io-ts.js",
"module": "dist/io-ts.module.js",
"umd:main": "dist/io-ts.umd.js",
"source": "src/index.ts",
"types": "dist/index.d.ts",
"license": "MIT",
"peerDependencies": {
"react-hook-form": "^7.0.0",
"@hookform/resolvers": "^2.0.0",
"io-ts": "^2.0.0",
"fp-ts": "^2.7.0"
}
}
Loading

0 comments on commit 73dfad7

Please sign in to comment.