diff --git a/README.md b/README.md index 12e3be1..19b0bde 100644 --- a/README.md +++ b/README.md @@ -4,12 +4,13 @@ [![Test status](https://github.com/paperhive/fefe/actions/workflows/test.yaml/badge.svg)](https://github.com/paperhive/fefe/actions/workflows/test.yaml) [![codecov](https://codecov.io/gh/paperhive/fefe/branch/main/graph/badge.svg?token=OZcHEYFYrQ)](https://codecov.io/gh/paperhive/fefe) -Validate, sanitize and transform values with proper TypeScript types and with zero dependencies. +Validate, sanitize and transform values with proper TypeScript types and with a single dependency ([fp-ts](https://www.npmjs.com/package/fp-ts)). **πŸ”Ž  Validation:** checks a value (example: check if value is string)
**:nut_and_bolt:  Sanitization:** if a value is not valid, try to transform it (example: transform value to `Date`)
**πŸ› οΈ  Transformation:** transforms a value (example: parse JSON)
-**πŸ”Œ  Everything is a function**: functional approach makes it easy to extend – just plug in your own function anywhere! +**πŸ”Œ  Everything is a function**: functional approach makes it easy to extend – just plug in your own function anywhere!
+**↔️  Based on `Either`:** explicit and type-safe error handling – `left` path is a (typed!) error, `right` path is a valid value (see below). ## Installation @@ -19,44 +20,50 @@ npm install fefe ## Usage +The + ### πŸ”Ž Validation example -Validation only checks the provided value and returns it with proper types. +Validation checks the provided value and returns it with proper types. ```typescript import { object, string } from 'fefe' const validatePerson = object({ name: string() }) -// result is of type { name: string } -const person = validatePerson({ name: 'Leia' }) +const result = validatePerson({ name: 'Leia' }) +if (isFailure(result)) { + return console.error(result.left) -// throws FefeError because 'foo' is not a valid property -validatePerson({ foo: 'bar' }) +// result is of type { name: string } +const person = result.right ``` ☝️ You can also use `fefe` to define your types easily: ```typescript -type Person = ReturnType // { name: string } +import { ValidatorReturnType } from 'fefe' +type Person = ValidatorReturnType // { name: string } ``` ### βš™οΈ Basic transformation example #### Parse a value -In this example a `string` needs to be parsed as a `Date`. +In this example a `string` needs to be parsed as a `Date`. Chaining functions can be achieved by the standard functional tools like `flow` and `chain` in [fp-ts](https://www.npmjs.com/package/fp-ts). ```typescript -import { object, parseDate, string } from 'fefe' +import { object, parseDate, string, ValidatorReturnType } from 'fefe' +import { chain } from 'fp-ts/lib/Either' +import { flow } from 'fp-ts/lib/function' const sanitizeMovie = object({ title: string(), - releasedAt: parseDate() + releasedAt: flow(string(), chain(parseDate())) }) // { title: string, releasedAt: Date } -type Movie = ReturnType +type Movie = ValidatorReturnType const movie: Movie = sanitizeMovie({ title: 'Star Wars', @@ -64,36 +71,46 @@ const movie: Movie = sanitizeMovie({ }) ``` -Then `movie` equals `{ title: 'Star Wars', releasedAt: Date(1977-05-25T12:00:00.000Z) }` (`releasedAt` now is a date). +Then `movie.right` equals `{ title: 'Star Wars', releasedAt: Date(1977-05-25T12:00:00.000Z) }` (`releasedAt` now is a date). #### Parse a value on demand (sanitize) -Sometimes a value might already be of the right type. In the following example we use `union()` to create a sanitizer that returns a provided value if it is a Date already and parse it otherwise. If it can't be parsed either the function will throw: +Sometimes a value might already be of the right type. In the following example we use `union()` to create a sanitizer that returns a provided value if it is a `Date` already and parse it otherwise. If it can't be parsed either the function will throw: ```typescript import { date, parseDate, union } from 'fefe' +import { chain } from 'fp-ts/lib/Either' +import { flow } from 'fp-ts/lib/function' -const sanitizeDate = union(date(), parseDate()) +const sanitizeDate = union( + date(), + flow(string(), chain(parseDate())) +) ``` ### πŸ› οΈ Complex transformation example -This is a more complex example that can be applied to parsing environment variables or query string parameters. Note how easy it is to apply a chain of functions to validate and transform a value (here we use `ramda`). +This is a more complex example that can be applied to parsing environment variables or query string parameters. Again, we use `flow` and `chain` to compose functions. Here, we also add a custom function that splits a string into an array. ```typescript -import { object, parseJson, string } from 'fefe' -import { pipe } from 'ramda' +import { object, parseJson, string, success } from 'fefe' +import { chain } from 'fp-ts/lib/Either' +import { flow } from 'fp-ts/lib/function' const parseConfig = object({ - gcloudCredentials: pipe( - parseJson(), - object({ secret: string() }) + gcloudCredentials: flow( + string() + chain(parseJson()), + chain(object({ secret: string() })) ), - whitelist: pipe(string(), secret => str.split(',')) + whitelist: flow( + string(), + chain(secret => success(str.split(','))) + ) }) // { gcloudCredentials: { secret: string }, whitelist: string[] } -type Config = ReturnType +type Config = ValidatorReturnType const config: Config = parseConfig({ gcloudCredentials: '{"secret":"foobar"}', @@ -101,94 +118,170 @@ const config: Config = parseConfig({ }) ``` -Then `config` will equal `{ gcloudCredentials: { secret: 'foobar'}, whitelist: ['alice', 'bob'] }`. +Then `config.right` will equal `{ gcloudCredentials: { secret: 'foobar'}, whitelist: ['alice', 'bob'] }`. ## Documentation +### Transformer + +A transformer is a function that accepts some value of type `V` (it could be `unknown`) and returns a type `T`: +```typescript +type Transform = (v: V) => Result +``` +The result can either be a `FefeError` (see below) or the validated value as type `T`: +```typescript +type Result = Either +``` + +`fefe` uses the `Either` pattern with types and functions from [fp-ts](https://www.npmjs.com/package/fp-ts). `Either` can either represent an error (the "left" path) or the successfully validated value (the "right" path). This results in type-safe errors and explicit error-handling. Example: + +```typescript +import { isFailure } from 'fefe' + +const result: Result = ... +if (isFailure(result)) { + console.error(result.left) + process.exit(1) +} +const name = result.right +``` + +You may wonder why `fefe` does not just throw an error and the answer is: +1. Throwing an error is a side-effect which goes against pure functional programming. +2. Lack of type-safety: A thrown error can be anything and needs run-time checking before it can be used in a meaningful way. + +You can read more about it [here](https://medium.com/nmc-techblog/functional-error-handling-in-js-8b7f7e4fa092). + + + +### Validator + +A validator is just a special (but common) case of a transformer where the input is `unknown`: + +```typescript +type Validator = Transformer +``` + ### `FefeError` -`fefe` throws a `FefeError` if a value can't be validated/transformed. A `FefeError` has the following properties: +`fefe` validators return a `FefeError` if a value can't be validated/transformed. Note that `FefeError` is *not* derived from the JavaScript `Error` object but is a simple object. + +If an error occurs it will allow you to pinpoint where exactly the error(s) occured and why. The structure is the following: + +```typescript +type FefeError = LeafError | BranchError +``` + +#### `LeafError` + +A `LeafError` can be seen as the source of an error which can happen deep in a nested object and it carries both the value that failed and a human-readable reason describing why it failed. + +```typescript +interface LeafError { + type: 'leaf' + value: unknown + reason: string +} +``` + +#### `BranchError` + +A `BranchError` is the encapsulation of one or more errors on a higher level. + +```typescript +interface BranchError { + type: 'branch' + value: unknown + childErrors: ChildError[] +} + +interface ChildError { + key: Key + error: FefeError +} +``` + +Imagine an array of values where the values at position 2 and 5 fail. This would result in two `childErrors`: one with `key` equal to 2 and `key` equal to 5. The `error` property is again a `FefeError` so this is a full error tree. + +#### `getErrorString(error: FefeError): string` + +To simplify handling of errors, you can use `getErrorString()` which traverses the tree and returns a human-readable error message for each `LeafError` – along with the paths and reasons. -* `reason`: the reason for the error. -* `value`: the value that was passed. -* `path`: the path in `value` to where the error occured. +Example error message: `user.id: Not a string.` -### `array(elementValidator, options?)` +### `array(elementValidator, options?): Validator` -Returns a function `(value: unknown) => T[]` that checks that the given value is an array and that runs `elementValidator` on all elements. A new array with the results is returned. +Returns a validator that checks that the given value is an array and that runs `elementValidator` on all elements. A new array with the results is returned as `Result`. Options: -* `elementValidator`: validator function `(value: unknown) => T` that is applied to each element. The return values are returned as a new array. -* `options.minLength?`, `options.maxLength?`: restrict length of array +* `elementValidator: Validator`: validator that is applied to each element. The return values are returned as a new array. +* `options.minLength?: number`, `options.maxLength?: number`: restrict length of array +* `options.allErrors?: boolean`: set to `true` to return all errors instead of only the first. -### `boolean()` +### `boolean(): Validator` -Returns a function `(value: unknown) => boolean` that returns `value` if it is a boolean and throws otherwise. +Returns a validator that returns `value` if it is a boolean and returns an error otherwise. -### `date(options?)` +### `date(options?): Validator` -Returns a function `(value: unknown) => Date` that returns `value` if it is a Date and throws otherwise. +Returns a validator that returns `value` if it is a Date and returns an error otherwise. Options: -* `options.min?`, `options.max?`: restrict date +* `options.min?: Date`, `options.max?: Date`: restrict date -### `enumerate(value1, value2, ...)` +### `enumerate(value1, value2, ...): Validator` -Returns a function `(value: unknown) => value1 | value2 | ...` that returns `value` if if equals one of the strings `value1`, `value2`, .... and throws otherwise. +Returns a validator that returns `value` if if equals one of the strings `value1`, `value2`, .... and returns an error otherwise. -### `number(options?)` +### `number(options?): Validator` -Returns a function `(value: unknown) => number` that returns `value` if it is a number and throws otherwise. +Returns a validator that returns `value` if it is a number and returns an error otherwise. Options: -* `options.min?`, `options.max?`: restrict number -* `options.integer?`: require number to be an integer (default: `false`) -* `options.allowNaN?`, `options.allowInfinity?`: allow `NaN` or `infinity` (default: `false`) +* `options.min?: number`, `options.max?: number`: restrict number +* `options.integer?: boolean`: require number to be an integer (default: `false`) +* `options.allowNaN?: boolean`, `options.allowInfinity?: boolean`: allow `NaN` or `infinity` (default: `false`) -### `object(definition, options?)` +### `object(definition, options?): Validator>` -Returns a function `(value: unknown) => {...}` that returns `value` if it is an object and all values pass the validation as specified in `definition`, otherwise it throws. A new object is returned that has the results of the validator functions as values. +Returns a validator that returns `value` if it is an object and all values pass the validation as specified in `definition`, otherwise it returns an error. A new object is returned that has the results of the validator functions as values. Options: -* `definition`: an object where each value is either: - * a validator functions `(value: unknown) => T` or - * an object with the following properties: - * `validator`: validator function `(value: unknown) => T` - * `optional?`: allow undefined values (default: `false`) - * `default?`: default value of type `T` or function `() => T` that returns a default value -* `allowExcessProperties?`: allow excess properties in `value` (default: `false`). Excess properties are not copied to the returned object. +* `definition: ObjectDefinition`: an object where each value is a `Validator`. +* `allowExcessProperties?: boolean`: allow excess properties in `value` (default: `false`). Excess properties are not copied to the returned object. +* `allErrors?: boolean`: set to `true` to return all errors instead of only the first (default: `false`). You can use the following helpers: -* `optional(validator)`: generates an optional key validator with the given `validator`. -* `defaultTo(validator, default)`: generates a key validator that defaults to `default` (also see `default` option above). +* `optional(validator: Validator)`: generates an optional key validator with the given `validator`. +* `defaultTo(validator: Validator, default: D | () => D`: generates a validator that defaults to `default()` if it is a function and `default` otherwise. -### `string(options?)` +### `string(options?): Validator` -Returns a function `(value: unknown) => string` that returns `value` if it is a string and throws otherwise. +Returns a validator that returns `value` if it is a string and returns an error otherwise. Options: -* `options.minLength?`, `options.maxLength?`: restrict length of string -* `options.regex?`: require string to match regex +* `options.minLength?: number`, `options.maxLength?: number`: restrict length of string +* `options.regex?: RegExp`: require string to match regex -### `union(validator1, validator2, ...)` +### `union(validator1, validator2, ...): Validator` -Returns a function `(value: unknown) => return1 | return2 | ...` that returns the return value of the first validator called with `value` that does not throw. The function throws if all validators throw. +Returns a validator that returns the return value of the first validator called with `value` that does not return an error. The function returns an error if all validators return an error. All arguments are validators (e.g., `validator1: Validator, validator2: Validator, ...`) -### `parseBoolean()` +### `parseBoolean(): Transformer` -Returns a function `(value: string) => boolean` that parses a string as a boolean. +Returns a transformer that parses a string as a boolean. -### `parseDate(options?)` +### `parseDate(options?): Transformer` -Returns a function `(value: string) => Date` that parses a string as a date. +Returns a transformer that parses a string as a date. Options: -* `options.iso?`: require value to be an ISO 8601 string. +* `options.iso?: boolean`: require value to be an ISO 8601 string. -### `parseJson()` +### `parseJson(): Transformer` -Returns a function `(value: string) => any` that parses a JSON string. +Returns a transformer that parses a JSON string. Since parsed JSON can in turn be almost anything, it is usually combined with another validator like `object({ ... })`. -### `parseNumber()` +### `parseNumber(): Transformer` -Returns a function `(value: string) => number` that parses a number string. +Returns a transformer that parses a number string. diff --git a/package-lock.json b/package-lock.json index 497bd7c..de07651 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,9 @@ "": { "version": "2.1.1", "license": "MIT", + "dependencies": { + "fp-ts": "^2.9.5" + }, "devDependencies": { "@types/chai": "^4.2.13", "@types/mocha": "^8.0.3", @@ -22,7 +25,6 @@ "mocha": "^8.1.3", "nyc": "^15.1.0", "prettier": "^2.2.1", - "ramda": "^0.27.1", "ts-node": "^9.0.0", "typescript": "^4.0.3" } @@ -1825,6 +1827,11 @@ "node": ">=8.0.0" } }, + "node_modules/fp-ts": { + "version": "2.9.5", + "resolved": "https://registry.npmjs.org/fp-ts/-/fp-ts-2.9.5.tgz", + "integrity": "sha512-MiHrA5teO6t8zKArE3DdMPT/Db6v2GUt5yfWnhBTrrsVfeCJUUnV6sgFvjGNBKDmEMqVwRFkEePL7wPwqrLKKA==" + }, "node_modules/fromentries": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/fromentries/-/fromentries-1.3.2.tgz", @@ -3446,12 +3453,6 @@ } ] }, - "node_modules/ramda": { - "version": "0.27.1", - "resolved": "https://registry.npmjs.org/ramda/-/ramda-0.27.1.tgz", - "integrity": "sha512-PgIdVpn5y5Yns8vqb8FzBUEYn98V3xcPgawAkkgj0YJ0qDsnHCiNmZYfOGMgOvoB0eWFLpYbhxUR3mxfDIMvpw==", - "dev": true - }, "node_modules/randombytes": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", @@ -5712,6 +5713,11 @@ "signal-exit": "^3.0.2" } }, + "fp-ts": { + "version": "2.9.5", + "resolved": "https://registry.npmjs.org/fp-ts/-/fp-ts-2.9.5.tgz", + "integrity": "sha512-MiHrA5teO6t8zKArE3DdMPT/Db6v2GUt5yfWnhBTrrsVfeCJUUnV6sgFvjGNBKDmEMqVwRFkEePL7wPwqrLKKA==" + }, "fromentries": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/fromentries/-/fromentries-1.3.2.tgz", @@ -6895,12 +6901,6 @@ "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", "dev": true }, - "ramda": { - "version": "0.27.1", - "resolved": "https://registry.npmjs.org/ramda/-/ramda-0.27.1.tgz", - "integrity": "sha512-PgIdVpn5y5Yns8vqb8FzBUEYn98V3xcPgawAkkgj0YJ0qDsnHCiNmZYfOGMgOvoB0eWFLpYbhxUR3mxfDIMvpw==", - "dev": true - }, "randombytes": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", diff --git a/package.json b/package.json index d7f3742..9cc4c8b 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,6 @@ "mocha": "^8.1.3", "nyc": "^15.1.0", "prettier": "^2.2.1", - "ramda": "^0.27.1", "ts-node": "^9.0.0", "typescript": "^4.0.3" }, @@ -68,5 +67,8 @@ "*.ts": [ "eslint --fix" ] + }, + "dependencies": { + "fp-ts": "^2.9.5" } } diff --git a/src/array.test.ts b/src/array.test.ts index adfc1dc..a2b78cd 100644 --- a/src/array.test.ts +++ b/src/array.test.ts @@ -1,36 +1,70 @@ -import { expect } from 'chai' +import { assert } from 'chai' +import { chain } from 'fp-ts/lib/Either' +import { flow } from 'fp-ts/lib/function' -import { FefeError } from './errors' +import { branchError, leafError } from './errors' import { array } from './array' -import { string } from './string' +import { boolean } from './boolean' +import { failure, success } from './result' describe('array()', () => { - it('should throw if not a array', () => { - const validate = array(string()) - expect(() => validate('foo')) - .to.throw(FefeError, 'Not an array.') - .that.deep.include({ value: 'foo', path: [], child: undefined }) + it('should return error if not a array', () => { + assert.deepStrictEqual( + array(boolean())('foo'), + failure(leafError('foo', 'Not an array.')) + ) }) - it('should throw if nested validation fails', () => { - const validate = array(string()) - const value = ['foo', 1] - expect(() => validate(value)) - .to.throw(FefeError, 'Not a string.') - .that.deep.include({ value, path: [1] }) + it('should return error if nested validation fails', () => { + assert.deepStrictEqual( + array(boolean())([true, 42]), + failure( + branchError( + [true, 42], + [{ key: 1, error: leafError(42, 'Not a boolean.') }] + ) + ) + ) + }) + + it('should return all errors if nested validation fails', () => { + assert.deepStrictEqual( + array(boolean(), { allErrors: true })([true, 42, 1337]), + failure( + branchError( + [true, 42, 1337], + [ + { key: 1, error: leafError(42, 'Not a boolean.') }, + { key: 2, error: leafError(1337, 'Not a boolean.') }, + ] + ) + ) + ) }) it('should return a valid array', () => { - const validate = array(string()) - const value = ['foo', 'bar'] - expect(validate(value)).to.eql(value) + const value = [true, false] + assert.deepStrictEqual(array(boolean())(value), success(value)) + }) + + it('should return a valid array with allErrors', () => { + const value = [true, false] + assert.deepStrictEqual( + array(boolean(), { allErrors: true })(value), + success(value) + ) }) it('should return a valid array with transformed values', () => { - const validate = array((value) => `transformed: ${string()(value)}`) - expect(validate(['foo', 'bar'])).to.eql([ - 'transformed: foo', - 'transformed: bar', - ]) + const transform = array( + flow( + boolean(), + chain((v: boolean) => success(`transformed: ${v}`)) + ) + ) + assert.deepStrictEqual( + transform([false, true]), + success(['transformed: false', 'transformed: true']) + ) }) }) diff --git a/src/array.ts b/src/array.ts index 7d93f58..b0cc9bb 100644 --- a/src/array.ts +++ b/src/array.ts @@ -1,31 +1,42 @@ -import { FefeError } from './errors' +import { partitionMapWithIndex, traverseWithIndex } from 'fp-ts/lib/Array' +import { either, isLeft, left } from 'fp-ts/Either' + +import { branchError, leafError } from './errors' +import { failure, isFailure, success } from './result' import { Validator } from './validate' export interface ArrayOptions { minLength?: number maxLength?: number + allErrors?: boolean } export function array( elementValidator: Validator, - { minLength, maxLength }: ArrayOptions = {} -): (value: unknown) => R[] { + { minLength, maxLength, allErrors }: ArrayOptions = {} +): Validator { + const validate = (index: number, element: unknown) => { + const result = elementValidator(element) + if (isFailure(result)) return left({ key: index, error: result.left }) + return result + } return (value: unknown) => { - if (!Array.isArray(value)) throw new FefeError(value, 'Not an array.') + if (!Array.isArray(value)) return failure(leafError(value, 'Not an array.')) if (minLength !== undefined && value.length < minLength) - throw new FefeError(value, `Has less than ${minLength} elements.`) + return failure(leafError(value, `Has less than ${minLength} elements.`)) if (maxLength !== undefined && value.length > maxLength) - throw new FefeError(value, `Has more than ${maxLength} elements.`) + return failure(leafError(value, `Has more than ${maxLength} elements.`)) + + if (allErrors) { + const results = partitionMapWithIndex(validate)(value) + + if (results.left.length > 0) + return failure(branchError(value, results.left)) + return success(results.right) + } - return value.map((element, index) => { - try { - return elementValidator(element) - } catch (error) { - if (error instanceof FefeError) { - throw error.createParentError(value, index) - } - throw error - } - }) + const result = traverseWithIndex(either)(validate)(value) + if (isLeft(result)) return failure(branchError(value, [result.left])) + return success(result.right) } } diff --git a/src/boolean.test.ts b/src/boolean.test.ts index 7c3b880..a620665 100644 --- a/src/boolean.test.ts +++ b/src/boolean.test.ts @@ -1,12 +1,17 @@ -import { expect } from 'chai' +import { assert } from 'chai' -import { FefeError } from './errors' import { boolean } from './boolean' +import { leafError } from './errors' +import { failure, success } from './result' describe('boolean()', () => { - it('should throw if not a boolean', () => { - expect(() => boolean()('foo')).to.throw(FefeError, 'Not a boolean.') + it('should return an error if not a boolean', () => { + assert.deepStrictEqual( + boolean()('foo'), + failure(leafError('foo', 'Not a boolean.')) + ) }) - it('return a valid boolean', () => expect(boolean()(true)).to.equal(true)) + it('return a valid boolean', () => + assert.deepStrictEqual(boolean()(true), success(true))) }) diff --git a/src/boolean.ts b/src/boolean.ts index 7924e9c..185b96e 100644 --- a/src/boolean.ts +++ b/src/boolean.ts @@ -1,9 +1,11 @@ -import { FefeError } from './errors' +import { leafError } from './errors' +import { failure, success } from './result' +import { Validator } from './validate' -export function boolean() { - return (value: unknown): boolean => { - // tslint:disable-next-line:strict-type-predicates - if (typeof value !== 'boolean') throw new FefeError(value, 'Not a boolean.') - return value +export function boolean(): Validator { + return (value: unknown) => { + if (typeof value !== 'boolean') + return failure(leafError(value, 'Not a boolean.')) + return success(value) } } diff --git a/src/date.test.ts b/src/date.test.ts index bea3c52..c29a162 100644 --- a/src/date.test.ts +++ b/src/date.test.ts @@ -1,33 +1,40 @@ -import { expect } from 'chai' +import { assert } from 'chai' -import { FefeError } from './errors' +import { leafError } from './errors' import { date } from './date' +import { failure, success } from './result' describe('date()', () => { - it('should throw if not a date', () => { - expect(() => date()('foo')).to.throw(FefeError, 'Not a date.') + it('should return an error if not a date', () => { + assert.deepStrictEqual( + date()('foo'), + failure(leafError('foo', 'Not a date.')) + ) }) - it('should throw if not a valid date', () => { - expect(() => date()(new Date('foo'))).to.throw( - FefeError, - 'Not a valid date.' + it('should return an error if not a valid date', () => { + const value = new Date('foo') + assert.deepStrictEqual( + date()(value), + failure(leafError(value, 'Not a valid date.')) ) }) - it('should throw if before min', () => { + it('should return an error if before min', () => { const validate = date({ min: new Date('2018-10-22T00:00:00.000Z') }) - expect(() => validate(new Date('2018-10-21T00:00:00.000Z'))).to.throw( - FefeError, - 'Before 2018-10-22T00:00:00.000Z.' + const value = new Date('2018-10-21T00:00:00.000Z') + assert.deepStrictEqual( + validate(value), + failure(leafError(value, 'Before 2018-10-22T00:00:00.000Z.')) ) }) - it('should throw if after max', () => { + it('should return an error if after max', () => { const validate = date({ max: new Date('2018-10-22T00:00:00.000Z') }) - expect(() => validate(new Date('2018-10-23T00:00:00.000Z'))).to.throw( - FefeError, - 'After 2018-10-22T00:00:00.000Z.' + const value = new Date('2018-10-23T00:00:00.000Z') + assert.deepStrictEqual( + validate(value), + failure(leafError(value, 'After 2018-10-22T00:00:00.000Z.')) ) }) @@ -36,8 +43,7 @@ describe('date()', () => { min: new Date('2018-10-20T00:00:00.000Z'), max: new Date('2018-10-22T00:00:00.000Z'), }) - const unsafeDate = new Date('2018-10-21T00:00:00.000Z') - const validatedDate: Date = validate(unsafeDate) - expect(validate(validatedDate)).to.equal(unsafeDate) + const value = new Date('2018-10-21T00:00:00.000Z') + assert.deepStrictEqual(validate(value), success(value)) }) }) diff --git a/src/date.ts b/src/date.ts index 02cf768..9999dbc 100644 --- a/src/date.ts +++ b/src/date.ts @@ -1,18 +1,22 @@ -import { FefeError } from './errors' +import { leafError } from './errors' +import { failure, success } from './result' +import { Validator } from './validate' export interface DateOptions { min?: Date max?: Date } -export function date({ min, max }: DateOptions = {}) { - return (value: unknown): Date => { - if (!(value instanceof Date)) throw new FefeError(value, 'Not a date.') - if (isNaN(value.getTime())) throw new FefeError(value, 'Not a valid date.') +export function date({ min, max }: DateOptions = {}): Validator { + return (value: unknown) => { + if (!(value instanceof Date)) + return failure(leafError(value, 'Not a date.')) + if (isNaN(value.getTime())) + return failure(leafError(value, 'Not a valid date.')) if (min !== undefined && value.getTime() < min.getTime()) - throw new FefeError(value, `Before ${min.toISOString()}.`) + return failure(leafError(value, `Before ${min.toISOString()}.`)) if (max !== undefined && value.getTime() > max.getTime()) - throw new FefeError(value, `After ${max.toISOString()}.`) - return value + return failure(leafError(value, `After ${max.toISOString()}.`)) + return success(value) } } diff --git a/src/enumerate.test.ts b/src/enumerate.test.ts index 1ba4665..27e108f 100644 --- a/src/enumerate.test.ts +++ b/src/enumerate.test.ts @@ -1,17 +1,25 @@ -import { expect } from 'chai' +import { assert } from 'chai' -import { FefeError } from './errors' +import { leafError } from './errors' import { enumerate } from './enumerate' +import { failure, Result, success } from './result' describe('enumerate()', () => { const validate = enumerate('foo', 'bar') - it('should throw if value is not in the list', () => { - expect(() => validate('baz')).to.throw(FefeError, 'Not one of foo, bar.') - expect(() => validate(true)).to.throw(FefeError, 'Not one of foo, bar.') + + it('should return an error if value is not in the list', () => { + assert.deepStrictEqual( + validate('baz'), + failure(leafError('baz', 'Not one of foo, bar.')) + ) + assert.deepStrictEqual( + validate(true), + failure(leafError(true, 'Not one of foo, bar.')) + ) }) it('return a valid value', () => { - const validatedValue: 'foo' | 'bar' = validate('bar') - expect(validatedValue).to.equal('bar') + const result: Result<'foo' | 'bar'> = validate('bar') + assert.deepStrictEqual(result, success('bar')) }) }) diff --git a/src/enumerate.ts b/src/enumerate.ts index 878ee26..22be38c 100644 --- a/src/enumerate.ts +++ b/src/enumerate.ts @@ -1,10 +1,13 @@ -import { FefeError } from './errors' +import { leafError } from './errors' +import { failure, success } from './result' +import { Validator } from './validate' -export function enumerate(...args: T) { - return (value: unknown): T[number] => { - if (args.indexOf(value as string) === -1) { - throw new FefeError(value, `Not one of ${args.join(', ')}.`) - } - return value as T[number] +export function enumerate( + ...args: T +): Validator { + return (value: unknown) => { + if (args.indexOf(value as T[number]) === -1) + return failure(leafError(value, `Not one of ${args.join(', ')}.`)) + return success(value as T[number]) } } diff --git a/src/errors.test.ts b/src/errors.test.ts new file mode 100644 index 0000000..59b549e --- /dev/null +++ b/src/errors.test.ts @@ -0,0 +1,36 @@ +import { assert } from 'chai' + +import { + branchError, + leafError, + getLeafErrorReasons, + getErrorString, + FefeError, +} from './errors' + +const error: FefeError = branchError({ id: 'c0ff33', emails: ['hurz'] }, [ + { key: 'id', error: leafError('c0ff33', 'Not a number.') }, + { + key: 'emails', + error: branchError( + ['hurz'], + [{ key: 0, error: leafError('hurz', 'Not an email address.') }] + ), + }, +]) + +describe('getLeafErrorReasons()', () => { + it('should return leaf error reasons', () => + assert.deepStrictEqual(getLeafErrorReasons(error), [ + { path: ['id'], reason: 'Not a number.' }, + { path: ['emails', 0], reason: 'Not an email address.' }, + ])) +}) + +describe('getErrorString()', () => { + it('should return an error string', () => + assert.equal( + getErrorString(error), + 'id: Not a number. emails.0: Not an email address.' + )) +}) diff --git a/src/errors.ts b/src/errors.ts index 7182846..879c100 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -1,39 +1,53 @@ -export class ExtendableError extends Error { - constructor(message: string) { - super(message) - Object.setPrototypeOf(this, new.target.prototype) - } -} +export type Key = string | number | symbol -export interface FefeChildError { - key: string | number | symbol +export interface ChildError { + key: Key error: FefeError } -export class FefeError extends ExtendableError { - public readonly value: unknown - public readonly reason: string - public readonly child?: FefeChildError - - // derived properties - public readonly path: (string | number | symbol)[] - public readonly originalError: FefeError - - constructor(value: unknown, reason: string, child?: FefeChildError) { - const path = child ? [child.key, ...child.error.path] : [] - super(child ? `${path.join('.')}: ${reason}` : reason) - this.value = value - this.reason = reason - this.child = child - this.path = path - this.originalError = child ? child.error.originalError : this - } - - createParentError( - parentValue: unknown, - key: string | number | symbol - ): FefeError { - const child: FefeChildError = { key, error: this } - return new FefeError(parentValue, this.reason, child) - } +export interface LeafError { + type: 'leaf' + value: unknown + reason: string +} + +export interface BranchError { + type: 'branch' + value: unknown + childErrors: ChildError[] +} + +export type FefeError = LeafError | BranchError + +export function leafError(value: unknown, reason: string): LeafError { + return { type: 'leaf', value, reason } +} + +export function branchError( + value: unknown, + children: ChildError[] +): BranchError { + return { type: 'branch', value, childErrors: children } +} + +export type LeafErrorReason = { path: Key[]; reason: string } + +export function getLeafErrorReasons(error: FefeError): LeafErrorReason[] { + if (error.type === 'leaf') return [{ path: [], reason: error.reason }] + + return error.childErrors.flatMap((child) => { + return getLeafErrorReasons(child.error).map((leafErrorReason) => ({ + path: [child.key, ...leafErrorReason.path], + reason: leafErrorReason.reason, + })) + }) +} + +export function getErrorString(error: FefeError): string { + return getLeafErrorReasons(error) + .map(({ path, reason }) => { + if (path.length === 0) return reason + return `${path.join('.')}: ${reason}` + }) + .join(' ') } diff --git a/src/index.test.ts b/src/index.test.ts index 0e4723f..656b559 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -1,5 +1,6 @@ -import { expect } from 'chai' -import { pipe } from 'ramda' +import { assert } from 'chai' +import { chain } from 'fp-ts/lib/Either' +import { flow } from 'fp-ts/lib/function' import * as fefe from '.' @@ -12,48 +13,57 @@ describe('Integration tests', () => { street: fefe.string(), zip: fefe.number(), }), - isVerified: fefe.boolean(), + // isVerified: fefe.boolean(), verifiedAt: fefe.union(fefe.date(), fefe.enumerate('never')), joinedAt: fefe.date(), favoriteDishes: fefe.array(fefe.string()), notifications: fefe.enumerate('immediately', 'daily', 'never'), }) - type Person = ReturnType + type Person = fefe.ValidatorReturnType const validPerson: Person = { name: 'AndrΓ©', age: 35, address: { street: 'Kreuzbergstr', zip: 10965 }, - isVerified: true, + // isVerified: true, verifiedAt: 'never', joinedAt: new Date(), favoriteDishes: ['Pho Bo', 'Sushi'], notifications: 'daily', } - it('validates a person', () => { - const person = validatePerson(validPerson) - expect(person).to.eql(validPerson) - }) + it('validates a person', () => + assert.deepStrictEqual( + validatePerson(validPerson), + fefe.success(validPerson) + )) - it('throws with an invalid person', () => { + it('returns an error if person is invalid', () => { const invalidPerson = { ...validPerson, address: { street: 'Ackerstr', zip: 'foo' }, } - expect(() => validatePerson(invalidPerson)) - .to.throw(fefe.FefeError, 'address.zip: Not a number.') - .that.deep.include({ value: invalidPerson, path: ['address', 'zip'] }) - .and.has.property('originalError') - .that.include({ value: 'foo' }) + assert.deepStrictEqual( + validatePerson(invalidPerson), + fefe.failure( + fefe.branchError(invalidPerson, [ + { + key: 'address', + error: fefe.branchError(invalidPerson.address, [ + { key: 'zip', error: fefe.leafError('foo', 'Not a number.') }, + ]), + }, + ]) + ) + ) }) }) describe('Basic transformation (sanitization)', () => { const sanitizeMovie = fefe.object({ title: fefe.string(), - releasedAt: fefe.parseDate(), + releasedAt: flow(fefe.string(), chain(fefe.parseDate())), }) it('validates a movie and parses the date string', () => { @@ -61,54 +71,73 @@ describe('Integration tests', () => { title: 'Star Wars', releasedAt: '1977-05-25T12:00:00.000Z', }) - expect(movie).to.eql({ - title: 'Star Wars', - releasedAt: new Date('1977-05-25T12:00:00.000Z'), - }) + assert.deepStrictEqual( + movie, + fefe.success({ + title: 'Star Wars', + releasedAt: new Date('1977-05-25T12:00:00.000Z'), + }) + ) }) - it('throws with an invalid date', () => { + it('returns error if date is invalid', () => { const invalidMovie = { title: 'Star Wars', releasedAt: 'foo' } - expect(() => sanitizeMovie(invalidMovie)) - .to.throw(fefe.FefeError, 'releasedAt: Not a date.') - .that.deep.include({ value: invalidMovie, path: ['releasedAt'] }) - .and.has.property('originalError') - .that.include({ value: 'foo' }) + assert.deepStrictEqual( + sanitizeMovie(invalidMovie), + fefe.failure( + fefe.branchError(invalidMovie, [ + { + key: 'releasedAt', + error: fefe.leafError('foo', 'Not a date.'), + }, + ]) + ) + ) }) }) describe('Basic transformation (on-demand sanitization)', () => { - const sanitizeDate = fefe.union(fefe.date(), fefe.parseDate()) + const sanitizeDate = fefe.union( + fefe.date(), + flow(fefe.string(), chain(fefe.parseDate())) + ) const date = new Date() - it('returns a date', () => { - const sanitizedDate: Date = sanitizeDate(date) - expect(sanitizedDate).to.equal(date) - }) - - it('returns a parsed date', () => { - const sanitizedDate: Date = sanitizeDate(date.toISOString()) - expect(sanitizedDate).to.eql(date) - }) - - it('throws with an invalid date', () => { - expect(() => sanitizeDate('foo')).to.throw( - fefe.FefeError, - 'Not of any expected type.' - ) - }) + it('returns a date', () => + assert.deepStrictEqual(sanitizeDate(date), fefe.success(date))) + + it('returns a parsed date', () => + assert.deepStrictEqual( + sanitizeDate(date.toISOString()), + fefe.success(date) + )) + + it('throws with an invalid date', () => + assert.deepStrictEqual( + sanitizeDate('foo'), + fefe.failure( + fefe.leafError( + 'foo', + 'Not of any expected type (Not a date. Not a date.).' + ) + ) + )) }) describe('Complex transformation and validation', () => { const parseConfig = fefe.object({ - gcloudCredentials: pipe( - fefe.parseJson(), - fefe.object({ key: fefe.string() }) + gcloudCredentials: flow( + fefe.string(), + chain(fefe.parseJson()), + chain(fefe.object({ key: fefe.string() })) + ), + whitelist: flow( + fefe.string(), + chain((value) => fefe.success(value.split(','))) ), - whitelist: pipe(fefe.string(), (value) => value.split(',')), }) - type Config = ReturnType + type Config = fefe.ValidatorReturnType const validConfig: Config = { gcloudCredentials: { key: 'secret' }, @@ -120,27 +149,42 @@ describe('Integration tests', () => { whitelist: 'alice,bob', } - it('parses a config', () => { - const config = parseConfig(validConfigInput) - expect(config).to.eql(validConfig) - }) + it('parses a config', () => + assert.deepStrictEqual( + parseConfig(validConfigInput), + fefe.success(validConfig) + )) it('throws with an invalid config', () => { const invalidConfigInput = { ...validConfigInput, gcloudCredentials: '{ "key": "secret", "foo": "bar" }', } - expect(() => parseConfig(invalidConfigInput)) - .to.throw( - fefe.FefeError, - 'gcloudCredentials: Properties not allowed: foo' + assert.deepStrictEqual( + parseConfig(invalidConfigInput), + fefe.failure( + fefe.branchError(invalidConfigInput, [ + { + key: 'gcloudCredentials', + error: fefe.leafError( + { key: 'secret', foo: 'bar' }, + 'Properties not allowed: foo.' + ), + }, + ]) ) - .that.deep.include({ - value: invalidConfigInput, - path: ['gcloudCredentials'], - }) - .and.has.property('originalError') - .that.include({ value: { key: 'secret', foo: 'bar' } }) + ) + // expect(() => ) + // .to.throw( + // fefe.FefeError, + // 'gcloudCredentials: Properties not allowed: foo' + // ) + // .that.deep.include({ + // value: invalidConfigInput, + // path: ['gcloudCredentials'], + // }) + // .and.has.property('originalError') + // .that.include({ value: { key: 'secret', foo: 'bar' } }) }) }) }) diff --git a/src/index.ts b/src/index.ts index 9a54b29..8b8b431 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,15 +1,16 @@ -export { FefeError } from './errors' +export * from './errors' +export * from './result' +export * from './validate' -export { Validator } from './validate' -export { array } from './array' -export { boolean } from './boolean' -export { date } from './date' -export { enumerate } from './enumerate' -export { number } from './number' -export { object, optional, defaultTo } from './object' -export { parseBoolean } from './parse-boolean' -export { parseDate } from './parse-date' -export { parseJson } from './parse-json' -export { parseNumber } from './parse-number' -export { string } from './string' -export { union } from './union' +export * from './array' +export * from './boolean' +export * from './date' +export * from './enumerate' +export * from './number' +export * from './object' +export * from './parse-boolean' +export * from './parse-date' +export * from './parse-json' +export * from './parse-number' +export * from './string' +export * from './union' diff --git a/src/number.test.ts b/src/number.test.ts index bc06ec4..636e140 100644 --- a/src/number.test.ts +++ b/src/number.test.ts @@ -1,43 +1,59 @@ -import { expect } from 'chai' +import { assert } from 'chai' -import { FefeError } from './errors' import { number } from './number' +import { leafError } from './errors' +import { failure, success } from './result' describe('number()', () => { - it('should throw if not a number', () => { - expect(() => number()('foo')).to.throw(FefeError, 'Not a number.') + it('should return an error if not a number', () => { + assert.deepStrictEqual( + number()('foo'), + failure(leafError('foo', 'Not a number.')) + ) }) - it('should throw if NaN', () => { - expect(() => number()(1 / 0 - 1 / 0)).to.throw( - FefeError, - 'NaN is not allowed.' + it('should return an error if NaN', () => { + const value = 1 / 0 - 1 / 0 + assert.deepStrictEqual( + number()(value), + failure(leafError(value, 'NaN is not allowed.')) ) }) - it('should throw if infinite', () => { - expect(() => number()(1 / 0)).to.throw( - FefeError, - 'Infinity is not allowed.' + it('should return an error if infinite', () => { + const value = 1 / 0 + assert.deepStrictEqual( + number()(value), + failure(leafError(value, 'Infinity is not allowed.')) ) }) - it('should throw if not integer', () => { - expect(() => number({ integer: true })(1.5)).to.throw( - FefeError, - 'Not an integer.' + it('should return an error if not integer', () => { + const value = 1.5 + assert.deepStrictEqual( + number({ integer: true })(value), + failure(leafError(value, 'Not an integer.')) ) }) - it('should throw if less than min', () => { - expect(() => number({ min: 0 })(-1)).to.throw(FefeError, 'Less than 0.') + it('should return an error if less than min', () => { + const value = -1 + assert.deepStrictEqual( + number({ min: 1 })(value), + failure(leafError(value, 'Less than 1.')) + ) }) - it('should throw if less than max', () => { - expect(() => number({ max: 0 })(11)).to.throw(FefeError, 'Greater than 0.') + it('should return an error if less than max', () => { + const value = 11 + assert.deepStrictEqual( + number({ max: 3 })(value), + failure(leafError(value, 'Greater than 3.')) + ) }) it('return a valid number', () => { - expect(number({ min: 0, max: 2, integer: true })(1)).to.equal(1) + const value = 2 + assert.deepStrictEqual(number({ min: 1, max: 3 })(value), success(value)) }) }) diff --git a/src/number.ts b/src/number.ts index 2905b16..0e3efe9 100644 --- a/src/number.ts +++ b/src/number.ts @@ -1,4 +1,6 @@ -import { FefeError } from './errors' +import { leafError } from './errors' +import { failure, success } from './result' +import { Validator } from './validate' export interface NumberOptions { min?: number @@ -14,20 +16,20 @@ export function number({ integer, allowNaN = false, allowInfinity = false, -}: NumberOptions = {}) { - return (value: unknown): number => { - // tslint:disable-next-line:strict-type-predicates - if (typeof value !== 'number') throw new FefeError(value, 'Not a number.') +}: NumberOptions = {}): Validator { + return (value: unknown) => { + if (typeof value !== 'number') + return failure(leafError(value, 'Not a number.')) if (!allowNaN && Number.isNaN(value)) - throw new FefeError(value, 'NaN is not allowed.') + return failure(leafError(value, 'NaN is not allowed.')) if (!allowInfinity && !Number.isFinite(value)) - throw new FefeError(value, 'Infinity is not allowed.') + return failure(leafError(value, 'Infinity is not allowed.')) if (integer && !Number.isInteger(value)) - throw new FefeError(value, 'Not an integer.') + return failure(leafError(value, 'Not an integer.')) if (min !== undefined && value < min) - throw new FefeError(value, `Less than ${min}.`) + return failure(leafError(value, `Less than ${min}.`)) if (max !== undefined && value > max) - throw new FefeError(value, `Greater than ${max}.`) - return value + return failure(leafError(value, `Greater than ${max}.`)) + return success(value) } } diff --git a/src/object.test.ts b/src/object.test.ts index 7008333..6ca6d0d 100644 --- a/src/object.test.ts +++ b/src/object.test.ts @@ -1,85 +1,124 @@ -import { expect } from 'chai' +import { assert } from 'chai' -import { FefeError } from './errors' -import { object, defaultTo, optional, ObjectOptions } from './object' +import { object, defaultTo, optional } from './object' import { string } from './string' +import { branchError, leafError } from './errors' +import { failure, success } from './result' +import { Validator } from './validate' +import { number } from './number' describe('object()', () => { - it('should throw if value is not an object', () => { - const validate = object({}) - expect(() => validate(null)) - .to.throw(FefeError, 'Not an object.') - .that.deep.include({ value: null, path: [], child: undefined }) + it('should return an error if value is not an object', () => + assert.deepStrictEqual( + object({})(null), + failure(leafError(null, 'Not an object.')) + )) + + it('should return an error if object has non-allowed key', () => { + const value = { foo: 'test', bar: true } + assert.deepStrictEqual( + object({ foo: string() })(value), + failure(leafError(value, 'Properties not allowed: bar.')) + ) }) - it('should throw if object has a missing key', () => { - const validate = object({ foo: string() }) + it('should return an error if object has a missing key', () => { const value = {} - expect(() => validate(value)) - .to.throw(FefeError, 'foo: Not a string.') - .that.deep.include({ value, path: ['foo'] }) + assert.deepStrictEqual( + object({ foo: string() })(value), + failure( + branchError(value, [ + { key: 'foo', error: leafError(undefined, 'Not a string.') }, + ]) + ) + ) }) - it('should throw if object has a value does not validate', () => { - const validate = object({ foo: string() }) + it('should return an error if object has a value that does not validate', () => { const value = { foo: 1337 } - expect(() => validate(value)) - .to.throw(FefeError, 'foo: Not a string.') - .that.deep.include({ value, path: ['foo'] }) + assert.deepStrictEqual( + object({ foo: string() })(value), + failure( + branchError(value, [ + { key: 'foo', error: leafError(1337, 'Not a string.') }, + ]) + ) + ) }) - it('should validate an object with shorthand notation', () => { - const validate = object({ foo: string() }) - const result: { foo: string } = validate({ foo: 'bar' }) - expect(result).to.eql({ foo: 'bar' }) + it('should return all errors if requested and object has two value that do not validate', () => { + const value = { foo: 1337, bar: 'test' } + assert.deepStrictEqual( + object({ foo: string(), bar: number() }, { allErrors: true })(value), + failure( + branchError(value, [ + { key: 'foo', error: leafError(1337, 'Not a string.') }, + { key: 'bar', error: leafError('test', 'Not a number.') }, + ]) + ) + ) }) - it('should validate an object with explicit notation', () => { - const validate = object({ foo: { validator: string() } }) - const result: { foo: string } = validate({ foo: 'bar' }) - expect(result).to.eql({ foo: 'bar' }) + it('should validate an object', () => { + const value = { foo: 'bar' } + assert.deepStrictEqual(object({ foo: string() })(value), success(value)) }) - it('should validate an object with optional key', () => { - const validate = object({ foo: { validator: string(), optional: true } }) - const result: { foo?: string } = validate({ foo: 'bar' }) - expect(result).to.eql({ foo: 'bar' }) - const emptyResult: { foo?: string } = validate({}) - expect(emptyResult).to.eql({}) + it('should validate an object with allErrors', () => { + const value = { foo: 'bar' } + assert.deepStrictEqual( + object({ foo: string() }, { allErrors: true })(value), + success(value) + ) }) - it('should validate an object with default value', () => { - const validate = object({ foo: { validator: string(), default: 'bar' } }) - const result: { foo: string } = validate({}) - expect(result).to.eql({ foo: 'bar' }) + it('should validate an object with optional key', () => { + const validate: Validator<{ foo?: string }> = object({ + foo: optional(string()), + }) + assert.deepStrictEqual(validate({ foo: 'bar' }), success({ foo: 'bar' })) + assert.deepStrictEqual(validate({}), success({})) + assert.notProperty(validate({}), 'foo') + assert.deepStrictEqual(validate({ foo: undefined }), success({})) + assert.notProperty(validate({ foo: undefined }), 'foo') }) - it('should validate an object with default value function', () => { - const validate = object({ - foo: { validator: string(), default: () => 'bar' }, - }) - const result: { foo: string } = validate({}) - expect(result).to.eql({ foo: 'bar' }) + it('should validate an object with default value', () => { + const validate = object({ foo: defaultTo(string(), 'bar') }) + assert.deepStrictEqual(validate({ foo: 'baz' }), success({ foo: 'baz' })) + assert.deepStrictEqual(validate({}), success({ foo: 'bar' })) + assert.deepStrictEqual( + validate({ foo: undefined }), + success({ foo: 'bar' }) + ) }) }) describe('defaultTo()', () => { - it('should return an object options object with default value/function', () => { - const validator = string() - const options: ObjectOptions = defaultTo(validator, 'foo') - expect(options).to.eql({ validator, default: 'foo' }) - }) + const validate = defaultTo(string(), 'foo') + + it('should validate if value is provided', () => + assert.deepStrictEqual(validate('bar'), success('bar'))) + + it('should return an error if non-passing value is provided', () => + assert.deepStrictEqual( + validate(42), + failure(leafError(42, 'Not a string.')) + )) + + it('should return default if no value is provided', () => + assert.deepStrictEqual(validate(undefined), success('foo'))) }) describe('optional()', () => { - it('should return an optional object options object', () => { - const validator = string() - const options = optional(validator) - expect(options).to.eql({ validator, optional: true }) - const validate = object({ foo: options }) - const result: { foo?: string } = validate({ foo: 'bar' }) - expect(result).to.eql({ foo: 'bar' }) - const emptyResult: { foo?: string } = validate({}) - expect(emptyResult).to.eql({}) - }) + const validate = optional(string()) + + it('should validate if value is provided', () => + assert.deepStrictEqual(validate('bar'), success('bar'))) + + it('should return an error if non-passing value is provided', () => + assert.deepStrictEqual( + validate(42), + failure(leafError(42, 'Not a string.')) + )) }) diff --git a/src/object.ts b/src/object.ts index 345be82..801e0e3 100644 --- a/src/object.ts +++ b/src/object.ts @@ -1,19 +1,12 @@ -import { FefeError } from './errors' -import { Validator } from './validate' +import { partitionMap, traverse } from 'fp-ts/lib/Array' +import { either, Either, isLeft, left, right } from 'fp-ts/lib/Either' +import { pipe } from 'fp-ts/lib/function' +import { branchError, ChildError, leafError } from './errors' +import { failure, isFailure, success } from './result' +import { Validator, ValidatorReturnType } from './validate' -export interface ObjectOptions { - validator: Validator - optional?: boolean - default?: R | (() => R) -} - -export type ObjectDefinitionValue = Validator | ObjectOptions - -export type ObjectDefinition = Record> - -export type ObjectReturnType = T extends ObjectDefinitionValue - ? U - : never +export type ObjectValueValidator = Validator & { optional?: boolean } +export type ObjectDefinition = Record type FilterObject = { [k in keyof T]: T[k] extends C ? k : never } type MatchingKeys = FilterObject[keyof T] @@ -24,94 +17,109 @@ type MandatoryKeys = NonMatchingKeys type OptionalKeys = MatchingKeys export type ObjectResult = { - [k in MandatoryKeys]: ObjectReturnType + [k in MandatoryKeys]: ValidatorReturnType } & - { [k in OptionalKeys]?: ObjectReturnType } + { [k in OptionalKeys]?: ValidatorReturnType } + +export interface ObjectOptions { + allowExcessProperties?: boolean + allErrors?: boolean +} + +type ValidatedEntry = + | { type: 'mandatory'; key: K; value: T } + | { type: 'optional'; key: K } export function object( definition: D, - { allowExcessProperties = false }: { allowExcessProperties?: boolean } = {} -): (v: unknown) => ObjectResult { - Object.entries(definition).forEach(([, definitionValue]) => { - if (typeof definitionValue !== 'object') return - if (definitionValue.default !== undefined && definitionValue.optional) { - throw new Error('default and optional cannot be used together') + { allowExcessProperties = false, allErrors = false }: ObjectOptions = {} +): Validator> { + function getEntryValidator(value: Record) { + return ([key, validator]: [ + K, + ObjectValueValidator + ]): Either>> => { + if (validator.optional && (!(key in value) || value[key] === undefined)) + return right({ type: 'optional', key }) + const result = validator(value[key]) + if (isFailure(result)) return left({ key, error: result.left }) + return right({ + type: 'mandatory', + key, + value: result.right as ValidatorReturnType, + }) } - }) + } + + function createObjectFromEntries( + entries: ValidatedEntry>[] + ) { + return pipe( + entries, + partitionMap( + (entry: ValidatedEntry>) => + entry.type === 'optional' + ? left(entry.key) + : right([entry.key, entry.value] as [ + keyof D, + ValidatorReturnType + ]) + ), + ({ right }) => Object.fromEntries(right) as ObjectResult + ) + } return (value: unknown) => { - // note: type 'object' includes null - // tslint:disable-next-line:strict-type-predicates if (typeof value !== 'object' || value === null) - throw new FefeError(value, 'Not an object.') + return failure(leafError(value, 'Not an object.')) if (!allowExcessProperties) { const excessProperties = Object.keys(value).filter( (key) => !definition[key] ) if (excessProperties.length > 0) - throw new FefeError( - value, - `Properties not allowed: ${excessProperties.join(', ')}` + return failure( + leafError( + value, + `Properties not allowed: ${excessProperties.join(', ')}.` + ) ) } - const validated = {} as ObjectResult - - Object.entries(definition).forEach( - ([key, definitionValue]: [string, ObjectDefinitionValue]) => { - const options: ObjectOptions = - typeof definitionValue === 'object' - ? definitionValue - : { validator: definitionValue } - - const currentValue: unknown = (value as Record)[key] + const entries = Object.entries(definition) + const validateEntry = getEntryValidator( + value as Record + ) - // tslint:disable-next-line:strict-type-predicates - if (currentValue === undefined) { - if (options.default !== undefined) { - validated[key as keyof typeof validated] = - typeof options.default === 'function' - ? options.default() - : options.default - return - } + if (allErrors) { + const results = partitionMap(validateEntry)(entries) + if (results.left.length > 0) + return failure(branchError(value, results.left)) + return success(createObjectFromEntries(results.right)) + } - if (options.optional) { - return - } - } - try { - validated[key as keyof typeof validated] = options.validator( - currentValue - ) as ObjectResult[keyof ObjectResult] - } catch (error) { - if (error instanceof FefeError) { - throw error.createParentError(value, key) - } - throw error - } - } - ) - return validated + const result = traverse(either)(validateEntry)(entries) + if (isLeft(result)) return failure(branchError(value, [result.left])) + return success(createObjectFromEntries(result.right)) } } -export function defaultTo( - validator: Validator, - _default: R | (() => R) -): ObjectOptions { - return { - validator, - default: _default, +export function defaultTo( + validator: Validator, + _default: D | (() => D) +): Validator { + return (value: unknown) => { + if (value !== undefined) return validator(value) + return success(_default instanceof Function ? _default() : _default) } } -export function optional( - validator: Validator -): { validator: Validator; optional: true } { - return { - validator, - optional: true, +export function optional( + validator: Validator +): Validator & { optional: true } { + const validate = ((v: unknown) => validator(v)) as Validator & { + optional: true } + validate.optional = true + return validate } diff --git a/src/parse-boolean.test.ts b/src/parse-boolean.test.ts index 2f8f816..441b933 100644 --- a/src/parse-boolean.test.ts +++ b/src/parse-boolean.test.ts @@ -1,15 +1,18 @@ -import { expect } from 'chai' +import { assert } from 'chai' -import { FefeError } from './errors' +import { leafError } from './errors' +import { failure, success } from './result' import { parseBoolean } from './parse-boolean' describe('parseBoolean()', () => { - it('should throw if not a boolean', () => { - expect(() => parseBoolean()('foo')).to.throw(FefeError, 'Not a boolean.') - }) + it('should return an error if not a boolean', () => + assert.deepStrictEqual( + parseBoolean()('foo'), + failure(leafError('foo', 'Not a boolean.')) + )) it('return parsed boolean', () => { - expect(parseBoolean()('true')).to.equal(true) - expect(parseBoolean()('false')).to.equal(false) + assert.deepStrictEqual(parseBoolean()('true'), success(true)) + assert.deepStrictEqual(parseBoolean()('false'), success(false)) }) }) diff --git a/src/parse-boolean.ts b/src/parse-boolean.ts index b3ad244..3400d29 100644 --- a/src/parse-boolean.ts +++ b/src/parse-boolean.ts @@ -1,16 +1,16 @@ -import { FefeError } from './errors' +import { leafError } from './errors' +import { failure, success } from './result' +import { Transformer } from './validate' -export function parseBoolean() { - return (value: unknown): boolean => { - // tslint:disable-next-line:strict-type-predicates - if (typeof value !== 'string') throw new FefeError(value, 'Not a string.') +export function parseBoolean(): Transformer { + return (value: string) => { switch (value) { case 'true': - return true + return success(true) case 'false': - return false + return success(false) default: - throw new FefeError(value, 'Not a boolean.') + return failure(leafError(value, 'Not a boolean.')) } } } diff --git a/src/parse-date.test.ts b/src/parse-date.test.ts index 7dd74d4..673b454 100644 --- a/src/parse-date.test.ts +++ b/src/parse-date.test.ts @@ -1,29 +1,33 @@ -import { expect } from 'chai' +import { assert } from 'chai' -import { FefeError } from './errors' +import { leafError } from './errors' +import { failure, success } from './result' import { parseDate } from './parse-date' describe('parseDate()', () => { - it('should throw if not a date', () => { - expect(() => parseDate()('foo')).to.throw(FefeError, 'Not a date.') - }) + it('should return an error if not a date', () => + assert.deepStrictEqual( + parseDate()('foo'), + failure(leafError('foo', 'Not a date.')) + )) - it('should throw if not an ISO date string', () => { - expect(() => parseDate({ iso: true })('2018-10-22T09:40:40')).to.throw( - FefeError, - 'Not an ISO 8601 date string.' + it('should return an error if not an ISO date string', () => { + const value = '2018-10-22T09:40:40' + assert.deepStrictEqual( + parseDate({ iso: true })(value), + failure(leafError(value, 'Not an ISO 8601 date string.')) ) }) it('should parse an ISO date string without milliseconds', () => { - const date = '2018-10-22T09:40:40Z' - const parsedDate = parseDate({ iso: true })(date) - expect(parsedDate.getTime()).to.equal(new Date(date).getTime()) + const value = '2018-10-22T09:40:40Z' + const parsedDate = parseDate({ iso: true })(value) + assert.deepStrictEqual(parsedDate, success(new Date(value))) }) it('return parsed date', () => { const date = new Date() const parsedDate = parseDate({ iso: true })(date.toISOString()) - expect(parsedDate.getTime()).to.equal(date.getTime()) + assert.deepStrictEqual(parsedDate, success(date)) }) }) diff --git a/src/parse-date.ts b/src/parse-date.ts index 9b5833a..e71131f 100644 --- a/src/parse-date.ts +++ b/src/parse-date.ts @@ -1,15 +1,18 @@ -import { FefeError } from './errors' +import { leafError } from './errors' +import { failure, success } from './result' +import { Transformer } from './validate' const isoDateRegex = /^\d{4}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d:[0-5]\d(\.\d+)?([+-][0-2]\d:[0-5]\d|Z)$/ -export function parseDate({ iso = false }: { iso?: boolean } = {}) { - return (value: unknown): Date => { - // tslint:disable-next-line:strict-type-predicates - if (typeof value !== 'string') throw new FefeError(value, 'Not a string.') +export function parseDate({ iso = false }: { iso?: boolean } = {}): Transformer< + string, + Date +> { + return (value: string) => { if (iso && !isoDateRegex.test(value)) - throw new FefeError(value, 'Not an ISO 8601 date string.') + return failure(leafError(value, 'Not an ISO 8601 date string.')) const time = Date.parse(value) - if (Number.isNaN(time)) throw new FefeError(value, 'Not a date.') - return new Date(time) + if (Number.isNaN(time)) return failure(leafError(value, 'Not a date.')) + return success(new Date(time)) } } diff --git a/src/parse-json.test.ts b/src/parse-json.test.ts index 79a34e6..330e2b9 100644 --- a/src/parse-json.test.ts +++ b/src/parse-json.test.ts @@ -1,14 +1,24 @@ -import { expect } from 'chai' +import { assert } from 'chai' -import { FefeError } from './errors' +import { leafError } from './errors' +import { failure, success } from './result' import { parseJson } from './parse-json' describe('parseJson()', () => { - it('should throw if not JSON', () => { - expect(() => parseJson()('{foo}')).to.throw(FefeError, 'Invalid JSON') - }) + it('should return an error if not JSON', () => + assert.deepStrictEqual( + parseJson()('foo'), + failure( + leafError( + 'foo', + 'Invalid JSON: Unexpected token o in JSON at position 1.' + ) + ) + )) - it('return parsed JSON', () => { - expect(parseJson()('{"foo":"bar"}')).to.eql({ foo: 'bar' }) - }) + it('return parsed JSON', () => + assert.deepStrictEqual( + parseJson()('{"foo":"bar"}'), + success({ foo: 'bar' }) + )) }) diff --git a/src/parse-json.ts b/src/parse-json.ts index 3bcf089..e5fa357 100644 --- a/src/parse-json.ts +++ b/src/parse-json.ts @@ -1,13 +1,13 @@ -import { FefeError } from './errors' +import { leafError } from './errors' +import { failure, success } from './result' +import { Transformer } from './validate' -export function parseJson() { - return (value: unknown): unknown => { - // tslint:disable-next-line:strict-type-predicates - if (typeof value !== 'string') throw new FefeError(value, 'Not a string.') +export function parseJson(): Transformer { + return (value: string) => { try { - return JSON.parse(value) + return success(JSON.parse(value)) } catch (error) { - throw new FefeError(value, `Invalid JSON: ${error.message}.`) + return failure(leafError(value, `Invalid JSON: ${error.message}.`)) } } } diff --git a/src/parse-number.test.ts b/src/parse-number.test.ts index 06426cb..473c4e4 100644 --- a/src/parse-number.test.ts +++ b/src/parse-number.test.ts @@ -1,12 +1,16 @@ -import { expect } from 'chai' +import { assert } from 'chai' -import { FefeError } from './errors' +import { leafError } from './errors' +import { failure, success } from './result' import { parseNumber } from './parse-number' describe('parseNumber()', () => { - it('should throw if not a number', () => { - expect(() => parseNumber()('foo')).to.throw(FefeError, 'Not a number.') - }) + it('should return an error if not a number', () => + assert.deepStrictEqual( + parseNumber()('foo'), + failure(leafError('foo', 'Not a number.')) + )) - it('return parsed number', () => expect(parseNumber()('0.5')).to.equal(0.5)) + it('return parsed number', () => + assert.deepStrictEqual(parseNumber()('0.5'), success(0.5))) }) diff --git a/src/parse-number.ts b/src/parse-number.ts index 4223a49..7fe98b5 100644 --- a/src/parse-number.ts +++ b/src/parse-number.ts @@ -1,11 +1,11 @@ -import { FefeError } from './errors' +import { leafError } from './errors' +import { failure, success } from './result' +import { Transformer } from './validate' -export function parseNumber() { - return (value: unknown): number => { - // tslint:disable-next-line:strict-type-predicates - if (typeof value !== 'string') throw new FefeError(value, 'Not a string.') +export function parseNumber(): Transformer { + return (value: string) => { const num = parseFloat(value) - if (Number.isNaN(num)) throw new FefeError(value, 'Not a number.') - return num + if (Number.isNaN(num)) return failure(leafError(value, 'Not a number.')) + return success(num) } } diff --git a/src/result.ts b/src/result.ts new file mode 100644 index 0000000..bf52c52 --- /dev/null +++ b/src/result.ts @@ -0,0 +1,11 @@ +import { Either, isLeft, isRight, left, right } from 'fp-ts/lib/Either' + +import { FefeError } from './errors' + +export type Result = Either + +export const success = (value: T): Result => right(value) +export const isSuccess = isRight + +export const failure = (error: FefeError): Result => left(error) +export const isFailure = isLeft diff --git a/src/string.test.ts b/src/string.test.ts index e3e43fa..1b05b45 100644 --- a/src/string.test.ts +++ b/src/string.test.ts @@ -1,37 +1,39 @@ -import { expect } from 'chai' +import { assert } from 'chai' -import { FefeError } from './errors' +import { leafError } from './errors' import { string } from './string' +import { failure, success } from './result' describe('string()', () => { - it('should throw if not a string', () => { - expect(() => string()(1)).to.throw(FefeError, 'Not a string.') + it('should return an error if not a string', () => { + assert.deepStrictEqual(string()(1), failure(leafError(1, 'Not a string.'))) }) - it('should throw if shorter than minLength', () => { - expect(() => string({ minLength: 4 })('foo')).to.throw( - FefeError, - 'Shorter than 4 characters.' + it('should return an error if shorter than minLength', () => { + assert.deepStrictEqual( + string({ minLength: 4 })('foo'), + failure(leafError('foo', 'Shorter than 4 characters.')) ) }) - it('should throw if longer than maxLength', () => { - expect(() => string({ maxLength: 2 })('foo')).to.throw( - FefeError, - 'Longer than 2 characters.' + it('should return an error if longer than maxLength', () => { + assert.deepStrictEqual( + string({ maxLength: 2 })('foo'), + failure(leafError('foo', 'Longer than 2 characters.')) ) }) - it('should throw if does not match regex', () => { - expect(() => string({ regex: /foo/ })('bar')).to.throw( - FefeError, - 'Does not match regex.' + it('should return an error if does not match regex', () => { + assert.deepStrictEqual( + string({ regex: /foo/ })('bar'), + failure(leafError('bar', 'Does not match regex.')) ) }) it('return a valid string', () => { - expect( - string({ minLength: 2, maxLength: 4, regex: /foo/ })('foo') - ).to.equal('foo') + assert.deepStrictEqual( + string({ minLength: 2, maxLength: 4, regex: /foo/ })('foo'), + success('foo') + ) }) }) diff --git a/src/string.ts b/src/string.ts index b7ea9de..9e50ada 100644 --- a/src/string.ts +++ b/src/string.ts @@ -1,4 +1,6 @@ -import { FefeError } from './errors' +import { leafError } from './errors' +import { failure, success } from './result' +import { Validator } from './validate' export interface StringOptions { minLength?: number @@ -6,16 +8,21 @@ export interface StringOptions { regex?: RegExp } -export function string({ minLength, maxLength, regex }: StringOptions = {}) { - return (value: unknown): string => { +export function string({ + minLength, + maxLength, + regex, +}: StringOptions = {}): Validator { + return (value: unknown) => { // tslint:disable-next-line:strict-type-predicates - if (typeof value !== 'string') throw new FefeError(value, 'Not a string.') + if (typeof value !== 'string') + return failure(leafError(value, 'Not a string.')) if (minLength !== undefined && value.length < minLength) - throw new FefeError(value, `Shorter than ${minLength} characters.`) + return failure(leafError(value, `Shorter than ${minLength} characters.`)) if (maxLength !== undefined && value.length > maxLength) - throw new FefeError(value, `Longer than ${maxLength} characters.`) + return failure(leafError(value, `Longer than ${maxLength} characters.`)) if (regex !== undefined && !regex.test(value)) - throw new FefeError(value, 'Does not match regex.') - return value + return failure(leafError(value, 'Does not match regex.')) + return success(value) } } diff --git a/src/union.test.ts b/src/union.test.ts index de1d1b7..5532ea4 100644 --- a/src/union.test.ts +++ b/src/union.test.ts @@ -1,6 +1,7 @@ -import { expect } from 'chai' +import { assert } from 'chai' -import { FefeError } from './errors' +import { leafError } from './errors' +import { failure, success } from './result' import { boolean } from './boolean' import { string } from './string' import { union } from './union' @@ -8,14 +9,16 @@ import { union } from './union' describe('union()', () => { const validate = union(boolean(), string()) - it('should throw if all validators throw', () => { - expect(() => validate(1)).to.throw(FefeError, 'Not of any expected type.') - }) + it('should return an error if all validators return errors', () => + assert.deepStrictEqual( + validate(1), + failure( + leafError(1, 'Not of any expected type (Not a boolean. Not a string.).') + ) + )) it('should validate either type', () => { - const booleanResult: boolean | string = validate(false) - expect(booleanResult).to.equal(false) - const stringResult: boolean | string = validate('foo') - expect(stringResult).to.equal('foo') + assert.deepStrictEqual(validate(false), success(false)) + assert.deepStrictEqual(validate('foo'), success('foo')) }) }) diff --git a/src/union.ts b/src/union.ts index f4333bb..ad63780 100644 --- a/src/union.ts +++ b/src/union.ts @@ -1,20 +1,23 @@ -import { FefeError } from './errors' -import { Validator } from './validate' +import { FefeError, leafError, getErrorString } from './errors' +import { failure, isSuccess, success } from './result' +import { Validator, ValidatorReturnType } from './validate' -export function union[]>(...validators: T) { - return (value: unknown): ReturnType => { +export function union[]>( + ...validators: T +): Validator> { + return (value: unknown) => { const errors: FefeError[] = [] for (const validator of validators) { - try { - return validator(value) as ReturnType - } catch (error) { - if (error instanceof FefeError) { - errors.push(error) - } else { - throw error - } - } + const result = validator(value) + if (isSuccess(result)) + return success(result.right as ValidatorReturnType) + errors.push(result.left) } - throw new FefeError(value, 'Not of any expected type.') + return failure( + leafError( + value, + `Not of any expected type (${errors.map(getErrorString).join(' ')}).` + ) + ) } } diff --git a/src/validate.ts b/src/validate.ts index 1a92ba5..bef6c72 100644 --- a/src/validate.ts +++ b/src/validate.ts @@ -1 +1,6 @@ -export type Validator = (value: unknown) => R +import { Result } from './result' + +export type Transformer = (v: V) => Result + +export type Validator = Transformer +export type ValidatorReturnType = T extends Validator ? U : never diff --git a/tsconfig.json b/tsconfig.json index 26295e5..bf55243 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,7 +3,7 @@ "declaration": true, "esModuleInterop": true, "lib": [ - "es2017" + "es2020" ], "module": "commonjs", "outDir": "dist/",