diff --git a/src/index.ts b/src/index.ts index 87ba7c3c..7763d75c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,6 +3,7 @@ import { Shapes } from './lib/Shapes'; export const s = new Shapes(); export * from './lib/errors/CombinedError'; +export * from './lib/errors/CombinedPropertyError'; export * from './lib/errors/ConstraintError'; export * from './lib/errors/ExpectedValidationError'; export * from './lib/errors/MissingPropertyError'; diff --git a/src/lib/errors/CombinedPropertyError.ts b/src/lib/errors/CombinedPropertyError.ts new file mode 100644 index 00000000..eb422f8b --- /dev/null +++ b/src/lib/errors/CombinedPropertyError.ts @@ -0,0 +1,40 @@ +import type { InspectOptionsStylized } from 'node:util'; +import { BaseError, customInspectSymbolStackLess } from './BaseError'; + +export class CombinedPropertyError extends BaseError { + public readonly errors: [PropertyKey, BaseError][]; + + public constructor(errors: [PropertyKey, BaseError][]) { + super('Received one or more errors'); + + this.errors = errors; + } + + protected [customInspectSymbolStackLess](depth: number, options: InspectOptionsStylized): string { + if (depth < 0) { + return options.stylize('[CombinedPropertyError]', 'special'); + } + + const newOptions = { ...options, depth: options.depth === null ? null : options.depth! - 1, compact: true }; + + const padding = `\n ${options.stylize('|', 'undefined')} `; + + const header = `${options.stylize('CombinedPropertyError', 'special')} (${options.stylize(this.errors.length.toString(), 'number')})`; + const message = options.stylize(this.message, 'regexp'); + const errors = this.errors + .map(([key, error]) => { + const property = CombinedPropertyError.formatProperty(key, options); + const body = error[customInspectSymbolStackLess](depth - 1, newOptions).replaceAll('\n', padding); + + return ` input${property}${padding}${body}`; + }) + .join('\n\n'); + return `${header}\n ${message}\n\n${errors}`; + } + + private static formatProperty(key: PropertyKey, options: InspectOptionsStylized): string { + if (typeof key === 'string') return options.stylize(`.${key}`, 'symbol'); + if (typeof key === 'number') return `[${options.stylize(key.toString(), 'number')}]`; + return `[${options.stylize('Symbol', 'symbol')}(${key.description})]`; + } +} diff --git a/src/validators/ArrayValidator.ts b/src/validators/ArrayValidator.ts index 4ec2d501..b3d4e97d 100644 --- a/src/validators/ArrayValidator.ts +++ b/src/validators/ArrayValidator.ts @@ -1,6 +1,6 @@ import type { IConstraint } from '../constraints/base/IConstraint'; import type { BaseError } from '../lib/errors/BaseError'; -import { CombinedError } from '../lib/errors/CombinedError'; +import { CombinedPropertyError } from '../lib/errors/CombinedPropertyError'; import { ValidationError } from '../lib/errors/ValidationError'; import { Result } from '../lib/Result'; import { BaseValidator } from './imports'; @@ -17,22 +17,22 @@ export class ArrayValidator extends BaseValidator { return Reflect.construct(this.constructor, [this.validator, this.constraints]); } - protected handle(values: unknown): Result { + protected handle(values: unknown): Result { if (!Array.isArray(values)) { return Result.err(new ValidationError('ArrayValidator', 'Expected an array', values)); } - const errors: BaseError[] = []; + const errors: [number, BaseError][] = []; const transformed: T[] = []; - for (const value of values) { - const result = this.validator.run(value); + for (let i = 0; i < values.length; i++) { + const result = this.validator.run(values[i]); if (result.isOk()) transformed.push(result.value); - else errors.push(result.error!); + else errors.push([i, result.error!]); } return errors.length === 0 // ? Result.ok(transformed) - : Result.err(new CombinedError(errors)); + : Result.err(new CombinedPropertyError(errors)); } } diff --git a/src/validators/BaseValidator.ts b/src/validators/BaseValidator.ts index e57adfa3..b6f37ffc 100644 --- a/src/validators/BaseValidator.ts +++ b/src/validators/BaseValidator.ts @@ -1,6 +1,7 @@ import type { IConstraint } from '../constraints/base/IConstraint'; import type { BaseError } from '../lib/errors/BaseError'; import type { CombinedError } from '../lib/errors/CombinedError'; +import type { CombinedPropertyError } from '../lib/errors/CombinedPropertyError'; import type { ValidationError } from '../lib/errors/ValidationError'; import { Result } from '../lib/Result'; import { ArrayValidator, LiteralValidator, NullishValidator, SetValidator, UnionValidator, DefaultValidator } from './imports'; @@ -66,7 +67,7 @@ export abstract class BaseValidator { return Reflect.construct(this.constructor, [this.constraints]); } - protected abstract handle(value: unknown): Result; + protected abstract handle(value: unknown): Result; protected addConstraint(constraint: IConstraint): this { const clone = this.clone(); diff --git a/src/validators/DefaultValidator.ts b/src/validators/DefaultValidator.ts index c7e838b2..b445fde6 100644 --- a/src/validators/DefaultValidator.ts +++ b/src/validators/DefaultValidator.ts @@ -1,5 +1,6 @@ import type { IConstraint } from '../constraints/base/IConstraint'; import type { CombinedError } from '../lib/errors/CombinedError'; +import type { CombinedPropertyError } from '../lib/errors/CombinedPropertyError'; import type { ValidationError } from '../lib/errors/ValidationError'; import { Result } from '../lib/Result'; import { BaseValidator } from './imports'; @@ -15,7 +16,7 @@ export class DefaultValidator extends BaseValidator { this.defaultValue = value; } - protected handle(value: unknown): Result { + protected handle(value: unknown): Result { return typeof value === 'undefined' // ? Result.ok(getValue(this.defaultValue)) : this.validator['handle'](value); // eslint-disable-line @typescript-eslint/dot-notation diff --git a/src/validators/MapValidator.ts b/src/validators/MapValidator.ts index c7c5d082..136ce25e 100644 --- a/src/validators/MapValidator.ts +++ b/src/validators/MapValidator.ts @@ -1,6 +1,6 @@ import type { IConstraint } from '../constraints/base/IConstraint'; import type { BaseError } from '../lib/errors/BaseError'; -import { CombinedError } from '../lib/errors/CombinedError'; +import { CombinedPropertyError } from '../lib/errors/CombinedPropertyError'; import { ValidationError } from '../lib/errors/ValidationError'; import { Result } from '../lib/Result'; import { BaseValidator } from './imports'; @@ -19,25 +19,25 @@ export class MapValidator extends BaseValidator> { return Reflect.construct(this.constructor, [this.keyValidator, this.valueValidator, this.constraints]); } - protected handle(value: unknown): Result, ValidationError | CombinedError> { + protected handle(value: unknown): Result, ValidationError | CombinedPropertyError> { if (!(value instanceof Map)) { return Result.err(new ValidationError('MapValidator', 'Expected a map', value)); } - const errors: BaseError[] = []; + const errors: [string, BaseError][] = []; const transformed = new Map(); for (const [key, val] of value.entries()) { const keyResult = this.keyValidator.run(key); const valueResult = this.valueValidator.run(val); const { length } = errors; - if (keyResult.isErr()) errors.push(keyResult.error); - if (valueResult.isErr()) errors.push(valueResult.error); + if (keyResult.isErr()) errors.push([key, keyResult.error]); + if (valueResult.isErr()) errors.push([key, valueResult.error]); if (errors.length === length) transformed.set(keyResult.value!, valueResult.value!); } return errors.length === 0 // ? Result.ok(transformed) - : Result.err(new CombinedError(errors)); + : Result.err(new CombinedPropertyError(errors)); } } diff --git a/src/validators/ObjectValidator.ts b/src/validators/ObjectValidator.ts index 465c1bc7..e06074ba 100644 --- a/src/validators/ObjectValidator.ts +++ b/src/validators/ObjectValidator.ts @@ -1,6 +1,6 @@ import type { IConstraint } from '../constraints/base/IConstraint'; import type { BaseError } from '../lib/errors/BaseError'; -import { CombinedError } from '../lib/errors/CombinedError'; +import { CombinedPropertyError } from '../lib/errors/CombinedPropertyError'; import { MissingPropertyError } from '../lib/errors/MissingPropertyError'; import { UnknownPropertyError } from '../lib/errors/UnknownPropertyError'; import { ValidationError } from '../lib/errors/ValidationError'; @@ -12,7 +12,7 @@ export class ObjectValidator extends BaseValidator { public readonly shape: MappedObjectValidator; public readonly strategy: ObjectValidatorStrategy; private readonly keys: readonly (keyof T)[]; - private readonly handleStrategy: (value: NonNullObject) => Result; + private readonly handleStrategy: (value: NonNullObject) => Result; public constructor( shape: MappedObjectValidator, @@ -63,7 +63,7 @@ export class ObjectValidator extends BaseValidator { return Reflect.construct(this.constructor, [shape, this.strategy, this.constraints]); } - protected override handle(value: unknown): Result { + protected override handle(value: unknown): Result { const typeOfValue = typeof value; if (typeOfValue !== 'object') { return Result.err( @@ -82,7 +82,7 @@ export class ObjectValidator extends BaseValidator { return Reflect.construct(this.constructor, [this.shape, this.strategy, this.constraints]); } - private handleIgnoreStrategy(value: NonNullObject, errors: BaseError[] = []): Result { + private handleIgnoreStrategy(value: NonNullObject, errors: [PropertyKey, BaseError][] = []): Result { const entries = {} as T; let i = this.keys.length; @@ -95,20 +95,20 @@ export class ObjectValidator extends BaseValidator { } else { const error = result.error!; if (error instanceof ValidationError && error.given === undefined) { - errors.push(new MissingPropertyError(key)); + errors.push([key, new MissingPropertyError(key)]); } else { - errors.push(error); + errors.push([key, error]); } } } return errors.length === 0 // ? Result.ok(entries) - : Result.err(new CombinedError(errors)); + : Result.err(new CombinedPropertyError(errors)); } - private handleStrictStrategy(value: NonNullObject): Result { - const errors: BaseError[] = []; + private handleStrictStrategy(value: NonNullObject): Result { + const errors: [PropertyKey, BaseError][] = []; const finalResult = {} as T; const keysToIterateOver = [...new Set([...Object.keys(value), ...this.keys])].reverse(); let i = keysToIterateOver.length; @@ -124,21 +124,21 @@ export class ObjectValidator extends BaseValidator { } else { const error = result.error!; if (error instanceof ValidationError && error.given === undefined) { - errors.push(new MissingPropertyError(key)); + errors.push([key, new MissingPropertyError(key)]); } else { - errors.push(error); + errors.push([key, error]); } } continue; } - errors.push(new UnknownPropertyError(key, value[key as keyof NonNullObject])); + errors.push([key, new UnknownPropertyError(key, value[key as keyof NonNullObject])]); } return errors.length === 0 // ? Result.ok(finalResult) - : Result.err(new CombinedError(errors)); + : Result.err(new CombinedPropertyError(errors)); } } diff --git a/src/validators/RecordValidator.ts b/src/validators/RecordValidator.ts index 880d23d8..c1a886bf 100644 --- a/src/validators/RecordValidator.ts +++ b/src/validators/RecordValidator.ts @@ -1,6 +1,6 @@ import type { IConstraint } from '../constraints/base/IConstraint'; import type { BaseError } from '../lib/errors/BaseError'; -import { CombinedError } from '../lib/errors/CombinedError'; +import { CombinedPropertyError } from '../lib/errors/CombinedPropertyError'; import { ValidationError } from '../lib/errors/ValidationError'; import { Result } from '../lib/Result'; import { BaseValidator } from './imports'; @@ -17,7 +17,7 @@ export class RecordValidator extends BaseValidator> { return Reflect.construct(this.constructor, [this.validator, this.constraints]); } - protected handle(value: unknown): Result, ValidationError | CombinedError> { + protected handle(value: unknown): Result, ValidationError | CombinedPropertyError> { if (typeof value !== 'object') { return Result.err(new ValidationError('RecordValidator', 'Expected an object', value)); } @@ -26,17 +26,17 @@ export class RecordValidator extends BaseValidator> { return Result.err(new ValidationError('RecordValidator', 'Expected the value to not be null', value)); } - const errors: BaseError[] = []; + const errors: [string, BaseError][] = []; const transformed: Record = {}; for (const [key, val] of Object.entries(value!)) { const result = this.validator.run(val); if (result.isOk()) transformed[key] = result.value; - else errors.push(result.error!); + else errors.push([key, result.error!]); } return errors.length === 0 // ? Result.ok(transformed) - : Result.err(new CombinedError(errors)); + : Result.err(new CombinedPropertyError(errors)); } } diff --git a/tests/lib/errors/CombinedPropertyError.test.ts b/tests/lib/errors/CombinedPropertyError.test.ts new file mode 100644 index 00000000..53475686 --- /dev/null +++ b/tests/lib/errors/CombinedPropertyError.test.ts @@ -0,0 +1,61 @@ +import { inspect } from 'node:util'; +import { CombinedPropertyError, ValidationError } from '../../../src'; + +describe('CombinedError', () => { + const error = new CombinedPropertyError([ + ['foo', new ValidationError('StringValidator', 'Expected a string primitive', 42)], + [2, new ValidationError('StringValidator', 'Expected a string primitive', true)], + [Symbol('hello.there'), new ValidationError('StringValidator', 'Expected a string primitive', 75n)] + ]); + + test('GIVEN an instance THEN assigns fields correctly', () => { + expect(error.message).toBe('Received one or more errors'); + expect(error.errors).toHaveLength(3); + expect(error.errors[0][1]).toBeInstanceOf(ValidationError); + expect(error.errors[1][1]).toBeInstanceOf(ValidationError); + expect(error.errors[2][1]).toBeInstanceOf(ValidationError); + }); + + describe('inspect', () => { + test('GIVEN an inspected instance THEN formats data correctly', () => { + const content = inspect(error, { colors: false }); + const expected = [ + 'CombinedPropertyError (3)', + ' Received one or more errors', + '', + ' input.foo', + ' | ValidationError > StringValidator', + ' | Expected a string primitive', + ' | ', + ' | Received:', + ' | | 42', + '', + ' input[2]', + ' | ValidationError > StringValidator', + ' | Expected a string primitive', + ' | ', + ' | Received:', + ' | | true', + '', + ' input[Symbol(hello.there)]', + ' | ValidationError > StringValidator', + ' | Expected a string primitive', + ' | ', + ' | Received:', + ' | | 75n', + '' + ]; + + expect(content.startsWith(expected.join('\n'))).toBe(true); + }); + + test('GIVEN an inspected instance with negative depth THEN formats name only', () => { + const content = inspect(error, { colors: false, depth: -1 }); + const expected = [ + '[CombinedPropertyError]' // + ]; + + expect(content.startsWith(expected.join('\n'))).toBe(true); + }); + }); +});