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