From e5c2ddc1662304b26d89de4b6b2ae2762c01fa2c Mon Sep 17 00:00:00 2001 From: Charles Pascoe Date: Sat, 23 Dec 2017 18:05:40 +0000 Subject: [PATCH 1/6] refactor(lib/index:ValidationError): Change errorCode type from enum to string String type makes it easier to implement custom vaildators --- lib/index.ts | 21 +++------------------ 1 file changed, 3 insertions(+), 18 deletions(-) diff --git a/lib/index.ts b/lib/index.ts index d450f1c..eca6812 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -13,21 +13,6 @@ export interface ILength { } -export type ErrorCode = 'UNHANDLED_ERROR' - | 'NOT_OBJECT' - | 'NOT_BOOLEAN' - | 'NOT_NUMBER' - | 'LESS_THAN_MIN' - | 'GREATER_THAN_MAX' - | 'NOT_STRING' - | 'FAILED_REGEXP' - | 'LESS_THAN_MIN_LENGTH' - | 'GREATER_THAN_MAX_LENGTH' - | 'LENGTH_NOT_EQUAL' - | 'NOT_ARRAY' - | 'NOT_EQUAL'; - - export abstract class PathNode { } @@ -59,7 +44,7 @@ export class ArrayIndexPathNode extends PathNode { export class ValidationError { constructor( - public readonly errorCode: ErrorCode, + public readonly errorCode: string, public readonly message: string, public readonly path: PathNode[] = [] ) { } @@ -107,7 +92,7 @@ export function assertThat(name: string, assertion: (arg: any) => T): (arg: a err.path.unshift(new KeyPathNode(name)); throw err; } else { - throw new ValidationError('UNHANDLED_ERROR', `${err.message || 'Unknown error'}`, [new KeyPathNode(name)]); + throw new ValidationError('UNHANDLED_ERROR', `${typeof err === 'object' && err.message || 'Unknown error'}`, [new KeyPathNode(name)]); } } }; @@ -267,7 +252,7 @@ export function eachItem(assertion: (arg: any) => T, next?: (arg: any[]) => a err.path.unshift(new ArrayIndexPathNode(index)); throw err; } else { - throw new ValidationError('UNHANDLED_ERROR', `${err.message || 'Unknown error'}`, [new ArrayIndexPathNode(name)]); + throw new ValidationError('UNHANDLED_ERROR', `${typeof err === 'object' && err.message || 'Unknown error'}`, [new ArrayIndexPathNode(index)]); } } }); From 7c4546e3a569e6812c383d31b61331a0e991634f Mon Sep 17 00:00:00 2001 From: Charles Pascoe Date: Sat, 23 Dec 2017 20:19:01 +0000 Subject: [PATCH 2/6] feat(lib/index): Implement multiple error handling All errors will be reported, rather than just the first --- lib/index.ts | 103 +++++++++++++++++++++++++++++++++++---------------- 1 file changed, 71 insertions(+), 32 deletions(-) diff --git a/lib/index.ts b/lib/index.ts index eca6812..9a6d390 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -43,39 +43,91 @@ export class ArrayIndexPathNode extends PathNode { export class ValidationError { + public readonly path: PathNode[] = []; + constructor( public readonly errorCode: string, - public readonly message: string, - public readonly path: PathNode[] = [] + public readonly message: string ) { } - public toString(): string { - return `Validation failed for $root${this.path.map(node => node.toString()).join('')}: ${this.message}`; + public toString(root: string = '$root'): string { + return `${this.pathString(root)}: ${this.message}`; + } + + public pathString(root: string = '$root'): string { + return root + this.path.map(node => node.toString()).join(''); + } +} + + +export class ValidationErrorCollection { + public readonly errors: ValidationError[] = []; + + constructor(error?: ValidationError) { + if (error) { + this.errors.push(error); + } + } + + public insertError(node: PathNode, error: ValidationError) { + error.path.unshift(node); + this.errors.push(error); + } + + public handleError(node: PathNode, err: any) { + if (err instanceof ValidationErrorCollection) { + for (const error of err.errors) { + this.insertError(node, error); + } + } else if (err instanceof ValidationError) { + this.insertError(node, err); + } else { + this.insertError( + node, + new ValidationError('UNHANDLED_ERROR', `${typeof err === 'object' && err.message || 'Unknown error'}`) + ); + } + } + + public toString(root: string = '$root'): string { + return `${this.errors.length} validation error${this.errors.length === 1 ? '' : 's'}:\n ${this.errors.map(error => error.toString(root)).join('\n ')}`; } } export function validate(arg: any, validator: Validator): Validated { - if (typeof arg !== 'object') throw new ValidationError('NOT_OBJECT', `Expected object, got ${typeof arg}`); + if (typeof arg !== 'object') throw new ValidationErrorCollection(new ValidationError('NOT_OBJECT', `Expected object, got ${typeof arg}`)); - let result: {[key in keyof T]?: T[key]} = {}; + const result: {[key in keyof T]?: T[key]} = {}; - for (let key in validator) { - result[key] = validator[key](arg[key]); + let validationErrorCollection: ValidationErrorCollection | null = null; + + for (const key in validator) { + try { + result[key] = validator[key](arg[key]); + } catch (err) { + if (validationErrorCollection === null) { + validationErrorCollection = new ValidationErrorCollection(); + } + + validationErrorCollection.handleError(new KeyPathNode(key), err); + } } + if (validationErrorCollection !== null) throw validationErrorCollection; + return result as Validated; } export function extendValidator(validator1: Validator, validator2: Validator): Validator { - let result: any = {}; + const result: any = {}; - for (let key in validator1) { + for (const key in validator1) { result[key] = validator1[key]; } - for (let key in validator2) { + for (const key in validator2) { result[key] = validator2[key]; } @@ -83,22 +135,6 @@ export function extendValidator(validator1: Validator, validator2: Valid } -export function assertThat(name: string, assertion: (arg: any) => T): (arg: any) => T { - return (arg: any) => { - try { - return assertion(arg); - } catch (err) { - if (err instanceof ValidationError) { - err.path.unshift(new KeyPathNode(name)); - throw err; - } else { - throw new ValidationError('UNHANDLED_ERROR', `${typeof err === 'object' && err.message || 'Unknown error'}`, [new KeyPathNode(name)]); - } - } - }; -} - - // ASSERTIONS // @@ -244,19 +280,22 @@ export function eachItem(assertion: (arg: any) => T): (arg: any[]) => T[]; export function eachItem(assertion: (arg: any) => T, next: (arg: T[]) => U): (arg: any[]) => U; export function eachItem(assertion: (arg: any) => T, next?: (arg: any[]) => any): (arg: any[]) => any { return (arg: any[]) => { + let validationErrorCollection: ValidationErrorCollection | null = null; + const mapped = arg.map((item, index) => { try { return assertion(item); } catch (err) { - if (err instanceof ValidationError) { - err.path.unshift(new ArrayIndexPathNode(index)); - throw err; - } else { - throw new ValidationError('UNHANDLED_ERROR', `${typeof err === 'object' && err.message || 'Unknown error'}`, [new ArrayIndexPathNode(index)]); + if (validationErrorCollection === null) { + validationErrorCollection = new ValidationErrorCollection(); } + + validationErrorCollection.handleError(new ArrayIndexPathNode(index), err); } }); + if (validationErrorCollection !== null) throw validationErrorCollection; + return next ? next(mapped) : mapped; } } From 0f7667eca6e1e4741e9b72ec1be6093a5cc94a4a Mon Sep 17 00:00:00 2001 From: Charles Pascoe Date: Sat, 23 Dec 2017 20:42:51 +0000 Subject: [PATCH 3/6] docs(README): Align with new error implementation --- README.md | 77 +++++++++++++++++++++++++++++-------------------------- 1 file changed, 40 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index ac1d7b6..9b36123 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,8 @@ # Validate Objects Against TypeScript Interfaces # +Builds strongly-typed validators that can prove to the TypeScript compiler that a given object conforms to a TypeScript interface. + - [Installation](#installation) - [Basic Usage](#basic-usage) - [Assertions](#assertions) @@ -12,7 +14,6 @@ - [Validator](#validator) - [extendValidator](#extendvalidator) - [validate](#validate) - - [assertThat](#assertthat) - [optional](#optional) - [nullable](#nullable) - [defaultsTo](#defaultsto) @@ -49,10 +50,10 @@ interface Employee { // 2) Define a schema const employeeValidator: Validator = { - name: assertThat('name', isString(minLength(1))), - roleCode: assertThat('roleCode', isNumber(min(1, max(10)))), - completedTraining: assertThat('completedTraining', optional(isBoolean())), - addressPostcode: assertThat('addressPostcode', isString(matches(/^[a-z]{2}\d{1,2}\s+\d{1,2}[a-z]{2}$/i))) + name: isString(minLength(1)), + roleCode: isNumber(min(1, max(10))), + completedTraining: optional(isBoolean()), + addressPostcode: isString(matches(/^[a-z]{2}\d{1,2}\s+\d{1,2}[a-z]{2}$/i)) }; // 3) Validate @@ -68,7 +69,7 @@ try { let wrong: Employee = validate({ name: 'Name', roleCode: 4, - completedTraining: false, + completedTraining: 'false', addressPostcode: 'WRONG' }, employeeValidator); } catch (err) { @@ -76,26 +77,30 @@ try { } // Outputs: -// Validation failed for $root.addressPostcode: Failed regular expression /^[a-z]{2}\d{1,2}\s+\d{1,2}[a-z]{2}$/i +// 2 validation errors: +// $root.completedTraining: Expected boolean, got string +// $root.addressPostcode: Failed regular expression /^[a-z]{2}\d{1,2}\s+\d{1,2}[a-z]{2}$/i ``` ## Assertions ## This library provides a number of strongly-typed assertions which can be combined to validate the type of each property. -An assertion may take another assertion as its last argument; if assertion check passes, it calls the next assertion. For example, `isString(minLength(1, maxLength(10)))` first checks if the value is a string, then checks if its length is at least 1, and then checks that its length is no more than 10. If `isString` fails, `minLength` isn't run. Chaining assertions in this way allows for complex values to be validated. +An assertion may take another assertion as its last argument; if assertion check passes, it calls the next assertion. For example, `isString(minLength(1, maxLength(10)))` first checks if the value is a string, then checks if its length is at least 1, and then checks that its length is no more than 10. If `isString` fails, `minLength` isn't run. Chaining assertions in this way allows for complex validation. -Some assertions require other assertions to come before it. For example, `minLength` can't be used by itself because it needs another assertion to check that the value has the `length` property - so something like `isString(minLength(1))` or `isArray(minLength(0))`. +Some assertions require other assertions to come before it. For example, `minLength` can't be used by itself because it needs another assertion to check that the value has the `length` property - so something like `isString(minLength(1))` or `isArray(minLength(1))`. ## Handling Validation Errors ## -Errors will always be of the type `ValidationError`, which has a number of useful properties: +Errors will always be of the type `ValidationErrorCollection`, which has a property `error: ValidationError[]`. + +The `ValidationError` type has a number of useful properties: - `errorCode`: A string which is one of a set of error codes, e.g. `NOT_STRING`. Useful for producing custom error messages or triggering certain error logic. - `message`: A human-readable error message, with more information as to why the validation failed - `path`: An array of objects that describe the path to the value that caused the validation to fail. Each object is either an `ArrayIndexPathNode` (which has an `index` property) or `KeyPathNode` (which has a `key` property). -There's a `toString` method which prints this information in a human-readable format. +The `ValidationErrorCollection.toString()` method prints this information in a human-readable format. The name of the root object defaults to `$root`, but this can be changed by passing a string, e.g. `err.toString('this')`. ## Documentation ## @@ -111,25 +116,25 @@ interface IFoo { // A valid validator const fooValidator: Validator = { - bar: assertThat('bar', isString()), - baz: assertThat('baz', isNumber()) + bar: isString(), + baz: isNumber() }; // All of these are invalid, and will result in an error from the TypeScript compiler const fooValidator: Validator = { - bar: assertThat('bar', isString()) + bar: isString() }; // Missing 'baz' const fooValidator: Validator = { - bar: assertThat('bar', isNumber()), // Wrong type - baz: assertThat('baz', isNumber()) + bar: isNumber(), // Wrong type + baz: isNumber() }; const fooValidator: Validator = { - bar: assertThat('bar', isString()), - baz: assertThat('baz', isNumber()), - blah: assertThat('blah', isBoolean()) // Unexpected property + bar: isString(), + baz: isNumber(), + blah: isBoolean() // Unexpected property }; ``` @@ -145,7 +150,7 @@ interface IFoo { } const fooValidator: Validator = { - abc: assertThat('abc', isNumber()) + abc: isNumber() }; interface IBar extends IFoo { @@ -153,7 +158,7 @@ interface IBar extends IFoo { } const barValidator: Validator = extendValidator(fooValidator, { - xyz: assertThat('xyz', isString()) + xyz: isString() }); ``` @@ -162,9 +167,6 @@ const barValidator: Validator = extendValidator(fooValidator, { Checks that `arg` conforms to the type `T` using the given `validator`. Returns an object that conforms to `T` or throws an error. -### assertThat ### -Used to start an assertion chain. - ### optional ### Used when the property may not present on the object, or its value is undefined. Example: @@ -174,7 +176,7 @@ interface IFoo { } const fooValidator: Validator = { - bar: assertThat('bar', optional(isString())), + bar: optional(isString()), }; // Both of these are acceptable @@ -202,7 +204,7 @@ interface IFoo { } const fooValidator: Validator = { - bar: assertThat('bar', nullable(isString())), + bar: nullable(isString()), }; ``` @@ -216,7 +218,7 @@ interface IFoo { } const fooValidator: Validator = { - bar: assertThat('bar', defaultsTo('baz', isString())), + bar: defaultsTo('baz', isString()), }; const foo = validate({}, fooValidator); @@ -236,7 +238,7 @@ interface IFoo { } const fooValidator: Validator = { - bar: assertThat('bar', onErrorDefaultsTo('baz', isString())), + bar: onErrorDefaultsTo('baz', isString()), }; const foo = validate({bar: 123}, fooValidator); @@ -244,6 +246,7 @@ const foo = validate({bar: 123}, fooValidator); console.log(foo); // {bar: 'baz'} ``` + ### isBoolean ### Throws an error if the value is not a boolean. @@ -259,7 +262,7 @@ interface IFoo { } const fooValidator: Validator = { - bar: assertThat('bar', isNuber(min(0))) + bar: isNuber(min(0)) }; // This will throw an error @@ -281,7 +284,7 @@ interface IFoo { } const fooValidator: Validator = { - bar: assertThat('bar', isStirng(matches(/^[a-z]+$/))) + bar: isStirng(matches(/^[a-z]+$/)) }; // This will throw an error @@ -297,7 +300,7 @@ interface IFoo { } const fooValidator: Validator = { - bar: assertThat('bar', isStirng(minLength(1))) + bar: isStirng(minLength(1)) }; // This will throw an error @@ -319,7 +322,7 @@ interface IFoo { } const fooValidator: Validator = { - bar: assertThat('bar', isArray()) + bar: isArray() }; // This is valid @@ -342,7 +345,7 @@ interface IFoo { } const fooValidator: Validator = { - bar: assertThat('bar', isArray(eachItem(isNumber()))) + bar: isArray(eachItem(isNumber())) }; // This is valid @@ -365,7 +368,7 @@ interface IFoo { } const fooValidator: Validator = { - bar: assertThat('bar', isObject()) + bar: isObject() }; // This is valid, but it wouldn't be safe to access properties on foo.bar @@ -392,11 +395,11 @@ interface IFoo { } const barValidator: Validator = { - baz: assertThat('baz', isNumber()) + baz: isNumber() }; const fooValidator: Validator = { - bar: assertThat('bar', conformsTo(barValidator)) + bar: conformsTo(barValidator) }; // This is valid @@ -425,7 +428,7 @@ interface IFoo { } const fooValidator: Validator = { - bar: assertThat('bar', equals('A', 'B', 'C')) + bar: equals('A', 'B', 'C') }; // Throws an error From 1e3a21d59cff6c38041dfedf80cf61ed1eab1049 Mon Sep 17 00:00:00 2001 From: Charles Pascoe Date: Sat, 23 Dec 2017 20:45:36 +0000 Subject: [PATCH 4/6] docs(README): Reorganised sections --- README.md | 31 ++++++++++++++----------------- 1 file changed, 14 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 9b36123..e6d1325 100644 --- a/README.md +++ b/README.md @@ -8,8 +8,6 @@ Builds strongly-typed validators that can prove to the TypeScript compiler that - [Installation](#installation) - [Basic Usage](#basic-usage) -- [Assertions](#assertions) -- [Handling Validation Errors](#handling-validation-errors) - [Documentation](#documentation) - [Validator](#validator) - [extendValidator](#extendvalidator) @@ -32,6 +30,7 @@ Builds strongly-typed validators that can prove to the TypeScript compiler that - [isObject](#isobject) - [conformsTo](#conformsto) - [equals](#equals) +- [Handling Validation Errors](#handling-validation-errors) ## Installation ## @@ -83,27 +82,13 @@ try { ``` -## Assertions ## +## Documentation ## This library provides a number of strongly-typed assertions which can be combined to validate the type of each property. An assertion may take another assertion as its last argument; if assertion check passes, it calls the next assertion. For example, `isString(minLength(1, maxLength(10)))` first checks if the value is a string, then checks if its length is at least 1, and then checks that its length is no more than 10. If `isString` fails, `minLength` isn't run. Chaining assertions in this way allows for complex validation. Some assertions require other assertions to come before it. For example, `minLength` can't be used by itself because it needs another assertion to check that the value has the `length` property - so something like `isString(minLength(1))` or `isArray(minLength(1))`. -## Handling Validation Errors ## - -Errors will always be of the type `ValidationErrorCollection`, which has a property `error: ValidationError[]`. - -The `ValidationError` type has a number of useful properties: - -- `errorCode`: A string which is one of a set of error codes, e.g. `NOT_STRING`. Useful for producing custom error messages or triggering certain error logic. -- `message`: A human-readable error message, with more information as to why the validation failed -- `path`: An array of objects that describe the path to the value that caused the validation to fail. Each object is either an `ArrayIndexPathNode` (which has an `index` property) or `KeyPathNode` (which has a `key` property). - -The `ValidationErrorCollection.toString()` method prints this information in a human-readable format. The name of the root object defaults to `$root`, but this can be changed by passing a string, e.g. `err.toString('this')`. - -## Documentation ## - ### Validator ### `Validator` is a special type that enables TypeScript to validate that your validator correctly aligns to the type it is supposed to validate. @@ -436,3 +421,15 @@ validate({ bar: 'D' }, fooValidator); ``` + +## Handling Validation Errors ## + +Errors will always be of the type `ValidationErrorCollection`, which has a property `error: ValidationError[]`. + +The `ValidationError` type has a number of useful properties: + +- `errorCode`: A string which is one of a set of error codes, e.g. `NOT_STRING`. Useful for producing custom error messages or triggering certain error logic. +- `message`: A human-readable error message, with more information as to why the validation failed +- `path`: An array of objects that describe the path to the value that caused the validation to fail. Each object is either an `ArrayIndexPathNode` (which has an `index` property) or `KeyPathNode` (which has a `key` property). + +The `ValidationErrorCollection.toString()` method prints this information in a human-readable format. The name of the root object defaults to `$root`, but this can be changed by passing a string, e.g. `err.toString('this')`. From 5a62638f6d92cee9631e5fedd491d1ac36134df5 Mon Sep 17 00:00:00 2001 From: Charles Pascoe Date: Sat, 23 Dec 2017 20:48:14 +0000 Subject: [PATCH 5/6] docs(README): Split up table of contents --- README.md | 52 +++++++++++++++++++++++++--------------------------- 1 file changed, 25 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index e6d1325..00c324f 100644 --- a/README.md +++ b/README.md @@ -6,32 +6,6 @@ Builds strongly-typed validators that can prove to the TypeScript compiler that a given object conforms to a TypeScript interface. -- [Installation](#installation) -- [Basic Usage](#basic-usage) -- [Documentation](#documentation) - - [Validator](#validator) - - [extendValidator](#extendvalidator) - - [validate](#validate) - - [optional](#optional) - - [nullable](#nullable) - - [defaultsTo](#defaultsto) - - [onErrorDefaultsTo](#onerrordefaultsto) - - [isBoolean](#isboolean) - - [isNumber](#isnumber) - - [min](#min) - - [max](#max) - - [isString](#isstring) - - [matches](#matches) - - [minLength](#minLength) - - [maxLength](#maxLength) - - [lengthIs](#lengthis) - - [isArray](#isarray) - - [eachItem](#eachitem) - - [isObject](#isobject) - - [conformsTo](#conformsto) - - [equals](#equals) -- [Handling Validation Errors](#handling-validation-errors) - ## Installation ## `$ npm install --save validate-interface` @@ -89,6 +63,30 @@ An assertion may take another assertion as its last argument; if assertion check Some assertions require other assertions to come before it. For example, `minLength` can't be used by itself because it needs another assertion to check that the value has the `length` property - so something like `isString(minLength(1))` or `isArray(minLength(1))`. +Jump to section: +- [Validator](#validator) +- [extendValidator](#extendvalidator) +- [validate](#validate) +- [optional](#optional) +- [nullable](#nullable) +- [defaultsTo](#defaultsto) +- [onErrorDefaultsTo](#onerrordefaultsto) +- [isBoolean](#isboolean) +- [isNumber](#isnumber) +- [min](#min) +- [max](#max) +- [isString](#isstring) +- [matches](#matches) +- [minLength](#minLength) +- [maxLength](#maxLength) +- [lengthIs](#lengthis) +- [isArray](#isarray) +- [eachItem](#eachitem) +- [isObject](#isobject) +- [conformsTo](#conformsto) +- [equals](#equals) +- [Handling Validation Errors](#handling-validation-errors) + ### Validator ### `Validator` is a special type that enables TypeScript to validate that your validator correctly aligns to the type it is supposed to validate. @@ -422,7 +420,7 @@ validate({ }, fooValidator); ``` -## Handling Validation Errors ## +### Handling Validation Errors ### Errors will always be of the type `ValidationErrorCollection`, which has a property `error: ValidationError[]`. From cdf64a0ffcc144cd440619f50f9489184d5242ad Mon Sep 17 00:00:00 2001 From: Charles Pascoe Date: Wed, 27 Dec 2017 11:23:44 +0000 Subject: [PATCH 6/6] chore(package.json): Version 0.5.0 --- package-lock.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2035500..8ae75c3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "validate-interface", - "version": "0.4.1", + "version": "0.5.0", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index 20fdd5c..95ddb0c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "validate-interface", - "version": "0.4.1", + "version": "0.5.0", "description": "Validate Objects Against TypeScript Interfaces", "main": "dist/index.js", "types": "dist/index.d.ts",