diff --git a/.changeset/lovely-bobcats-hang.md b/.changeset/lovely-bobcats-hang.md new file mode 100644 index 00000000..a32fd203 --- /dev/null +++ b/.changeset/lovely-bobcats-hang.md @@ -0,0 +1,7 @@ +--- +'@typeschema/class-validator': minor +'@typeschema/main': patch +'@typeschema/all': patch +--- + +Improved error messages for class-validator diff --git a/README.md b/README.md index b17ee3e3..53023772 100644 --- a/README.md +++ b/README.md @@ -124,16 +124,6 @@ We value flexibility, which is why there are multiple ways of using TypeSchema: @typeschema/class-validator npm downloads - - superstruct - GitHub stars - ✅ - 🧐 - ✅ - 🧐 - @typeschema/superstruct - npm downloads - effect GitHub stars @@ -144,6 +134,16 @@ We value flexibility, which is why there are multiple ways of using TypeSchema: @typeschema/effect npm downloads + + superstruct + GitHub stars + ✅ + 🧐 + ✅ + 🧐 + @typeschema/superstruct + npm downloads + io-ts GitHub stars @@ -185,24 +185,24 @@ We value flexibility, which is why there are multiple ways of using TypeSchema: npm downloads - ow - GitHub stars + arktype + GitHub stars ✅ ✅ ✅ 🧐 - @typeschema/ow - npm downloads + @typeschema/arktype + npm downloads - arktype - GitHub stars + ow + GitHub stars ✅ ✅ ✅ 🧐 - @typeschema/arktype - npm downloads + @typeschema/ow + npm downloads deepkit diff --git a/packages/all/src/__tests__/class-validator.test.ts b/packages/all/src/__tests__/class-validator.test.ts index e2867c3d..c6454007 100644 --- a/packages/all/src/__tests__/class-validator.test.ts +++ b/packages/all/src/__tests__/class-validator.test.ts @@ -10,8 +10,11 @@ import { IsEmail, IsInt, IsNotEmpty, + IsOptional, + IsString, IsUUID, Min, + ValidateNested, } from 'class-validator'; import {expectTypeOf} from 'expect-type'; import {describe, expect, test} from 'vitest'; @@ -19,6 +22,12 @@ import {describe, expect, test} from 'vitest'; import {assert, validate, wrap} from '..'; describe('class-validator', () => { + class NestedSchema { + @IsString() + @IsNotEmpty() + value!: string; + } + class Schema { @IsInt() @Min(0) @@ -38,6 +47,14 @@ describe('class-validator', () => { @IsDateString() updatedAt!: string; + + @IsOptional() + @ValidateNested() + nested?: NestedSchema; + + @IsOptional() + @ValidateNested() + nestedArray?: Array; } const schema = Schema; @@ -48,7 +65,8 @@ describe('class-validator', () => { id: 'c4a760a8-dbcf-4e14-9f39-645a8e933d74', name: 'John Doe', updatedAt: '2021-01-01T00:00:00.000Z', - }; + } as Schema; + const badData = { age: '123', createdAt: '2021-01-01T00:00:00.000Z', @@ -58,6 +76,17 @@ describe('class-validator', () => { updatedAt: '2021-01-01T00:00:00.000Z', }; + const badNestedData = { + age: 123, + createdAt: '2021-01-01T00:00:00.000Z', + email: 'john.doe@test.com', + id: 'c4a760a8-dbcf-4e14-9f39-645a8e933d74', + name: 'John Doe', + updatedAt: '2021-01-01T00:00:00.000Z', + nested: new NestedSchema(), + nestedArray: [new NestedSchema()], + }; + test('infer', () => { expectTypeOf>().toEqualTypeOf(data); expectTypeOf>().toEqualTypeOf(data); @@ -71,11 +100,35 @@ describe('class-validator', () => { expect(await validate(schema, badData)).toStrictEqual({ issues: [ { - message: `An instance of Schema has failed the validation: - - property age has failed the following constraints: min, isInt -`, + message: 'age must not be less than 0', path: ['age'], }, + { + message: 'age must be an integer number', + path: ['age'], + }, + ], + success: false, + }); + + expect(await validate(schema, badNestedData)).toStrictEqual({ + issues: [ + { + message: 'value should not be empty', + path: ['nested', 'value'], + }, + { + message: 'value must be a string', + path: ['nested', 'value'], + }, + { + message: 'value should not be empty', + path: ['nestedArray', 0, 'value'], + }, + { + message: 'value must be a string', + path: ['nestedArray', 0, 'value'], + }, ], success: false, }); diff --git a/packages/class-validator/src/__tests__/class-validator.test.ts b/packages/class-validator/src/__tests__/class-validator.test.ts index 5bccd92c..21da4ae3 100644 --- a/packages/class-validator/src/__tests__/class-validator.test.ts +++ b/packages/class-validator/src/__tests__/class-validator.test.ts @@ -44,11 +44,13 @@ describe('class-validator', () => { @IsDateString() updatedAt!: string; + @IsOptional() @ValidateNested() - nested!: NestedSchema; + nested?: NestedSchema; + @IsOptional() @ValidateNested() - nestedArray?: NestedSchema[]; + nestedArray?: Array; } const schema = Schema; @@ -67,7 +69,7 @@ describe('class-validator', () => { email: 'john.doe@test.com', id: 'c4a760a8-dbcf-4e14-9f39-645a8e933d74', name: 'John Doe', - updatedAt: '2021-01-01T00:00:00.000Z' + updatedAt: '2021-01-01T00:00:00.000Z', }; const badNestedData = { @@ -100,7 +102,7 @@ describe('class-validator', () => { { message: 'age must be an integer number', path: ['age'], - } + }, ], success: false, }); @@ -109,20 +111,20 @@ describe('class-validator', () => { issues: [ { message: 'value should not be empty', - path: ['nested.value'], + path: ['nested', 'value'], }, { message: 'value must be a string', - path: ['nested.value'], + path: ['nested', 'value'], }, { message: 'value should not be empty', - path: ['nestedArray[0].value'], + path: ['nestedArray', 0, 'value'], }, { message: 'value must be a string', - path: ['nestedArray[0].value'], - } + path: ['nestedArray', 0, 'value'], + }, ], success: false, }); diff --git a/packages/class-validator/src/validation.ts b/packages/class-validator/src/validation.ts index 4d699f64..8ff885aa 100644 --- a/packages/class-validator/src/validation.ts +++ b/packages/class-validator/src/validation.ts @@ -1,36 +1,34 @@ -/* eslint-disable prettier/prettier */ import type {AdapterResolver} from './resolver'; import type {ValidationAdapter, ValidationIssue} from '@typeschema/core'; import {memoize} from '@typeschema/core'; -import { ValidationError } from "class-validator"; +import {ValidationError} from 'class-validator'; const importValidationModule = memoize(async () => { const {validate} = await import('class-validator'); return {validate}; }); +function getIssues( + error: ValidationError, + parentPath: Array, +): Array { + const path = [ + ...parentPath, + Number.isInteger(+error.property) ? +error.property : error.property, + ]; + return Object.values(error.constraints ?? {}) + .map((message): ValidationIssue => ({message, path})) + .concat( + error.children?.flatMap(childError => getIssues(childError, path)) ?? [], + ); +} + export const validationAdapter: ValidationAdapter< AdapterResolver > = async schema => { const {validate} = await importValidationModule(); return async data => { - function getIssues(error: ValidationError, parentPath = ""): ValidationIssue[] { - const currentPath = parentPath - ? Number.isInteger(+error.property) ? `${parentPath}[${error.property}]` : `${parentPath}.${error.property}` - : error.property; - const constraints = error.constraints ? Object.values(error.constraints) : []; - const childIssues = error.children ? error.children.flatMap(childError => getIssues(childError, currentPath)) : []; - - return [ - ...constraints.map((message) => ({ - message: message, - path: [currentPath], - })), - ...childIssues - ]; - } - const errors = await validate(Object.assign(new schema(), data)); if (errors.length === 0) { return { @@ -40,7 +38,7 @@ export const validationAdapter: ValidationAdapter< }; } return { - issues: errors.flatMap(error => getIssues(error)), + issues: errors.flatMap(error => getIssues(error, [])), success: false, }; }; diff --git a/packages/main/src/__tests__/class-validator.test.ts b/packages/main/src/__tests__/class-validator.test.ts index e2867c3d..c6454007 100644 --- a/packages/main/src/__tests__/class-validator.test.ts +++ b/packages/main/src/__tests__/class-validator.test.ts @@ -10,8 +10,11 @@ import { IsEmail, IsInt, IsNotEmpty, + IsOptional, + IsString, IsUUID, Min, + ValidateNested, } from 'class-validator'; import {expectTypeOf} from 'expect-type'; import {describe, expect, test} from 'vitest'; @@ -19,6 +22,12 @@ import {describe, expect, test} from 'vitest'; import {assert, validate, wrap} from '..'; describe('class-validator', () => { + class NestedSchema { + @IsString() + @IsNotEmpty() + value!: string; + } + class Schema { @IsInt() @Min(0) @@ -38,6 +47,14 @@ describe('class-validator', () => { @IsDateString() updatedAt!: string; + + @IsOptional() + @ValidateNested() + nested?: NestedSchema; + + @IsOptional() + @ValidateNested() + nestedArray?: Array; } const schema = Schema; @@ -48,7 +65,8 @@ describe('class-validator', () => { id: 'c4a760a8-dbcf-4e14-9f39-645a8e933d74', name: 'John Doe', updatedAt: '2021-01-01T00:00:00.000Z', - }; + } as Schema; + const badData = { age: '123', createdAt: '2021-01-01T00:00:00.000Z', @@ -58,6 +76,17 @@ describe('class-validator', () => { updatedAt: '2021-01-01T00:00:00.000Z', }; + const badNestedData = { + age: 123, + createdAt: '2021-01-01T00:00:00.000Z', + email: 'john.doe@test.com', + id: 'c4a760a8-dbcf-4e14-9f39-645a8e933d74', + name: 'John Doe', + updatedAt: '2021-01-01T00:00:00.000Z', + nested: new NestedSchema(), + nestedArray: [new NestedSchema()], + }; + test('infer', () => { expectTypeOf>().toEqualTypeOf(data); expectTypeOf>().toEqualTypeOf(data); @@ -71,11 +100,35 @@ describe('class-validator', () => { expect(await validate(schema, badData)).toStrictEqual({ issues: [ { - message: `An instance of Schema has failed the validation: - - property age has failed the following constraints: min, isInt -`, + message: 'age must not be less than 0', path: ['age'], }, + { + message: 'age must be an integer number', + path: ['age'], + }, + ], + success: false, + }); + + expect(await validate(schema, badNestedData)).toStrictEqual({ + issues: [ + { + message: 'value should not be empty', + path: ['nested', 'value'], + }, + { + message: 'value must be a string', + path: ['nested', 'value'], + }, + { + message: 'value should not be empty', + path: ['nestedArray', 0, 'value'], + }, + { + message: 'value must be a string', + path: ['nestedArray', 0, 'value'], + }, ], success: false, });