Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: display the property that errored #35

Merged
merged 2 commits into from
Feb 17, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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);
});
});
});