From bae21f5d7e8e64b53ee00075226ed393c4ac339f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Gaul?= Date: Sat, 27 Mar 2021 16:07:13 +0100 Subject: [PATCH 01/21] introduce new error types and migrate boolean() to Either --- package-lock.json | 13 +++++++++++ package.json | 3 +++ src/boolean.test.ts | 15 ++++++++----- src/boolean.ts | 10 +++++---- src/errors.test.ts | 36 ++++++++++++++++++++++++++++++ src/errors.ts | 54 +++++++++++++++++++++++++++++++++++++++++++++ src/index.test.ts | 4 ++-- src/result.ts | 6 +++++ src/union.test.ts | 34 ++++++++++++++-------------- tsconfig.json | 2 +- 10 files changed, 148 insertions(+), 29 deletions(-) create mode 100644 src/errors.test.ts create mode 100644 src/result.ts diff --git a/package-lock.json b/package-lock.json index 497bd7c..99ef3d9 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", @@ -1825,6 +1828,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", @@ -5712,6 +5720,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", diff --git a/package.json b/package.json index d7f3742..d52fff6 100644 --- a/package.json +++ b/package.json @@ -68,5 +68,8 @@ "*.ts": [ "eslint --fix" ] + }, + "dependencies": { + "fp-ts": "^2.9.5" } } 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..636c202 100644 --- a/src/boolean.ts +++ b/src/boolean.ts @@ -1,9 +1,11 @@ -import { FefeError } from './errors' +import { Either, left, right } from 'fp-ts/lib/Either' +import { leafError, FefeError2 } from './errors' export function boolean() { - return (value: unknown): boolean => { + return (value: unknown): Either => { // tslint:disable-next-line:strict-type-predicates - if (typeof value !== 'boolean') throw new FefeError(value, 'Not a boolean.') - return value + if (typeof value !== 'boolean') + return left(leafError(value, 'Not a boolean.')) + return right(value) } } diff --git a/src/errors.test.ts b/src/errors.test.ts new file mode 100644 index 0000000..d343435 --- /dev/null +++ b/src/errors.test.ts @@ -0,0 +1,36 @@ +import { assert } from 'chai' + +import { + branchError, + leafError, + getLeafErrorReasons, + getErrorString, + FefeError2, +} from './errors' + +const error: FefeError2 = 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..a0b5c4f 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -1,3 +1,57 @@ +export type Key = string | number | symbol + +export interface ChildError2 { + key: Key + error: FefeError2 +} + +export interface LeafError { + type: 'leaf' + value: unknown + reason: string +} + +export interface BranchError { + type: 'branch' + value: unknown + children: ChildError2[] +} + +export type FefeError2 = LeafError | BranchError + +export function leafError(value: unknown, reason: string): LeafError { + return { type: 'leaf', value, reason } +} + +export function branchError( + value: unknown, + children: ChildError2[] +): BranchError { + return { type: 'branch', value, children } +} + +export type LeafErrorReason = { path: Key[]; reason: string } + +export function getLeafErrorReasons(error: FefeError2): LeafErrorReason[] { + if (error.type === 'leaf') return [{ path: [], reason: error.reason }] + + return error.children.flatMap((child) => { + return getLeafErrorReasons(child.error).map((leafErrorReason) => ({ + path: [child.key, ...leafErrorReason.path], + reason: leafErrorReason.reason, + })) + }) +} + +export function getErrorString(error: FefeError2): string { + return getLeafErrorReasons(error) + .map(({ path, reason }) => { + if (path.length === 0) return reason + return `${path.join('.')}: ${reason}` + }) + .join(' ') +} + export class ExtendableError extends Error { constructor(message: string) { super(message) diff --git a/src/index.test.ts b/src/index.test.ts index 0e4723f..fea7ac1 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -12,7 +12,7 @@ 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()), @@ -25,7 +25,7 @@ describe('Integration tests', () => { name: 'André', age: 35, address: { street: 'Kreuzbergstr', zip: 10965 }, - isVerified: true, + // isVerified: true, verifiedAt: 'never', joinedAt: new Date(), favoriteDishes: ['Pho Bo', 'Sushi'], diff --git a/src/result.ts b/src/result.ts new file mode 100644 index 0000000..efff8b2 --- /dev/null +++ b/src/result.ts @@ -0,0 +1,6 @@ +import { Either, left, right } from 'fp-ts/lib/Either' +import { FefeError2 } from './errors' + +export const success = (value: T): Either => right(value) +export const failure = (error: FefeError2): Either => + left(error) diff --git a/src/union.test.ts b/src/union.test.ts index de1d1b7..368ac0c 100644 --- a/src/union.test.ts +++ b/src/union.test.ts @@ -1,21 +1,21 @@ -import { expect } from 'chai' +// import { expect } from 'chai' -import { FefeError } from './errors' -import { boolean } from './boolean' -import { string } from './string' -import { union } from './union' +// import { FefeError } from './errors' +// import { boolean } from './boolean' +// import { string } from './string' +// import { union } from './union' -describe('union()', () => { - const validate = union(boolean(), string()) +// 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 throw if all validators throw', () => { +// expect(() => validate(1)).to.throw(FefeError, 'Not of any expected type.') +// }) - 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') - }) -}) +// 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') +// }) +// }) 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/", From 78ea13df11437ac3f89f5459ec287f2d396f6153 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Gaul?= Date: Sat, 27 Mar 2021 17:50:19 +0100 Subject: [PATCH 02/21] update array() to either --- src/array.ts | 47 +++++++++++++++++++++++++++++------------------ src/boolean.ts | 14 +++++++------- src/errors.ts | 6 +++--- src/result.ts | 11 ++++++++--- src/validate.ts | 4 ++++ 5 files changed, 51 insertions(+), 31 deletions(-) diff --git a/src/array.ts b/src/array.ts index 7d93f58..15ffd55 100644 --- a/src/array.ts +++ b/src/array.ts @@ -1,31 +1,42 @@ -import { FefeError } from './errors' -import { Validator } from './validate' +import { branchError, leafError } from './errors' +import { failure, isFailure, success } from './result' +import { Validator2 } from './validate' + +import { partitionMapWithIndex, traverseWithIndex } from 'fp-ts/lib/Array' +import { either, isLeft, left } from 'fp-ts/Either' export interface ArrayOptions { minLength?: number maxLength?: number + allErrors?: boolean } export function array( - elementValidator: Validator, - { minLength, maxLength }: ArrayOptions = {} -): (value: unknown) => R[] { + elementValidator: Validator2, + { minLength, maxLength, allErrors }: ArrayOptions = {} +): Validator2 { + 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.ts b/src/boolean.ts index 636c202..577edb7 100644 --- a/src/boolean.ts +++ b/src/boolean.ts @@ -1,11 +1,11 @@ -import { Either, left, right } from 'fp-ts/lib/Either' -import { leafError, FefeError2 } from './errors' +import { leafError } from './errors' +import { failure, success } from './result' +import { Validator2 } from './validate' -export function boolean() { - return (value: unknown): Either => { - // tslint:disable-next-line:strict-type-predicates +export function boolean(): Validator2 { + return (value: unknown) => { if (typeof value !== 'boolean') - return left(leafError(value, 'Not a boolean.')) - return right(value) + return failure(leafError(value, 'Not a boolean.')) + return success(value) } } diff --git a/src/errors.ts b/src/errors.ts index a0b5c4f..439745e 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -14,7 +14,7 @@ export interface LeafError { export interface BranchError { type: 'branch' value: unknown - children: ChildError2[] + childErrors: ChildError2[] } export type FefeError2 = LeafError | BranchError @@ -27,7 +27,7 @@ export function branchError( value: unknown, children: ChildError2[] ): BranchError { - return { type: 'branch', value, children } + return { type: 'branch', value, childErrors: children } } export type LeafErrorReason = { path: Key[]; reason: string } @@ -35,7 +35,7 @@ export type LeafErrorReason = { path: Key[]; reason: string } export function getLeafErrorReasons(error: FefeError2): LeafErrorReason[] { if (error.type === 'leaf') return [{ path: [], reason: error.reason }] - return error.children.flatMap((child) => { + return error.childErrors.flatMap((child) => { return getLeafErrorReasons(child.error).map((leafErrorReason) => ({ path: [child.key, ...leafErrorReason.path], reason: leafErrorReason.reason, diff --git a/src/result.ts b/src/result.ts index efff8b2..43fd5a8 100644 --- a/src/result.ts +++ b/src/result.ts @@ -1,6 +1,11 @@ import { Either, left, right } from 'fp-ts/lib/Either' +import { isLeft, isRight } from 'fp-ts/lib/These' import { FefeError2 } from './errors' -export const success = (value: T): Either => right(value) -export const failure = (error: FefeError2): Either => - left(error) +export type Result = Either + +export const success = (value: T): Result => right(value) +export const isSuccess = isRight + +export const failure = (error: FefeError2): Result => left(error) +export const isFailure = isLeft diff --git a/src/validate.ts b/src/validate.ts index 1a92ba5..99ae2e6 100644 --- a/src/validate.ts +++ b/src/validate.ts @@ -1 +1,5 @@ +import { Result } from './result' + export type Validator = (value: unknown) => R +export type Validator2 = (v: unknown) => Result +export type Transformer = (v: V) => Result From 0b7065dbc7ba63c048040cc626126e9dbf7fc7cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Gaul?= Date: Sat, 27 Mar 2021 18:16:10 +0100 Subject: [PATCH 03/21] finish array --- src/array.test.ts | 70 ++++++++++++++++++++++++++++++++--------------- src/array.ts | 6 ++-- src/result.ts | 4 +-- 3 files changed, 53 insertions(+), 27 deletions(-) diff --git a/src/array.test.ts b/src/array.test.ts index adfc1dc..6bc32dd 100644 --- a/src/array.test.ts +++ b/src/array.test.ts @@ -1,36 +1,62 @@ -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 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 15ffd55..1d422bc 100644 --- a/src/array.ts +++ b/src/array.ts @@ -1,10 +1,10 @@ +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 { Validator2 } from './validate' -import { partitionMapWithIndex, traverseWithIndex } from 'fp-ts/lib/Array' -import { either, isLeft, left } from 'fp-ts/Either' - export interface ArrayOptions { minLength?: number maxLength?: number diff --git a/src/result.ts b/src/result.ts index 43fd5a8..e5b6d57 100644 --- a/src/result.ts +++ b/src/result.ts @@ -1,5 +1,5 @@ -import { Either, left, right } from 'fp-ts/lib/Either' -import { isLeft, isRight } from 'fp-ts/lib/These' +import { Either, isLeft, isRight, left, right } from 'fp-ts/lib/Either' + import { FefeError2 } from './errors' export type Result = Either From 50a15fa41a606d6083c10ab581477e307c97bd74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Gaul?= Date: Sat, 27 Mar 2021 18:36:46 +0100 Subject: [PATCH 04/21] migrate date() to either --- src/date.test.ts | 44 +++++++++++++++++++++++++------------------- src/date.ts | 20 ++++++++++++-------- 2 files changed, 37 insertions(+), 27 deletions(-) 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..b3b7ab9 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 { Validator2 } 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 = {}): Validator2 { + 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) } } From 70b07ab7293accfbab47a8d1bc80f069f32f02dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Gaul?= Date: Sat, 27 Mar 2021 18:45:25 +0100 Subject: [PATCH 05/21] migrate enumerate() --- src/enumerate.test.ts | 22 +++++++++++++++------- src/enumerate.ts | 17 ++++++++++------- 2 files changed, 25 insertions(+), 14 deletions(-) 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..4445a26 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 { Validator2 } 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 +): Validator2 { + 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]) } } From e539e78e7d2539bf36ff196f651e1d4cc15e121f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Gaul?= Date: Sat, 27 Mar 2021 18:52:04 +0100 Subject: [PATCH 06/21] migrate number() to either --- src/number.test.ts | 58 +++++++++++++++++++++++++++++----------------- src/number.ts | 24 ++++++++++--------- 2 files changed, 50 insertions(+), 32 deletions(-) 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..33bf38c 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 { Validator2 } 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 = {}): Validator2 { + 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) } } From 538273fbaa2deee7f057e775e3a7485b057334a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Gaul?= Date: Sat, 27 Mar 2021 18:58:15 +0100 Subject: [PATCH 07/21] migrate string() to either --- src/string.test.ts | 40 +++++++++++++++++++++------------------- src/string.ts | 23 +++++++++++++++-------- 2 files changed, 36 insertions(+), 27 deletions(-) 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..ddcfb6f 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 { Validator2 } 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 = {}): Validator2 { + 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) } } From 85da7fe89cd2b3193bb7c13032dc7c1324e3ccc0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Gaul?= Date: Sun, 28 Mar 2021 19:25:03 +0200 Subject: [PATCH 08/21] migrate object to either --- src/object.test.ts | 127 ++++++++++++++++++++------------------- src/object.ts | 146 ++++++++++++++++++++------------------------- src/validate.ts | 3 + 3 files changed, 134 insertions(+), 142 deletions(-) diff --git a/src/object.test.ts b/src/object.test.ts index 7008333..09cb64e 100644 --- a/src/object.test.ts +++ b/src/object.test.ts @@ -1,85 +1,92 @@ -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' 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 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 does not validate', () => { const value = { foo: 1337 } - expect(() => validate(value)) - .to.throw(FefeError, 'foo: Not a string.') - .that.deep.include({ value, path: ['foo'] }) - }) - - 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' }) + assert.deepStrictEqual( + object({ foo: string() })(value), + failure( + branchError(value, [ + { key: 'foo', error: leafError(1337, 'Not a string.') }, + ]) + ) + ) }) - 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({}) + const validate = object({ foo: optional(string()) }) + assert.deepStrictEqual(validate({ foo: 'bar' }), success({ foo: 'bar' })) + assert.deepStrictEqual(validate({}), success({})) + assert.deepStrictEqual(validate({ foo: undefined }), success({})) }) 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 default value function', () => { - const validate = object({ - foo: { validator: string(), default: () => 'bar' }, - }) - const result: { foo: string } = validate({}) - expect(result).to.eql({ foo: 'bar' }) + 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.')) + )) + + it('should return undefined if no value is provided', () => + assert.deepStrictEqual(validate(undefined), success(undefined))) }) diff --git a/src/object.ts b/src/object.ts index 345be82..69ca4bd 100644 --- a/src/object.ts +++ b/src/object.ts @@ -1,117 +1,99 @@ -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 { branchError, ChildError2, leafError } from './errors' +import { failure, isFailure, success } from './result' +import { Validator2, Validator2ReturnType } 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 ObjectDefinition = Record> type FilterObject = { [k in keyof T]: T[k] extends C ? k : never } type MatchingKeys = FilterObject[keyof T] type NotFilterObject = { [k in keyof T]: T[k] extends C ? never : k } type NonMatchingKeys = NotFilterObject[keyof T] -type MandatoryKeys = NonMatchingKeys -type OptionalKeys = MatchingKeys +type MandatoryKeys = NonMatchingKeys> +type OptionalKeys = MatchingKeys> export type ObjectResult = { - [k in MandatoryKeys]: ObjectReturnType + [k in MandatoryKeys]: Validator2ReturnType } & - { [k in OptionalKeys]?: ObjectReturnType } + { [k in OptionalKeys]?: Validator2ReturnType } + +export interface ObjectOptions { + allowExcessProperties?: boolean + allErrors?: boolean +} 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 = {} +): Validator2> { + function getEntryValidator(value: Record) { + return ([key, validator]: [ + K, + Validator2 + ]): Either]> => { + const result = validator(value[key]) + if (isFailure(result)) return left({ key, error: result.left }) + return right([key, result.right as Validator2ReturnType]) } - }) + } + + function createObjectFromEntries( + entries: [keyof D, Validator2ReturnType][] + ) { + return Object.fromEntries( + entries.filter(([, v]) => v !== undefined) + ) 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: Validator2, + _default: D | (() => D) +): Validator2 { + 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: Validator2 +): Validator2 { + return defaultTo(validator, undefined) } diff --git a/src/validate.ts b/src/validate.ts index 99ae2e6..23ea399 100644 --- a/src/validate.ts +++ b/src/validate.ts @@ -1,5 +1,8 @@ import { Result } from './result' export type Validator = (value: unknown) => R + export type Validator2 = (v: unknown) => Result +export type Validator2ReturnType = T extends Validator2 ? U : never + export type Transformer = (v: V) => Result From d19e8f582839697f93bae243b4f695549158dd72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Gaul?= Date: Sun, 28 Mar 2021 19:28:29 +0200 Subject: [PATCH 09/21] fix optional in object --- src/object.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/object.ts b/src/object.ts index 69ca4bd..9d35930 100644 --- a/src/object.ts +++ b/src/object.ts @@ -6,9 +6,9 @@ import { Validator2, Validator2ReturnType } from './validate' export type ObjectDefinition = Record> -type FilterObject = { [k in keyof T]: T[k] extends C ? k : never } +type FilterObject = { [k in keyof T]: C extends T[k] ? k : never } type MatchingKeys = FilterObject[keyof T] -type NotFilterObject = { [k in keyof T]: T[k] extends C ? never : k } +type NotFilterObject = { [k in keyof T]: C extends T[k] ? never : k } type NonMatchingKeys = NotFilterObject[keyof T] type MandatoryKeys = NonMatchingKeys> From b1cf6a179be52ce5abbd3ffc9d858f3e1c2cec22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Gaul?= Date: Mon, 29 Mar 2021 20:50:16 +0200 Subject: [PATCH 10/21] finish object() migration --- src/object.test.ts | 10 +++++---- src/object.ts | 54 ++++++++++++++++++++++++++++++++++------------ 2 files changed, 46 insertions(+), 18 deletions(-) diff --git a/src/object.test.ts b/src/object.test.ts index 09cb64e..58a5231 100644 --- a/src/object.test.ts +++ b/src/object.test.ts @@ -4,6 +4,7 @@ import { object, defaultTo, optional } from './object' import { string } from './string' import { branchError, leafError } from './errors' import { failure, success } from './result' +import { Validator2 } from './validate' describe('object()', () => { it('should return an error if value is not an object', () => @@ -42,10 +43,14 @@ describe('object()', () => { }) it('should validate an object with optional key', () => { - const validate = object({ foo: optional(string()) }) + const validate: Validator2<{ 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', () => { @@ -86,7 +91,4 @@ describe('optional()', () => { validate(42), failure(leafError(42, 'Not a string.')) )) - - it('should return undefined if no value is provided', () => - assert.deepStrictEqual(validate(undefined), success(undefined))) }) diff --git a/src/object.ts b/src/object.ts index 9d35930..d0da46f 100644 --- a/src/object.ts +++ b/src/object.ts @@ -1,18 +1,20 @@ 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, ChildError2, leafError } from './errors' import { failure, isFailure, success } from './result' import { Validator2, Validator2ReturnType } from './validate' -export type ObjectDefinition = Record> +export type ObjectValueValidator = Validator2 & { optional?: boolean } +export type ObjectDefinition = Record -type FilterObject = { [k in keyof T]: C extends T[k] ? k : never } +type FilterObject = { [k in keyof T]: T[k] extends C ? k : never } type MatchingKeys = FilterObject[keyof T] -type NotFilterObject = { [k in keyof T]: C extends T[k] ? never : k } +type NotFilterObject = { [k in keyof T]: T[k] extends C ? never : k } type NonMatchingKeys = NotFilterObject[keyof T] -type MandatoryKeys = NonMatchingKeys> -type OptionalKeys = MatchingKeys> +type MandatoryKeys = NonMatchingKeys +type OptionalKeys = MatchingKeys export type ObjectResult = { [k in MandatoryKeys]: Validator2ReturnType @@ -24,6 +26,10 @@ export interface ObjectOptions { allErrors?: boolean } +type ValidatedEntry = + | { type: 'mandatory'; key: K; value: T } + | { type: 'optional'; key: K } + export function object( definition: D, { allowExcessProperties = false, allErrors = false }: ObjectOptions = {} @@ -31,20 +37,36 @@ export function object( function getEntryValidator(value: Record) { return ([key, validator]: [ K, - Validator2 - ]): Either]> => { + 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([key, result.right as Validator2ReturnType]) + return right({ + type: 'mandatory', + key, + value: result.right as Validator2ReturnType, + }) } } function createObjectFromEntries( - entries: [keyof D, Validator2ReturnType][] + entries: ValidatedEntry>[] ) { - return Object.fromEntries( - entries.filter(([, v]) => v !== undefined) - ) as ObjectResult + return pipe( + entries, + partitionMap( + (entry: ValidatedEntry>) => + entry.type === 'optional' + ? left(entry.key) + : right([entry.key, entry.value] as [ + keyof D, + Validator2ReturnType + ]) + ), + ({ right }) => Object.fromEntries(right) as ObjectResult + ) } return (value: unknown) => { @@ -94,6 +116,10 @@ export function defaultTo( export function optional( validator: Validator2 -): Validator2 { - return defaultTo(validator, undefined) +): Validator2 & { optional: true } { + const validate = ((v: unknown) => validator(v)) as Validator2 & { + optional: true + } + validate.optional = true + return validate } From f78b4a5d382a6f5efad5cc5ae945dae0b64c3154 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Gaul?= Date: Mon, 29 Mar 2021 21:11:59 +0200 Subject: [PATCH 11/21] migrate parsers --- src/parse-boolean.test.ts | 17 ++++++++++------- src/parse-boolean.ts | 16 ++++++++-------- src/parse-date.test.ts | 30 +++++++++++++++++------------- src/parse-date.ts | 19 +++++++++++-------- src/parse-json.test.ts | 26 ++++++++++++++++++-------- src/parse-json.ts | 14 +++++++------- src/parse-number.test.ts | 16 ++++++++++------ src/parse-number.ts | 14 +++++++------- 8 files changed, 88 insertions(+), 64 deletions(-) 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..42c47f6 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 throw 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..461e05f 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 throw 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) } } From 67c0b57a2c9cc645323009c5a1c390c94a78d659 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Gaul?= Date: Mon, 29 Mar 2021 21:34:04 +0200 Subject: [PATCH 12/21] migrate union() --- src/parse-json.test.ts | 2 +- src/parse-number.test.ts | 2 +- src/union.test.ts | 37 ++++++++++++++++++++----------------- src/union.ts | 33 ++++++++++++++++++--------------- 4 files changed, 40 insertions(+), 34 deletions(-) diff --git a/src/parse-json.test.ts b/src/parse-json.test.ts index 42c47f6..330e2b9 100644 --- a/src/parse-json.test.ts +++ b/src/parse-json.test.ts @@ -5,7 +5,7 @@ import { failure, success } from './result' import { parseJson } from './parse-json' describe('parseJson()', () => { - it('should throw if not JSON', () => + it('should return an error if not JSON', () => assert.deepStrictEqual( parseJson()('foo'), failure( diff --git a/src/parse-number.test.ts b/src/parse-number.test.ts index 461e05f..473c4e4 100644 --- a/src/parse-number.test.ts +++ b/src/parse-number.test.ts @@ -5,7 +5,7 @@ import { failure, success } from './result' import { parseNumber } from './parse-number' describe('parseNumber()', () => { - it('should throw if not a number', () => + it('should return an error if not a number', () => assert.deepStrictEqual( parseNumber()('foo'), failure(leafError('foo', 'Not a number.')) diff --git a/src/union.test.ts b/src/union.test.ts index 368ac0c..5532ea4 100644 --- a/src/union.test.ts +++ b/src/union.test.ts @@ -1,21 +1,24 @@ -// import { expect } from 'chai' +import { assert } from 'chai' -// import { FefeError } from './errors' -// import { boolean } from './boolean' -// import { string } from './string' -// import { union } from './union' +import { leafError } from './errors' +import { failure, success } from './result' +import { boolean } from './boolean' +import { string } from './string' +import { union } from './union' -// describe('union()', () => { -// const validate = union(boolean(), string()) +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') -// }) -// }) + it('should validate either type', () => { + assert.deepStrictEqual(validate(false), success(false)) + assert.deepStrictEqual(validate('foo'), success('foo')) + }) +}) diff --git a/src/union.ts b/src/union.ts index f4333bb..c1b95bb 100644 --- a/src/union.ts +++ b/src/union.ts @@ -1,20 +1,23 @@ -import { FefeError } from './errors' -import { Validator } from './validate' +import { FefeError2, leafError, getErrorString } from './errors' +import { failure, isSuccess, success } from './result' +import { Validator2, Validator2ReturnType } from './validate' -export function union[]>(...validators: T) { - return (value: unknown): ReturnType => { - const errors: FefeError[] = [] +export function union[]>( + ...validators: T +): Validator2> { + return (value: unknown) => { + const errors: FefeError2[] = [] 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 Validator2ReturnType) + 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(' ')}).` + ) + ) } } From f236dd2d7e420e670caf71f8ec08315d6db2539d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Gaul?= Date: Mon, 29 Mar 2021 22:00:11 +0200 Subject: [PATCH 13/21] migrate integration tests to either --- package-lock.json | 13 ---- package.json | 1 - src/index.test.ts | 158 +++++++++++++++++++++++++++++----------------- src/index.ts | 28 ++++---- src/object.ts | 2 +- 5 files changed, 115 insertions(+), 87 deletions(-) diff --git a/package-lock.json b/package-lock.json index 99ef3d9..de07651 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,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" } @@ -3454,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", @@ -6908,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 d52fff6..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" }, diff --git a/src/index.test.ts b/src/index.test.ts index fea7ac1..1852643 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -1,8 +1,13 @@ -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 '.' +import { branchError, leafError } from './errors' +import { failure, success } from './result' +import { Validator2ReturnType } from './validate' + describe('Integration tests', () => { describe('Basic validation', () => { const validatePerson = fefe.object({ @@ -19,7 +24,7 @@ describe('Integration tests', () => { notifications: fefe.enumerate('immediately', 'daily', 'never'), }) - type Person = ReturnType + type Person = Validator2ReturnType const validPerson: Person = { name: 'André', @@ -32,28 +37,34 @@ describe('Integration tests', () => { notifications: 'daily', } - it('validates a person', () => { - const person = validatePerson(validPerson) - expect(person).to.eql(validPerson) - }) + it('validates a person', () => + assert.deepStrictEqual(validatePerson(validPerson), 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), + failure( + branchError(invalidPerson, [ + { + key: 'address', + error: branchError(invalidPerson.address, [ + { key: 'zip', error: 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 +72,70 @@ 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, + 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), + failure( + branchError(invalidMovie, [ + { + key: 'releasedAt', + error: 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 date', () => + assert.deepStrictEqual(sanitizeDate(date), success(date))) - it('returns a parsed date', () => { - const sanitizedDate: Date = sanitizeDate(date.toISOString()) - expect(sanitizedDate).to.eql(date) - }) + it('returns a parsed date', () => + assert.deepStrictEqual(sanitizeDate(date.toISOString()), success(date))) - it('throws with an invalid date', () => { - expect(() => sanitizeDate('foo')).to.throw( - fefe.FefeError, - 'Not of any expected type.' - ) - }) + it('throws with an invalid date', () => + assert.deepStrictEqual( + sanitizeDate('foo'), + failure( + 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) => success(value.split(','))) ), - whitelist: pipe(fefe.string(), (value) => value.split(',')), }) - type Config = ReturnType + type Config = Validator2ReturnType const validConfig: Config = { gcloudCredentials: { key: 'secret' }, @@ -120,27 +147,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), + 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), + failure( + branchError(invalidConfigInput, [ + { + key: 'gcloudCredentials', + error: 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..ce8d20f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,15 +1,15 @@ -export { FefeError } from './errors' +export * from './errors' -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 './validate' +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/object.ts b/src/object.ts index d0da46f..f68c2ff 100644 --- a/src/object.ts +++ b/src/object.ts @@ -81,7 +81,7 @@ export function object( return failure( leafError( value, - `Properties not allowed: ${excessProperties.join(', ')}` + `Properties not allowed: ${excessProperties.join(', ')}.` ) ) } From a7f3e0a2aab20cd67128eba87b9aa8bb73354a02 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Gaul?= Date: Mon, 29 Mar 2021 22:02:49 +0200 Subject: [PATCH 14/21] remove outdated error code --- src/errors.ts | 40 ---------------------------------------- 1 file changed, 40 deletions(-) diff --git a/src/errors.ts b/src/errors.ts index 439745e..a7c04a8 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -51,43 +51,3 @@ export function getErrorString(error: FefeError2): string { }) .join(' ') } - -export class ExtendableError extends Error { - constructor(message: string) { - super(message) - Object.setPrototypeOf(this, new.target.prototype) - } -} - -export interface FefeChildError { - key: string | number | symbol - 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) - } -} From 5e134543dd33fc871189b717c82ae8dd9f090693 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Gaul?= Date: Mon, 29 Mar 2021 22:04:35 +0200 Subject: [PATCH 15/21] rename Validator2 to Validator --- src/array.ts | 6 +++--- src/boolean.ts | 4 ++-- src/date.ts | 4 ++-- src/enumerate.ts | 4 ++-- src/index.test.ts | 6 +++--- src/number.ts | 4 ++-- src/object.test.ts | 4 ++-- src/object.ts | 30 +++++++++++++++--------------- src/string.ts | 4 ++-- src/union.ts | 8 ++++---- src/validate.ts | 8 +++----- 11 files changed, 40 insertions(+), 42 deletions(-) diff --git a/src/array.ts b/src/array.ts index 1d422bc..b0cc9bb 100644 --- a/src/array.ts +++ b/src/array.ts @@ -3,7 +3,7 @@ import { either, isLeft, left } from 'fp-ts/Either' import { branchError, leafError } from './errors' import { failure, isFailure, success } from './result' -import { Validator2 } from './validate' +import { Validator } from './validate' export interface ArrayOptions { minLength?: number @@ -12,9 +12,9 @@ export interface ArrayOptions { } export function array( - elementValidator: Validator2, + elementValidator: Validator, { minLength, maxLength, allErrors }: ArrayOptions = {} -): Validator2 { +): Validator { const validate = (index: number, element: unknown) => { const result = elementValidator(element) if (isFailure(result)) return left({ key: index, error: result.left }) diff --git a/src/boolean.ts b/src/boolean.ts index 577edb7..185b96e 100644 --- a/src/boolean.ts +++ b/src/boolean.ts @@ -1,8 +1,8 @@ import { leafError } from './errors' import { failure, success } from './result' -import { Validator2 } from './validate' +import { Validator } from './validate' -export function boolean(): Validator2 { +export function boolean(): Validator { return (value: unknown) => { if (typeof value !== 'boolean') return failure(leafError(value, 'Not a boolean.')) diff --git a/src/date.ts b/src/date.ts index b3b7ab9..9999dbc 100644 --- a/src/date.ts +++ b/src/date.ts @@ -1,13 +1,13 @@ import { leafError } from './errors' import { failure, success } from './result' -import { Validator2 } from './validate' +import { Validator } from './validate' export interface DateOptions { min?: Date max?: Date } -export function date({ min, max }: DateOptions = {}): Validator2 { +export function date({ min, max }: DateOptions = {}): Validator { return (value: unknown) => { if (!(value instanceof Date)) return failure(leafError(value, 'Not a date.')) diff --git a/src/enumerate.ts b/src/enumerate.ts index 4445a26..22be38c 100644 --- a/src/enumerate.ts +++ b/src/enumerate.ts @@ -1,10 +1,10 @@ import { leafError } from './errors' import { failure, success } from './result' -import { Validator2 } from './validate' +import { Validator } from './validate' export function enumerate( ...args: T -): Validator2 { +): Validator { return (value: unknown) => { if (args.indexOf(value as T[number]) === -1) return failure(leafError(value, `Not one of ${args.join(', ')}.`)) diff --git a/src/index.test.ts b/src/index.test.ts index 1852643..3206dd2 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -6,7 +6,7 @@ import * as fefe from '.' import { branchError, leafError } from './errors' import { failure, success } from './result' -import { Validator2ReturnType } from './validate' +import { ValidatorReturnType } from './validate' describe('Integration tests', () => { describe('Basic validation', () => { @@ -24,7 +24,7 @@ describe('Integration tests', () => { notifications: fefe.enumerate('immediately', 'daily', 'never'), }) - type Person = Validator2ReturnType + type Person = ValidatorReturnType const validPerson: Person = { name: 'André', @@ -135,7 +135,7 @@ describe('Integration tests', () => { ), }) - type Config = Validator2ReturnType + type Config = ValidatorReturnType const validConfig: Config = { gcloudCredentials: { key: 'secret' }, diff --git a/src/number.ts b/src/number.ts index 33bf38c..0e3efe9 100644 --- a/src/number.ts +++ b/src/number.ts @@ -1,6 +1,6 @@ import { leafError } from './errors' import { failure, success } from './result' -import { Validator2 } from './validate' +import { Validator } from './validate' export interface NumberOptions { min?: number @@ -16,7 +16,7 @@ export function number({ integer, allowNaN = false, allowInfinity = false, -}: NumberOptions = {}): Validator2 { +}: NumberOptions = {}): Validator { return (value: unknown) => { if (typeof value !== 'number') return failure(leafError(value, 'Not a number.')) diff --git a/src/object.test.ts b/src/object.test.ts index 58a5231..761513e 100644 --- a/src/object.test.ts +++ b/src/object.test.ts @@ -4,7 +4,7 @@ import { object, defaultTo, optional } from './object' import { string } from './string' import { branchError, leafError } from './errors' import { failure, success } from './result' -import { Validator2 } from './validate' +import { Validator } from './validate' describe('object()', () => { it('should return an error if value is not an object', () => @@ -43,7 +43,7 @@ describe('object()', () => { }) it('should validate an object with optional key', () => { - const validate: Validator2<{ foo?: string }> = object({ + const validate: Validator<{ foo?: string }> = object({ foo: optional(string()), }) assert.deepStrictEqual(validate({ foo: 'bar' }), success({ foo: 'bar' })) diff --git a/src/object.ts b/src/object.ts index f68c2ff..4535653 100644 --- a/src/object.ts +++ b/src/object.ts @@ -3,9 +3,9 @@ import { either, Either, isLeft, left, right } from 'fp-ts/lib/Either' import { pipe } from 'fp-ts/lib/function' import { branchError, ChildError2, leafError } from './errors' import { failure, isFailure, success } from './result' -import { Validator2, Validator2ReturnType } from './validate' +import { Validator, ValidatorReturnType } from './validate' -export type ObjectValueValidator = Validator2 & { optional?: boolean } +export type ObjectValueValidator = Validator & { optional?: boolean } export type ObjectDefinition = Record type FilterObject = { [k in keyof T]: T[k] extends C ? k : never } @@ -17,9 +17,9 @@ type MandatoryKeys = NonMatchingKeys type OptionalKeys = MatchingKeys export type ObjectResult = { - [k in MandatoryKeys]: Validator2ReturnType + [k in MandatoryKeys]: ValidatorReturnType } & - { [k in OptionalKeys]?: Validator2ReturnType } + { [k in OptionalKeys]?: ValidatorReturnType } export interface ObjectOptions { allowExcessProperties?: boolean @@ -33,12 +33,12 @@ type ValidatedEntry = export function object( definition: D, { allowExcessProperties = false, allErrors = false }: ObjectOptions = {} -): Validator2> { +): Validator> { function getEntryValidator(value: Record) { return ([key, validator]: [ K, ObjectValueValidator - ]): Either>> => { + ]): Either>> => { if (validator.optional && (!(key in value) || value[key] === undefined)) return right({ type: 'optional', key }) const result = validator(value[key]) @@ -46,23 +46,23 @@ export function object( return right({ type: 'mandatory', key, - value: result.right as Validator2ReturnType, + value: result.right as ValidatorReturnType, }) } } function createObjectFromEntries( - entries: ValidatedEntry>[] + entries: ValidatedEntry>[] ) { return pipe( entries, partitionMap( - (entry: ValidatedEntry>) => + (entry: ValidatedEntry>) => entry.type === 'optional' ? left(entry.key) : right([entry.key, entry.value] as [ keyof D, - Validator2ReturnType + ValidatorReturnType ]) ), ({ right }) => Object.fromEntries(right) as ObjectResult @@ -105,9 +105,9 @@ export function object( } export function defaultTo( - validator: Validator2, + validator: Validator, _default: D | (() => D) -): Validator2 { +): Validator { return (value: unknown) => { if (value !== undefined) return validator(value) return success(_default instanceof Function ? _default() : _default) @@ -115,9 +115,9 @@ export function defaultTo( } export function optional( - validator: Validator2 -): Validator2 & { optional: true } { - const validate = ((v: unknown) => validator(v)) as Validator2 & { + validator: Validator +): Validator & { optional: true } { + const validate = ((v: unknown) => validator(v)) as Validator & { optional: true } validate.optional = true diff --git a/src/string.ts b/src/string.ts index ddcfb6f..9e50ada 100644 --- a/src/string.ts +++ b/src/string.ts @@ -1,6 +1,6 @@ import { leafError } from './errors' import { failure, success } from './result' -import { Validator2 } from './validate' +import { Validator } from './validate' export interface StringOptions { minLength?: number @@ -12,7 +12,7 @@ export function string({ minLength, maxLength, regex, -}: StringOptions = {}): Validator2 { +}: StringOptions = {}): Validator { return (value: unknown) => { // tslint:disable-next-line:strict-type-predicates if (typeof value !== 'string') diff --git a/src/union.ts b/src/union.ts index c1b95bb..1606236 100644 --- a/src/union.ts +++ b/src/union.ts @@ -1,16 +1,16 @@ import { FefeError2, leafError, getErrorString } from './errors' import { failure, isSuccess, success } from './result' -import { Validator2, Validator2ReturnType } from './validate' +import { Validator, ValidatorReturnType } from './validate' -export function union[]>( +export function union[]>( ...validators: T -): Validator2> { +): Validator> { return (value: unknown) => { const errors: FefeError2[] = [] for (const validator of validators) { const result = validator(value) if (isSuccess(result)) - return success(result.right as Validator2ReturnType) + return success(result.right as ValidatorReturnType) errors.push(result.left) } return failure( diff --git a/src/validate.ts b/src/validate.ts index 23ea399..bef6c72 100644 --- a/src/validate.ts +++ b/src/validate.ts @@ -1,8 +1,6 @@ import { Result } from './result' -export type Validator = (value: unknown) => R - -export type Validator2 = (v: unknown) => Result -export type Validator2ReturnType = T extends Validator2 ? U : never - export type Transformer = (v: V) => Result + +export type Validator = Transformer +export type ValidatorReturnType = T extends Validator ? U : never From 7c89a1865b16068caf4b76d290d64ce59988d00c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Gaul?= Date: Mon, 29 Mar 2021 22:12:30 +0200 Subject: [PATCH 16/21] add test for non-allowed key --- src/object.test.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/object.test.ts b/src/object.test.ts index 761513e..06d9576 100644 --- a/src/object.test.ts +++ b/src/object.test.ts @@ -13,6 +13,14 @@ describe('object()', () => { 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 return an error if object has a missing key', () => { const value = {} assert.deepStrictEqual( From 2a3be83b8f4def221ea7fe4271e9bcdf68cd12e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Gaul?= Date: Mon, 29 Mar 2021 22:15:21 +0200 Subject: [PATCH 17/21] add allErrors test to object() --- src/object.test.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/object.test.ts b/src/object.test.ts index 06d9576..06c843c 100644 --- a/src/object.test.ts +++ b/src/object.test.ts @@ -5,6 +5,7 @@ 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 return an error if value is not an object', () => @@ -33,7 +34,7 @@ describe('object()', () => { ) }) - it('should return an error if object has a value does not validate', () => { + it('should return an error if object has a value that does not validate', () => { const value = { foo: 1337 } assert.deepStrictEqual( object({ foo: string() })(value), @@ -45,6 +46,19 @@ describe('object()', () => { ) }) + it('should return all errors if 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', () => { const value = { foo: 'bar' } assert.deepStrictEqual(object({ foo: string() })(value), success(value)) From 445b19da2568f15e941eb131388cbee3fa42e207 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Gaul?= Date: Mon, 29 Mar 2021 22:19:05 +0200 Subject: [PATCH 18/21] add another allErrors test --- src/object.test.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/object.test.ts b/src/object.test.ts index 06c843c..6ca6d0d 100644 --- a/src/object.test.ts +++ b/src/object.test.ts @@ -46,7 +46,7 @@ describe('object()', () => { ) }) - it('should return all errors if object has two value that do not validate', () => { + 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), @@ -64,6 +64,14 @@ describe('object()', () => { assert.deepStrictEqual(object({ foo: string() })(value), success(value)) }) + 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 optional key', () => { const validate: Validator<{ foo?: string }> = object({ foo: optional(string()), From b802cb4fce6136ce4c78dd353323aa77c79b9d76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Gaul?= Date: Mon, 29 Mar 2021 22:20:31 +0200 Subject: [PATCH 19/21] add allErrors test --- src/array.test.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/array.test.ts b/src/array.test.ts index 6bc32dd..a2b78cd 100644 --- a/src/array.test.ts +++ b/src/array.test.ts @@ -47,6 +47,14 @@ describe('array()', () => { 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 transform = array( flow( From aeb4ae0befe265652620f6c727976f7718ef1531 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Gaul?= Date: Tue, 30 Mar 2021 09:50:09 +0200 Subject: [PATCH 20/21] replace FefeError --- src/errors.test.ts | 4 ++-- src/errors.ts | 14 +++++++------- src/object.ts | 4 ++-- src/result.ts | 6 +++--- src/union.ts | 4 ++-- 5 files changed, 16 insertions(+), 16 deletions(-) diff --git a/src/errors.test.ts b/src/errors.test.ts index d343435..59b549e 100644 --- a/src/errors.test.ts +++ b/src/errors.test.ts @@ -5,10 +5,10 @@ import { leafError, getLeafErrorReasons, getErrorString, - FefeError2, + FefeError, } from './errors' -const error: FefeError2 = branchError({ id: 'c0ff33', emails: ['hurz'] }, [ +const error: FefeError = branchError({ id: 'c0ff33', emails: ['hurz'] }, [ { key: 'id', error: leafError('c0ff33', 'Not a number.') }, { key: 'emails', diff --git a/src/errors.ts b/src/errors.ts index a7c04a8..879c100 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -1,8 +1,8 @@ export type Key = string | number | symbol -export interface ChildError2 { +export interface ChildError { key: Key - error: FefeError2 + error: FefeError } export interface LeafError { @@ -14,10 +14,10 @@ export interface LeafError { export interface BranchError { type: 'branch' value: unknown - childErrors: ChildError2[] + childErrors: ChildError[] } -export type FefeError2 = LeafError | BranchError +export type FefeError = LeafError | BranchError export function leafError(value: unknown, reason: string): LeafError { return { type: 'leaf', value, reason } @@ -25,14 +25,14 @@ export function leafError(value: unknown, reason: string): LeafError { export function branchError( value: unknown, - children: ChildError2[] + children: ChildError[] ): BranchError { return { type: 'branch', value, childErrors: children } } export type LeafErrorReason = { path: Key[]; reason: string } -export function getLeafErrorReasons(error: FefeError2): LeafErrorReason[] { +export function getLeafErrorReasons(error: FefeError): LeafErrorReason[] { if (error.type === 'leaf') return [{ path: [], reason: error.reason }] return error.childErrors.flatMap((child) => { @@ -43,7 +43,7 @@ export function getLeafErrorReasons(error: FefeError2): LeafErrorReason[] { }) } -export function getErrorString(error: FefeError2): string { +export function getErrorString(error: FefeError): string { return getLeafErrorReasons(error) .map(({ path, reason }) => { if (path.length === 0) return reason diff --git a/src/object.ts b/src/object.ts index 4535653..801e0e3 100644 --- a/src/object.ts +++ b/src/object.ts @@ -1,7 +1,7 @@ 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, ChildError2, leafError } from './errors' +import { branchError, ChildError, leafError } from './errors' import { failure, isFailure, success } from './result' import { Validator, ValidatorReturnType } from './validate' @@ -38,7 +38,7 @@ export function object( return ([key, validator]: [ K, ObjectValueValidator - ]): Either>> => { + ]): Either>> => { if (validator.optional && (!(key in value) || value[key] === undefined)) return right({ type: 'optional', key }) const result = validator(value[key]) diff --git a/src/result.ts b/src/result.ts index e5b6d57..bf52c52 100644 --- a/src/result.ts +++ b/src/result.ts @@ -1,11 +1,11 @@ import { Either, isLeft, isRight, left, right } from 'fp-ts/lib/Either' -import { FefeError2 } from './errors' +import { FefeError } from './errors' -export type Result = Either +export type Result = Either export const success = (value: T): Result => right(value) export const isSuccess = isRight -export const failure = (error: FefeError2): Result => left(error) +export const failure = (error: FefeError): Result => left(error) export const isFailure = isLeft diff --git a/src/union.ts b/src/union.ts index 1606236..ad63780 100644 --- a/src/union.ts +++ b/src/union.ts @@ -1,4 +1,4 @@ -import { FefeError2, leafError, getErrorString } from './errors' +import { FefeError, leafError, getErrorString } from './errors' import { failure, isSuccess, success } from './result' import { Validator, ValidatorReturnType } from './validate' @@ -6,7 +6,7 @@ export function union[]>( ...validators: T ): Validator> { return (value: unknown) => { - const errors: FefeError2[] = [] + const errors: FefeError[] = [] for (const validator of validators) { const result = validator(value) if (isSuccess(result)) From 8d7acbd0396a41bb400e00b9a774e462c3d9db3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Gaul?= Date: Tue, 30 Mar 2021 10:53:19 +0200 Subject: [PATCH 21/21] update README --- README.md | 233 ++++++++++++++++++++++++++++++++-------------- src/index.test.ts | 50 +++++----- src/index.ts | 3 +- 3 files changed, 191 insertions(+), 95 deletions(-) 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/src/index.test.ts b/src/index.test.ts index 3206dd2..656b559 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -4,10 +4,6 @@ import { flow } from 'fp-ts/lib/function' import * as fefe from '.' -import { branchError, leafError } from './errors' -import { failure, success } from './result' -import { ValidatorReturnType } from './validate' - describe('Integration tests', () => { describe('Basic validation', () => { const validatePerson = fefe.object({ @@ -24,7 +20,7 @@ describe('Integration tests', () => { notifications: fefe.enumerate('immediately', 'daily', 'never'), }) - type Person = ValidatorReturnType + type Person = fefe.ValidatorReturnType const validPerson: Person = { name: 'André', @@ -38,7 +34,10 @@ describe('Integration tests', () => { } it('validates a person', () => - assert.deepStrictEqual(validatePerson(validPerson), success(validPerson))) + assert.deepStrictEqual( + validatePerson(validPerson), + fefe.success(validPerson) + )) it('returns an error if person is invalid', () => { const invalidPerson = { @@ -47,12 +46,12 @@ describe('Integration tests', () => { } assert.deepStrictEqual( validatePerson(invalidPerson), - failure( - branchError(invalidPerson, [ + fefe.failure( + fefe.branchError(invalidPerson, [ { key: 'address', - error: branchError(invalidPerson.address, [ - { key: 'zip', error: leafError('foo', 'Not a number.') }, + error: fefe.branchError(invalidPerson.address, [ + { key: 'zip', error: fefe.leafError('foo', 'Not a number.') }, ]), }, ]) @@ -74,7 +73,7 @@ describe('Integration tests', () => { }) assert.deepStrictEqual( movie, - success({ + fefe.success({ title: 'Star Wars', releasedAt: new Date('1977-05-25T12:00:00.000Z'), }) @@ -85,11 +84,11 @@ describe('Integration tests', () => { const invalidMovie = { title: 'Star Wars', releasedAt: 'foo' } assert.deepStrictEqual( sanitizeMovie(invalidMovie), - failure( - branchError(invalidMovie, [ + fefe.failure( + fefe.branchError(invalidMovie, [ { key: 'releasedAt', - error: leafError('foo', 'Not a date.'), + error: fefe.leafError('foo', 'Not a date.'), }, ]) ) @@ -105,16 +104,19 @@ describe('Integration tests', () => { const date = new Date() it('returns a date', () => - assert.deepStrictEqual(sanitizeDate(date), success(date))) + assert.deepStrictEqual(sanitizeDate(date), fefe.success(date))) it('returns a parsed date', () => - assert.deepStrictEqual(sanitizeDate(date.toISOString()), success(date))) + assert.deepStrictEqual( + sanitizeDate(date.toISOString()), + fefe.success(date) + )) it('throws with an invalid date', () => assert.deepStrictEqual( sanitizeDate('foo'), - failure( - leafError( + fefe.failure( + fefe.leafError( 'foo', 'Not of any expected type (Not a date. Not a date.).' ) @@ -131,11 +133,11 @@ describe('Integration tests', () => { ), whitelist: flow( fefe.string(), - chain((value) => success(value.split(','))) + chain((value) => fefe.success(value.split(','))) ), }) - type Config = ValidatorReturnType + type Config = fefe.ValidatorReturnType const validConfig: Config = { gcloudCredentials: { key: 'secret' }, @@ -150,7 +152,7 @@ describe('Integration tests', () => { it('parses a config', () => assert.deepStrictEqual( parseConfig(validConfigInput), - success(validConfig) + fefe.success(validConfig) )) it('throws with an invalid config', () => { @@ -160,11 +162,11 @@ describe('Integration tests', () => { } assert.deepStrictEqual( parseConfig(invalidConfigInput), - failure( - branchError(invalidConfigInput, [ + fefe.failure( + fefe.branchError(invalidConfigInput, [ { key: 'gcloudCredentials', - error: leafError( + error: fefe.leafError( { key: 'secret', foo: 'bar' }, 'Properties not allowed: foo.' ), diff --git a/src/index.ts b/src/index.ts index ce8d20f..8b8b431 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,7 @@ export * from './errors' - +export * from './result' export * from './validate' + export * from './array' export * from './boolean' export * from './date'