Skip to content

Commit

Permalink
feat(errors): Add information about nested fields with errors (#61)
Browse files Browse the repository at this point in the history
* feat(errors): Add information about nested fields with errors

* v0.7.4-0

* wip

* Working implementation

* Coverage

* Final touch
  • Loading branch information
typeofweb committed May 1, 2021
1 parent 9dcd6bf commit 77312ff
Show file tree
Hide file tree
Showing 25 changed files with 302 additions and 154 deletions.
97 changes: 97 additions & 0 deletions __tests__/errors.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { number, object, string, validate, date, pipe, ValidationError, refine } from '../src';
import { modifierToString } from '../src/refine';

const expectToMatchError = (fn: () => any, obj: Record<string, any>) => {
try {
fn();
} catch (err) {
if (err instanceof ValidationError) {
return expect(err.getDetails()).toStrictEqual(obj);
}
fail(err);
}
fail();
};

describe('errors', () => {
it('should list fields in object which are incorrect', () => {
const validator = pipe(
object({
validString: string(),
invalidNumber: number(),
validNumber: number(),
invalidString: string(),
validDate: date(),
invalidDate: date(),
}),
validate,
);

expectToMatchError(
() =>
validator({
validString: 'aaa',
invalidNumber: 'vvvv',
validNumber: 123,
invalidString: 1333,
validDate: new Date('2020'),
invalidDate: 'no siema eniu',
}),
{
invalidNumber: { expected: 'number', got: 'vvvv' },
invalidString: { expected: 'string', got: 1333 },
invalidDate: { expected: 'Date', got: 'no siema eniu' },
},
);
});

it('should list nested objects which are undefined', () => {
const validator = pipe(
object({
nested: object({
invalid: string(),
})(),
}),
validate,
);

expectToMatchError(() => validator({}), {
nested: { expected: '{ invalid: string }', got: undefined },
});
});

it('should list nested objects which are undefined', () => {
const validator = pipe(
object({
a: number(),
nested: object({
invalid: string(),
})(),
}),
validate,
);

expectToMatchError(() => validator({ a: 123 }), {
nested: { expected: '{ invalid: string }', got: undefined },
});
});

it('should use custom refinement to string', () => {
const email = refine<string, string>(
(value: string, t) => (value.includes('@') ? t.nextValid(value) : t.left(value)),
modifierToString('email'),
);

const validator = pipe(
object({
// shouldBeEmail: string(email()),
shouldBeEmail: string(email()),
}),
validate,
);

expectToMatchError(() => validator({ shouldBeEmail: 123 }), {
shouldBeEmail: { expected: 'email(string)', got: 123 },
});
});
});
2 changes: 1 addition & 1 deletion __tests__/property-tests.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ const throws = <T extends readonly unknown[], Err extends Error>(
if (ErrorClass) {
const isValid = error instanceof ErrorClass && (!message || error.message === message);
if (!isValid) {
console.log(error);
// console.log(error);
}
return isValid;
}
Expand Down
18 changes: 12 additions & 6 deletions __tests__/refinements.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,21 +11,27 @@ import {
validate,
λ,
} from '../src';
import { modifierToString } from '../src/refine';

describe('refinements', () => {
const even = refine((value: number, t) => (value % 2 === 0 ? t.nextValid(value) : t.left(value)));
const even = refine(
(value: number, t) => (value % 2 === 0 ? t.nextValid(value) : t.left(value)),
modifierToString('even'),
);

const noDuplicateItems = refine((arr: ReadonlyArray<unknown>, t) => {
const allUnique = arr.every((item, index) => index === arr.indexOf(item));
return allUnique ? t.nextValid(arr) : t.left(arr);
});
}, modifierToString('noDuplicateItems'));

const allowTimestamps = refine((value, t) =>
typeof value === 'number' ? t.nextValid(new Date(value)) : t.nextValid(value),
const allowTimestamps = refine(
(value, t) => (typeof value === 'number' ? t.nextValid(new Date(value)) : t.nextValid(value)),
modifierToString('allowTimestamps'),
);

const presentOrFuture = refine((value: Date, t) =>
value.getTime() >= Date.now() ? t.nextValid(value) : t.left(value),
const presentOrFuture = refine(
(value: Date, t) => (value.getTime() >= Date.now() ? t.nextValid(value) : t.left(value)),
modifierToString('presentOrFuture'),
);

it('nullable', () => {
Expand Down
19 changes: 12 additions & 7 deletions __tests__/refinements.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,37 +12,42 @@ import {
minArrayLength,
minStringLength,
} from '../src';
import { refine } from '../src/refine';
import { modifierToString, refine } from '../src/refine';

const nullR = pipe(string, nullable, validate)('');
expectType<string | null>(nullR);

const optionalR = pipe(string, optional, validate)('');
expectType<string | undefined>(optionalR);

const even = refine((value: number, t) => (value % 2 === 0 ? t.nextValid(value) : t.left(value)));
const even = refine(
(value: number, t) => (value % 2 === 0 ? t.nextValid(value) : t.left(value)),
modifierToString('even'),
);
const evenR = pipe(number, even, validate)('');
expectType<number>(evenR);

const noDuplicateItems = refine((arr: ReadonlyArray<unknown>, t) => {
const allUnique = arr.every((item, index) => index === arr.indexOf(item));
return allUnique ? t.nextValid(arr) : t.left(arr);
});
}, modifierToString('noDuplicateItems'));
const noDuplicateItemsR = pipe(array(string()), noDuplicateItems, validate)('');
expect<readonly string[]>(noDuplicateItemsR);

const noDuplicateItemsAnyR = pipe(array(number()), noDuplicateItems, validate)('');
expect<readonly number[]>(noDuplicateItemsAnyR);

const allowTimestamps = refine((value, t) =>
typeof value === 'number' ? t.nextValid(new Date(value)) : t.nextValid(value),
const allowTimestamps = refine(
(value, t) => (typeof value === 'number' ? t.nextValid(new Date(value)) : t.nextValid(value)),
modifierToString('allowTimestamps'),
);
const allowDateTimestamps = pipe(date, allowTimestamps);
const allowDateTimestampsR = pipe(allowDateTimestamps, validate)('');
expectType<Date>(allowDateTimestampsR);

const presentOrFuture = refine((value: Date, t) =>
value.getTime() >= Date.now() ? t.nextValid(value) : t.left(value),
const presentOrFuture = refine(
(value: Date, t) => (value.getTime() >= Date.now() ? t.nextValid(value) : t.left(value)),
modifierToString('presentOrFuture'),
);
const allowDateTimestampsR2 = pipe(presentOrFuture, date, allowTimestamps, validate)('');
expectType<Date>(allowDateTimestampsR2);
Expand Down
11 changes: 7 additions & 4 deletions __tests__/unit-tests.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -448,10 +448,13 @@ describe('@typeofweb/schema unit tests', () => {

it('should exit early if one of the validators in oneOf returns right', () => {
const spy = jest.fn();
const shouldNotBeCalled = refine((value) => {
spy(value);
throw new Error(String(value));
});
const shouldNotBeCalled = refine(
(value) => {
spy(value);
throw new Error(String(value));
},
() => [''],
);
const validator = pipe(oneOf([string(), nullable(number()), shouldNotBeCalled()]), validate);
expect(validator(null)).toEqual(null);
expect(spy).toHaveBeenCalledTimes(0);
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@typeofweb/schema",
"version": "0.7.3",
"version": "0.7.4-0",
"main": "dist/index.common.js",
"module": "dist/index.esm.js",
"browser": "dist/index.umd.js",
Expand Down
45 changes: 34 additions & 11 deletions src/errors.ts
Original file line number Diff line number Diff line change
@@ -1,30 +1,53 @@
import { schemaToString } from './stringify';
import type { SomeSchema } from './types';
import type { Primitives, SomeSchema } from './types';

export type Result = Record<string, ValidationError> | Primitives | Date | ValidationError;

export class ValidationError extends Error {
public readonly details: ErrorDetails;
private readonly schema: SomeSchema<any> | undefined;
private readonly value: unknown;
private readonly result?: Result;

constructor(schema: SomeSchema<any>, value: any) {
const expected = schemaToString(schema);
const got = typeof value === 'function' ? String(value) : JSON.stringify(value);
constructor(schema?: SomeSchema<any> | undefined, value?: unknown, result?: Result) {
const expectedStr = schemaToString(schema);
const gotStr = typeof value === 'function' ? String(value) : JSON.stringify(value);

const details: ErrorDetails = {
kind: 'TYPE_MISMATCH',
got,
expected,
fields: result,
};
super(`Invalid type! Expected ${details.expected} but got ${details.got}!`);
super(`Invalid type! Expected ${expectedStr} but got ${gotStr}!`);

this.details = details;
this.name = 'ValidationError';
this.schema = schema;
this.value = value;
this.result = result;
Error.captureStackTrace(this);

Object.setPrototypeOf(this, ValidationError.prototype);
}

getDetails(): Record<string, any> {
if (this.result instanceof ValidationError) {
return this.result.getDetails();
} else if (typeof this.result === 'object' && this.result && !Array.isArray(this.result)) {
return Object.fromEntries(
Object.entries(this.result).map(([key, error]) => {
/* istanbul ignore else */
if (error instanceof ValidationError) {
return [key, error.getDetails()];
} else {
return [key, error];
}
}),
);
} else {
return { expected: schemaToString(this.schema), got: this.value };
}
}
}

type ErrorDetails = {
readonly kind: 'TYPE_MISMATCH';
readonly expected: string;
readonly got: string;
readonly fields: any;
};
4 changes: 2 additions & 2 deletions src/modifiers/minArrayLength.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { refine } from '../refine';
import { modifierToString, refine } from '../refine';
import type { TupleOf } from '../types';

export const minArrayLength = <L extends number>(minLength: L) =>
Expand All @@ -11,4 +11,4 @@ export const minArrayLength = <L extends number>(minLength: L) =>
],
)
: t.left(value);
});
}, modifierToString(`minArrayLength(${minLength})`));
7 changes: 5 additions & 2 deletions src/modifiers/minStringLength.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { refine } from '../refine';
import { modifierToString, refine } from '../refine';

export const minStringLength = <L extends number>(minLength: L) =>
refine((value: string, t) => (value.length >= minLength ? t.nextValid(value) : t.left(value)));
refine(
(value: string, t) => (value.length >= minLength ? t.nextValid(value) : t.left(value)),
modifierToString(`minStringLength(${minLength})`),
);
2 changes: 1 addition & 1 deletion src/modifiers/nil.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,5 @@ import { refine } from '../refine';

export const nil = refine(
(value, t) => (value === null || value === undefined ? t.right(value) : t.nextNotValid(value)),
() => `undefined | null`,
() => [`undefined`, `null`],
);
3 changes: 1 addition & 2 deletions src/modifiers/nullable.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { refine } from '../refine';
import { typeToPrint } from '../stringify';

export const nullable = refine(
(value, t) => (value === null ? t.right(null) : t.nextNotValid(value)),
() => typeToPrint('null'),
`null`,
);
3 changes: 1 addition & 2 deletions src/modifiers/optional.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { refine } from '../refine';
import { typeToPrint } from '../stringify';

export const optional = refine(
(value, t) => (value === undefined ? t.right(undefined) : t.nextNotValid(value)),
() => typeToPrint('undefined'),
`undefined`,
);
37 changes: 29 additions & 8 deletions src/refine.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { unionToPrint } from './stringify';
import { getErrorArray, unionToPrint } from './stringify';
import type { Either, If, Next, Pretty, SomeSchema } from './types';
import { left, right, nextValid, nextNotValid } from './utils/either';

Expand All @@ -18,8 +18,10 @@ type RefinementToolkit = typeof refinementToolkit;

export const refine = <Output, Input, ExitEarlyResult = never>(
refinement: Refinement<Output, Input, ExitEarlyResult>,
toString?: () => string,
) => <S extends SomeSchema<Input>>(schema?: S) => {
nameOrToString:
| string
| ((outerToString?: () => string | readonly string[]) => string | readonly string[]),
) => <S extends SomeSchema<Input>>(innerSchema?: S) => {
type HasExitEarlyResult = unknown extends ExitEarlyResult
? false
: ExitEarlyResult extends never
Expand Down Expand Up @@ -56,9 +58,16 @@ export const refine = <Output, Input, ExitEarlyResult = never>(
| If<false, HasOutput | HasExitEarlyResult, S['__type']>;

return {
...schema,
toString() {
return unionToPrint([schema?.toString()!, toString?.()!].filter(Boolean));
...innerSchema,
name: refinement.name || typeof nameOrToString === 'string' ? nameOrToString : nameOrToString(),
toString(outerToString) {
const currentToString =
typeof nameOrToString === 'string' ? simpleTypeToString(nameOrToString) : nameOrToString;

if (!innerSchema) {
return currentToString(outerToString);
}
return unionToPrint([innerSchema?.toString(currentToString)].flat());
},
__validate(val) {
// eslint-disable-next-line functional/no-this-expression
Expand All @@ -67,10 +76,22 @@ export const refine = <Output, Input, ExitEarlyResult = never>(
if (innerResult?._t === 'left' || innerResult?._t === 'right') {
return innerResult;
}
if (!schema) {
if (!innerSchema) {
return innerResult;
}
return schema.__validate(innerResult.value);
return innerSchema.__validate(innerResult.value);
},
} as SomeSchema<Pretty<Result>>;
};

export const modifierToString = (str: string): Exclude<Parameters<typeof refine>[1], string> => (
outerToString,
) => {
return getErrorArray(outerToString?.(), str).reduce((acc, el) => `${el}(${acc})`);
};

export const simpleTypeToString = (str: string): Exclude<Parameters<typeof refine>[1], string> => (
outerToString,
) => {
return getErrorArray(str, outerToString?.());
};

1 comment on commit 77312ff

@vercel
Copy link

@vercel vercel bot commented on 77312ff May 1, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please sign in to comment.