Skip to content

Commit

Permalink
feat: display the property that errored (#35)
Browse files Browse the repository at this point in the history
  • Loading branch information
kyranet authored Feb 17, 2022
1 parent 18d20d1 commit fe188b0
Show file tree
Hide file tree
Showing 9 changed files with 137 additions and 33 deletions.
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
40 changes: 40 additions & 0 deletions src/lib/errors/CombinedPropertyError.ts
Original file line number Diff line number Diff line change
@@ -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})]`;
}
}
14 changes: 7 additions & 7 deletions src/validators/ArrayValidator.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -17,22 +17,22 @@ export class ArrayValidator<T> extends BaseValidator<T[]> {
return Reflect.construct(this.constructor, [this.validator, this.constraints]);
}

protected handle(values: unknown): Result<T[], ValidationError | CombinedError> {
protected handle(values: unknown): Result<T[], ValidationError | CombinedPropertyError> {
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));
}
}
3 changes: 2 additions & 1 deletion src/validators/BaseValidator.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -66,7 +67,7 @@ export abstract class BaseValidator<T> {
return Reflect.construct(this.constructor, [this.constraints]);
}

protected abstract handle(value: unknown): Result<T, ValidationError | CombinedError>;
protected abstract handle(value: unknown): Result<T, ValidationError | CombinedError | CombinedPropertyError>;

protected addConstraint(constraint: IConstraint<T>): this {
const clone = this.clone();
Expand Down
3 changes: 2 additions & 1 deletion src/validators/DefaultValidator.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -15,7 +16,7 @@ export class DefaultValidator<T> extends BaseValidator<T | undefined> {
this.defaultValue = value;
}

protected handle(value: unknown): Result<T, ValidationError | CombinedError> {
protected handle(value: unknown): Result<T, ValidationError | CombinedError | CombinedPropertyError> {
return typeof value === 'undefined' //
? Result.ok(getValue(this.defaultValue))
: this.validator['handle'](value); // eslint-disable-line @typescript-eslint/dot-notation
Expand Down
12 changes: 6 additions & 6 deletions src/validators/MapValidator.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -19,25 +19,25 @@ export class MapValidator<K, V> extends BaseValidator<Map<K, V>> {
return Reflect.construct(this.constructor, [this.keyValidator, this.valueValidator, this.constraints]);
}

protected handle(value: unknown): Result<Map<K, V>, ValidationError | CombinedError> {
protected handle(value: unknown): Result<Map<K, V>, 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<K, V>();

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));
}
}
26 changes: 13 additions & 13 deletions src/validators/ObjectValidator.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -12,7 +12,7 @@ export class ObjectValidator<T extends NonNullObject> extends BaseValidator<T> {
public readonly shape: MappedObjectValidator<T>;
public readonly strategy: ObjectValidatorStrategy;
private readonly keys: readonly (keyof T)[];
private readonly handleStrategy: (value: NonNullObject) => Result<T, CombinedError>;
private readonly handleStrategy: (value: NonNullObject) => Result<T, CombinedPropertyError>;

public constructor(
shape: MappedObjectValidator<T>,
Expand Down Expand Up @@ -63,7 +63,7 @@ export class ObjectValidator<T extends NonNullObject> extends BaseValidator<T> {
return Reflect.construct(this.constructor, [shape, this.strategy, this.constraints]);
}

protected override handle(value: unknown): Result<T, ValidationError | CombinedError> {
protected override handle(value: unknown): Result<T, ValidationError | CombinedPropertyError> {
const typeOfValue = typeof value;
if (typeOfValue !== 'object') {
return Result.err(
Expand All @@ -82,7 +82,7 @@ export class ObjectValidator<T extends NonNullObject> extends BaseValidator<T> {
return Reflect.construct(this.constructor, [this.shape, this.strategy, this.constraints]);
}

private handleIgnoreStrategy(value: NonNullObject, errors: BaseError[] = []): Result<T, CombinedError> {
private handleIgnoreStrategy(value: NonNullObject, errors: [PropertyKey, BaseError][] = []): Result<T, CombinedPropertyError> {
const entries = {} as T;
let i = this.keys.length;

Expand All @@ -95,20 +95,20 @@ export class ObjectValidator<T extends NonNullObject> extends BaseValidator<T> {
} 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<T, CombinedError> {
const errors: BaseError[] = [];
private handleStrictStrategy(value: NonNullObject): Result<T, CombinedPropertyError> {
const errors: [PropertyKey, BaseError][] = [];
const finalResult = {} as T;
const keysToIterateOver = [...new Set([...Object.keys(value), ...this.keys])].reverse();
let i = keysToIterateOver.length;
Expand All @@ -124,21 +124,21 @@ export class ObjectValidator<T extends NonNullObject> extends BaseValidator<T> {
} 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));
}
}

Expand Down
10 changes: 5 additions & 5 deletions src/validators/RecordValidator.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -17,7 +17,7 @@ export class RecordValidator<T> extends BaseValidator<Record<string, T>> {
return Reflect.construct(this.constructor, [this.validator, this.constraints]);
}

protected handle(value: unknown): Result<Record<string, T>, ValidationError | CombinedError> {
protected handle(value: unknown): Result<Record<string, T>, ValidationError | CombinedPropertyError> {
if (typeof value !== 'object') {
return Result.err(new ValidationError('RecordValidator', 'Expected an object', value));
}
Expand All @@ -26,17 +26,17 @@ export class RecordValidator<T> extends BaseValidator<Record<string, T>> {
return Result.err(new ValidationError('RecordValidator', 'Expected the value to not be null', value));
}

const errors: BaseError[] = [];
const errors: [string, BaseError][] = [];
const transformed: Record<string, T> = {};

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));
}
}
61 changes: 61 additions & 0 deletions tests/lib/errors/CombinedPropertyError.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
});

0 comments on commit fe188b0

Please sign in to comment.