diff --git a/README.md b/README.md index 3d6baee5..2aacd66c 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,8 @@ - [typanion](#typanion) - [Ajv](#ajv) - [TypeBox](#typebox) + - [With `ValueCheck`](#with-valuecheck) + - [With `TypeCompiler`](#with-typecompiler) - [ArkType](#arktype) - [Valibot](#valibot) - [Backers](#backers) @@ -486,6 +488,8 @@ JSON Schema Type Builder with Static Type Resolution for TypeScript [![npm](https://img.shields.io/bundlephobia/minzip/@sinclair/typebox?style=for-the-badge)](https://bundlephobia.com/result?p=@sinclair/typebox) +#### With `ValueCheck` + ```typescript jsx import { useForm } from 'react-hook-form'; import { typeboxResolver } from '@hookform/resolvers/typebox'; @@ -511,6 +515,38 @@ const App = () => { }; ``` +#### With `TypeCompiler` + +A high-performance JIT of `TypeBox`, [read more](https://github.com/sinclairzx81/typebox#typecompiler) + +```typescript jsx +import { useForm } from 'react-hook-form'; +import { typeboxResolver } from '@hookform/resolvers/typebox'; +import { Type } from '@sinclair/typebox'; +import { TypeCompiler } from '@sinclair/typebox/compiler'; + +const schema = Type.Object({ + username: Type.String({ minLength: 1 }), + password: Type.String({ minLength: 1 }), +}); + +const typecheck = TypeCompiler.Compile(schema); + +const App = () => { + const { register, handleSubmit } = useForm({ + resolver: typeboxResolver(typecheck), + }); + + return ( +
console.log(d))}> + + + +
+ ); +}; +``` + ### [ArkType](https://github.com/arktypeio/arktype) TypeScript's 1:1 validator, optimized from editor to runtime diff --git a/typebox/src/__tests__/Form-compiler.tsx b/typebox/src/__tests__/Form-compiler.tsx new file mode 100644 index 00000000..a94781e1 --- /dev/null +++ b/typebox/src/__tests__/Form-compiler.tsx @@ -0,0 +1,57 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import user from '@testing-library/user-event'; +import { useForm } from 'react-hook-form'; +import { typeboxResolver } from '..'; +import { Type, Static } from '@sinclair/typebox'; +import { TypeCompiler } from '@sinclair/typebox/compiler'; + +const schema = Type.Object({ + username: Type.String({ minLength: 1 }), + password: Type.String({ minLength: 1 }), +}); + +const typecheck = TypeCompiler.Compile(schema) + +type FormData = Static & { unusedProperty: string }; + +interface Props { + onSubmit: (data: FormData) => void; +} + +function TestComponent({ onSubmit }: Props) { + const { + register, + handleSubmit, + formState: { errors }, + } = useForm({ + resolver: typeboxResolver(typecheck), // Useful to check TypeScript regressions + }); + + return ( +
+ + {errors.username && {errors.username.message}} + + + {errors.password && {errors.password.message}} + + +
+ ); +} + +test("form's validation with Typebox (with compiler) and TypeScript's integration", async () => { + const handleSubmit = vi.fn(); + render(); + + expect(screen.queryAllByRole('alert')).toHaveLength(0); + + await user.click(screen.getByText(/submit/i)); + + expect( + screen.getAllByText(/Expected string length greater or equal to 1/i), + ).toHaveLength(2); + + expect(handleSubmit).not.toHaveBeenCalled(); +}); diff --git a/typebox/src/__tests__/Form-native-validation-compiler.tsx b/typebox/src/__tests__/Form-native-validation-compiler.tsx new file mode 100644 index 00000000..c5e74c8c --- /dev/null +++ b/typebox/src/__tests__/Form-native-validation-compiler.tsx @@ -0,0 +1,86 @@ +import React from 'react'; +import { useForm } from 'react-hook-form'; +import { render, screen } from '@testing-library/react'; +import user from '@testing-library/user-event'; +import { typeboxResolver } from '..'; + +import { Type, Static } from '@sinclair/typebox'; +import { TypeCompiler } from '@sinclair/typebox/compiler'; + +const schema = Type.Object({ + username: Type.String({ minLength: 1 }), + password: Type.String({ minLength: 1 }), +}); + +const typecheck = TypeCompiler.Compile(schema) + +type FormData = Static; + +interface Props { + onSubmit: (data: FormData) => void; +} + +function TestComponent({ onSubmit }: Props) { + const { register, handleSubmit } = useForm({ + resolver: typeboxResolver(typecheck), + shouldUseNativeValidation: true, + }); + + return ( +
+ + + + + +
+ ); +} + +test("form's native validation with Typebox (with compiler)", async () => { + const handleSubmit = vi.fn(); + render(); + + // username + let usernameField = screen.getByPlaceholderText( + /username/i, + ) as HTMLInputElement; + expect(usernameField.validity.valid).toBe(true); + expect(usernameField.validationMessage).toBe(''); + + // password + let passwordField = screen.getByPlaceholderText( + /password/i, + ) as HTMLInputElement; + expect(passwordField.validity.valid).toBe(true); + expect(passwordField.validationMessage).toBe(''); + + await user.click(screen.getByText(/submit/i)); + + // username + usernameField = screen.getByPlaceholderText(/username/i) as HTMLInputElement; + expect(usernameField.validity.valid).toBe(false); + expect(usernameField.validationMessage).toBe( + 'Expected string length greater or equal to 1', + ); + + // password + passwordField = screen.getByPlaceholderText(/password/i) as HTMLInputElement; + expect(passwordField.validity.valid).toBe(false); + expect(passwordField.validationMessage).toBe( + 'Expected string length greater or equal to 1', + ); + + await user.type(screen.getByPlaceholderText(/username/i), 'joe'); + await user.type(screen.getByPlaceholderText(/password/i), 'password'); + + // username + usernameField = screen.getByPlaceholderText(/username/i) as HTMLInputElement; + expect(usernameField.validity.valid).toBe(true); + expect(usernameField.validationMessage).toBe(''); + + // password + passwordField = screen.getByPlaceholderText(/password/i) as HTMLInputElement; + expect(passwordField.validity.valid).toBe(true); + expect(passwordField.validationMessage).toBe(''); +}); diff --git a/typebox/src/__tests__/Form-native-validation.tsx b/typebox/src/__tests__/Form-native-validation.tsx index 78fdc439..460aa5fe 100644 --- a/typebox/src/__tests__/Form-native-validation.tsx +++ b/typebox/src/__tests__/Form-native-validation.tsx @@ -34,7 +34,7 @@ function TestComponent({ onSubmit }: Props) { ); } -test("form's native validation with Zod", async () => { +test("form's native validation with Typebox", async () => { const handleSubmit = vi.fn(); render(); diff --git a/typebox/src/__tests__/Form.tsx b/typebox/src/__tests__/Form.tsx index c1f3cc95..ce7a5729 100644 --- a/typebox/src/__tests__/Form.tsx +++ b/typebox/src/__tests__/Form.tsx @@ -38,7 +38,7 @@ function TestComponent({ onSubmit }: Props) { ); } -test("form's validation with Zod and TypeScript's integration", async () => { +test("form's validation with Typebox and TypeScript's integration", async () => { const handleSubmit = vi.fn(); render(); diff --git a/typebox/src/__tests__/__snapshots__/typebox-compiler.ts.snap b/typebox/src/__tests__/__snapshots__/typebox-compiler.ts.snap new file mode 100644 index 00000000..44e4eec9 --- /dev/null +++ b/typebox/src/__tests__/__snapshots__/typebox-compiler.ts.snap @@ -0,0 +1,188 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`typeboxResolver (with compiler) > should return a single error from typeboxResolver when validation fails 1`] = ` +{ + "errors": { + "accessToken": { + "message": "Required property", + "ref": undefined, + "type": "45", + }, + "birthYear": { + "message": "Expected number", + "ref": undefined, + "type": "41", + }, + "dateStr": { + "message": "Required property", + "ref": undefined, + "type": "45", + }, + "email": { + "message": "Expected string to match '^[a-z0-9!#$%&'*+/=?^_\`{|}~-]+(?:\\\\.[a-z0-9!#$%&'*+/=?^_\`{|}~-]+)*@(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\\\\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$'", + "ref": { + "name": "email", + }, + "type": "52", + }, + "enabled": { + "message": "Required property", + "ref": undefined, + "type": "45", + }, + "like": [ + { + "id": { + "message": "Expected number", + "ref": undefined, + "type": "41", + }, + "name": { + "message": "Required property", + "ref": undefined, + "type": "45", + }, + }, + ], + "password": { + "message": "Expected string length greater or equal to 8", + "ref": { + "name": "password", + }, + "type": "51", + }, + "repeatPassword": { + "message": "Required property", + "ref": undefined, + "type": "45", + }, + "tags": { + "message": "Required property", + "ref": undefined, + "type": "45", + }, + "username": { + "message": "Required property", + "ref": { + "name": "username", + }, + "type": "45", + }, + }, + "values": {}, +} +`; + +exports[`typeboxResolver (with compiler) > should return all the errors from typeboxResolver when validation fails with \`validateAllFieldCriteria\` set to true 1`] = ` +{ + "errors": { + "accessToken": { + "message": "Required property", + "ref": undefined, + "type": "45", + "types": { + "45": "Required property", + "61": "Expected union value", + }, + }, + "birthYear": { + "message": "Expected number", + "ref": undefined, + "type": "41", + "types": { + "41": "Expected number", + }, + }, + "dateStr": { + "message": "Required property", + "ref": undefined, + "type": "45", + "types": { + "20": "Expected Date", + "45": "Required property", + }, + }, + "email": { + "message": "Expected string to match '^[a-z0-9!#$%&'*+/=?^_\`{|}~-]+(?:\\\\.[a-z0-9!#$%&'*+/=?^_\`{|}~-]+)*@(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\\\\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$'", + "ref": { + "name": "email", + }, + "type": "52", + "types": { + "52": "Expected string to match '^[a-z0-9!#$%&'*+/=?^_\`{|}~-]+(?:\\\\.[a-z0-9!#$%&'*+/=?^_\`{|}~-]+)*@(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\\\\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$'", + }, + }, + "enabled": { + "message": "Required property", + "ref": undefined, + "type": "45", + "types": { + "14": "Expected boolean", + "45": "Required property", + }, + }, + "like": [ + { + "id": { + "message": "Expected number", + "ref": undefined, + "type": "41", + "types": { + "41": "Expected number", + }, + }, + "name": { + "message": "Required property", + "ref": undefined, + "type": "45", + "types": { + "45": "Required property", + "53": "Expected string", + }, + }, + }, + ], + "password": { + "message": "Expected string length greater or equal to 8", + "ref": { + "name": "password", + }, + "type": "51", + "types": { + "51": "Expected string length greater or equal to 8", + "52": "Expected string to match '^(.*[A-Za-z\\\\d].*)$'", + }, + }, + "repeatPassword": { + "message": "Required property", + "ref": undefined, + "type": "45", + "types": { + "45": "Required property", + "53": "Expected string", + }, + }, + "tags": { + "message": "Required property", + "ref": undefined, + "type": "45", + "types": { + "45": "Required property", + "6": "Expected array", + }, + }, + "username": { + "message": "Required property", + "ref": { + "name": "username", + }, + "type": "45", + "types": { + "45": "Required property", + "53": "Expected string", + }, + }, + }, + "values": {}, +} +`; diff --git a/typebox/src/__tests__/typebox-compiler.ts b/typebox/src/__tests__/typebox-compiler.ts new file mode 100644 index 00000000..e95d2d85 --- /dev/null +++ b/typebox/src/__tests__/typebox-compiler.ts @@ -0,0 +1,38 @@ +import { TypeCompiler } from '@sinclair/typebox/compiler'; +import { typeboxResolver } from '..'; +import { schema, validData, invalidData, fields } from './__fixtures__/data'; + +const shouldUseNativeValidation = false; + +describe('typeboxResolver (with compiler)', () => { + + const typecheck = TypeCompiler.Compile(schema) + + it('should return a single error from typeboxResolver when validation fails', async () => { + const result = await typeboxResolver(typecheck)(invalidData, undefined, { + fields, + shouldUseNativeValidation, + }); + + expect(result).toMatchSnapshot(); + }); + + it('should return all the errors from typeboxResolver when validation fails with `validateAllFieldCriteria` set to true', async () => { + const result = await typeboxResolver(typecheck)(invalidData, undefined, { + fields, + criteriaMode: 'all', + shouldUseNativeValidation, + }); + + expect(result).toMatchSnapshot(); + }); + + it('should validate with success', async () => { + const result = await typeboxResolver(typecheck)(validData, undefined, { + fields, + shouldUseNativeValidation, + }); + + expect(result).toEqual({ errors: {}, values: validData }); + }); +}); diff --git a/typebox/src/typebox.ts b/typebox/src/typebox.ts index 3fe79ae1..cf776525 100644 --- a/typebox/src/typebox.ts +++ b/typebox/src/typebox.ts @@ -1,7 +1,8 @@ import { appendErrors, FieldError, FieldErrors } from 'react-hook-form'; import { toNestErrors, validateFieldsNatively } from '@hookform/resolvers'; import type { Resolver } from './types'; -import { Value, ValueError } from '@sinclair/typebox/value'; +import { Value, type ValueError } from '@sinclair/typebox/value'; +import { TypeCheck } from '@sinclair/typebox/compiler'; const parseErrorSchema = ( _errors: ValueError[], @@ -40,7 +41,7 @@ const parseErrorSchema = ( export const typeboxResolver: Resolver = (schema) => async (values, _, options) => { - const errors = Array.from(Value.Errors(schema, values)); + const errors = Array.from(schema instanceof TypeCheck ? schema.Errors(values) : Value.Errors(schema, values)); options.shouldUseNativeValidation && validateFieldsNatively({}, options); diff --git a/typebox/src/types.ts b/typebox/src/types.ts index 917d1868..ecae5cde 100644 --- a/typebox/src/types.ts +++ b/typebox/src/types.ts @@ -1,8 +1,9 @@ import { Type } from '@sinclair/typebox'; +import type { TypeCheck } from '@sinclair/typebox/compiler/compiler'; import { FieldValues, ResolverResult, ResolverOptions } from 'react-hook-form'; export type Resolver = >( - schema: T, + schema: T | TypeCheck, ) => ( values: TFieldValues, context: TContext | undefined,