Skip to content

Commit

Permalink
Refactor sanitizers (#18)
Browse files Browse the repository at this point in the history
* Rename Either to Result

* Update the readme to fully reflect the changes

* Add cast

* Fix linter
  • Loading branch information
sz-piotr authored Aug 8, 2019
1 parent 7989ffc commit 565695b
Show file tree
Hide file tree
Showing 30 changed files with 213 additions and 166 deletions.
100 changes: 57 additions & 43 deletions sanitizers/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ yarn add @restless/sanitizers

## Api

- [`cast`](#cast)
- [`asString`](#asstring)
- [`asNumber`](#assumber)
- [`asBoolean`](#asboolean)
Expand All @@ -34,35 +35,44 @@ yarn add @restless/sanitizers
- [`asAnyOf`](#asanyof)
- [`withErrorMessage`](#witherrormessage)

### `cast`

Accepts a value and applies a sanitizer to it resulting in returning the sanitized value or throwing a TypeError.

```javascript
cast('123', asNumber) // 123
cast('foo', asNumber) // TypeError
```

### `asString`

Accepts any value that is a string. Returns a string.

```javascript
asString('asd') // RIGHT 'asd'
asString(123) // LEFT 'expected: string'
asString('asd', 'path') // Result.ok('asd')
asString(123, 'path') // Result.error([{expected: 'string', path: 'path'}])
```

### `asNumber`

Accepts any value that is a number or a string that represents a number. Returns a number.

```javascript
asNumber(123) // RIGHT 123
asNumber('0.2') // RIGHT 0.2
asNumber('boo') // LEFT 'expected: number'
asNumber({}) // LEFT 'expected: number'
asNumber(123, 'path') // Result.ok(123)
asNumber('0.2', 'path') // Result.ok(0.2)
asNumber('boo', 'path') // Result.error([{expected: 'number', path: 'path'}])
asNumber({}, 'path') // Result.error([{expected: 'number', path: 'path'}])
```

### `asBoolean`

Accepts any value that is a number or a string that represents a boolean (`"true"` or `"false"`). Returns a number.

```javascript
asBoolean(true) // RIGHT true
asBoolean('false') // RIGHT false
asBoolean('boo') // LEFT 'expected: boolean'
asBoolean(123) // LEFT 'expected: boolean'
asBoolean(true, 'path') // Result.ok(true)
asBoolean('false', 'path') // Result.ok(false)
asBoolean('boo', 'path') // Result.error([{expected: 'boolean', path: 'path'}])
asBoolean(123, 'path') // Result.error([{expected: 'boolean', path: 'path'}])
```

### `asMatching`
Expand All @@ -72,9 +82,9 @@ This higher-order sanitizer accepts values that are strings matching the regex p
```javascript
const sanitizer = asMatching(/aaa/, 'custom message')

sanitizer('aaa') // RIGHT 'aaa'
sanitizer(123) // LEFT 'expected: custom message'
sanitizer('b') // LEFT 'expected: custom message'
sanitizer('aaa', 'path') // Result.ok('aaa')
sanitizer(123, 'path') // Result.error([{expected: 'custom message', path: 'path'}])
sanitizer('b', 'path') // Result.error([{expected: 'custom message', path: 'path'}])
```

### `asObject`
Expand All @@ -84,10 +94,14 @@ This higher-order sanitizer requires a schema in the form of an object. Values o
```javascript
const sanitizer = asObject({ foo: asNumber, bar: asString })

sanitizer({ foo: 1, bar: 'a' }) // RIGHT { foo: 1, bar: 'a' }
sanitizer(123) // LEFT 'expected: object'
sanitizer({}) // LEFT ['(.foo) expected: number', '(.bar) expected: string']
sanitizer({ foo: true, bar: 'a' ) // LEFT '(.foo) expected: number'
sanitizer({ foo: 1, bar: 'a' }, 'path') // Result.ok({ foo: 1, bar: 'a' })
sanitizer(123, 'path') // Result.error([{expected: 'object', path: 'path'}])
sanitizer({}, 'path')
// Result.error([
// {expected: 'number', path: 'path.foo'},
// {expected: 'string', path: 'path.bar'}
// ])
sanitizer({ foo: true, bar: 'a' , 'path') // Result.error([{expected: 'number', path: 'path.foo'}])
```
### `asArray`
Expand All @@ -97,9 +111,9 @@ This higher-order sanitizer accepts any value that is an array of items that are
```javascript
const sanitizer = asArray(asNumber)

sanitizer([123, '45']) // RIGHT [123, 45]
sanitizer(123) // LEFT 'expected: array'
sanitizer([123, 'foo']) // LEFT '([0]) expected: number'
sanitizer([123, '45'], 'path') // Result.ok([123, 45])
sanitizer(123, 'path') // Result.error([{expected: 'array', path: 'path'}])
sanitizer([123, 'foo'], 'path') // Result.error([{expected: 'number', path: 'path[0]'}])
```
### `asOptional`
Expand All @@ -109,10 +123,10 @@ This higher-order sanitizer accepts undefined or null or any value that is sanit
```javascript
const sanitizer = asOptional(asString)

sanitizer('abcdef') // RIGHT 'abcdef'
sanitizer(null) // RIGHT undefined
sanitizer(undefined) // RIGHT undefined
sanitizer(123) // LEFT 'expected: string'
sanitizer('abcdef', 'path') // Result.ok('abcdef')
sanitizer(null, 'path') // Result.ok(undefined)
sanitizer(undefined, 'path') // Result.ok(undefined)
sanitizer(123, 'path') // Result.error([{expected: 'string', path: 'path'}])
```
### `asChecked`
Expand All @@ -122,13 +136,13 @@ This higher-order sanitizer accepts any value that is sanitized through the sani
```javascript
const sanitizer = asChecked(asString, x => x.length > 3)

sanitizer('abcdef') // RIGHT 'abcdef'
sanitizer(123) // LEFT 'expected: string'
sanitizer('a') // LEFT 'expected: custom logic'
sanitizer('abcdef', 'path') // Result.ok('abcdef')
sanitizer(123, 'path') // Result.error([{expected: 'string', path: 'path'}])
sanitizer('a', 'path') // Result.error([{expected: 'custom logic', path: 'path'}])
```
```javascript
const sanitizer = asChecked(asString, x => x.length > 3, 'string longer than 3')
sanitizer('a') // LEFT 'expected: string longer than 3'
sanitizer('a', 'path') // Result.error([{expected: 'string longer than 3', path: 'path'}])
```
### `asMapped`
Expand All @@ -138,24 +152,24 @@ This higher-order sanitizer accepts any value that is sanitized through the sani
```javascript
const sanitizer = asMapped(asNumber, x => x > 1)

sanitizer(123) // RIGHT true
sanitizer(0) // RIGHT false
sanitizer('a') // LEFT 'expected: number'
sanitizer(123, 'path') // Result.ok(true)
sanitizer(0, 'path') // Result.ok(false)
sanitizer('a', 'path') // Result.error([{expected: 'number', path: 'path'}])
```
### `asFlatMapped`
This higher-order sanitizer accepts any value that is sanitized through the sanitizer passed as argument. That value is then transformed using the provided function that can return either a new value or an error.
This higher-order sanitizer accepts any value that is sanitized through the sanitizer passed as argument. That value is then transformed using the provided function that can return Result a new value or an error.
```javascript
const sanitizer = asMapped(asNumber, (value, path) => x > 1
? Either.right(value)
: Either.left([{ path, expected: 'number > 1' }])
? Result.ok(value)
: Result.error([{ path, expected: 'number > 1' }])
)

sanitizer(123) // RIGHT 123
sanitizer(0) // LEFT 'expected: number > 1'
sanitizer('a') // LEFT 'expected: number'
sanitizer(123, 'path') // Result.ok(123)
sanitizer(0, 'path') // Result.error([{expected: 'number > 1', path: 'path'}])
sanitizer('a', 'path') // Result.error([{expected: 'number', path: 'path'}])
```
### `asAnyOf`
Expand All @@ -165,10 +179,10 @@ This higher-order sanitizer accepts any value that is successfully sanitized thr
```javascript
const sanitizer = asAnyOf([asNumber, asString], 'a string or a number')

sanitizer('abcdef') // RIGHT 'abcdef'
sanitizer('123') // RIGHT 123
sanitizer(123) // RIGHT 123
sanitizer({}) // LEFT 'expected: a string or a number'
sanitizer('abcdef', 'path') // Result.ok('abcdef')
sanitizer('123', 'path') // Result.ok(123)
sanitizer(123, 'path') // Result.ok(123)
sanitizer({}, 'path') // Result.error([{expected: 'a string or a number', path: 'path'}])
```
### `withErrorMessage`
Expand All @@ -178,6 +192,6 @@ This higher-order sanitizer will act just like the sanitizer passed as an argume
```javascript
const sanitizer = withErrorMessage(asString, 'bla bla')

sanitizer('abcdef') // RIGHT 'abcdef'
sanitizer(123) // LEFT 'expected: bla bla'
sanitizer('abcdef', 'path') // Result.ok('abcdef')
sanitizer(123, 'path') // Result.error([{expected: 'bla bla', path: 'path'}])
```
2 changes: 1 addition & 1 deletion sanitizers/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@restless/sanitizers",
"version": "0.1.0",
"version": "0.2.0",
"author": "Piotr Szlachciak <[email protected]>",
"description": "Data sanitization in a functional way",
"license": "Unlicense",
Expand Down
8 changes: 4 additions & 4 deletions sanitizers/src/asAnyOf.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Either, Sanitizer, Schema } from './model'
import { Result, Sanitizer, Schema } from './model'

type NonEmptyArray<T> = [T, ...T[]]

Expand All @@ -17,13 +17,13 @@ export const asAnyOf: AsAnyOf = (
): Sanitizer<any> =>
(value, path) => {
if (sanitizers.length === 0) {
return Either.right(value)
return Result.ok(value)
}
for (const sanitizer of sanitizers) {
const result = sanitizer(value, path)
if (Either.isRight(result)) {
if (Result.isOk(result)) {
return result
}
}
return Either.left([{ path, expected }])
return Result.error([{ path, expected }])
}
14 changes: 7 additions & 7 deletions sanitizers/src/asArray.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,21 @@
import { Either, Sanitizer, SanitizerFailure } from './model'
import { Result, Sanitizer, SanitizerFailure } from './model'

export const asArray = <T> (sanitizer: Sanitizer<T>): Sanitizer<T[]> =>
(value, path) => {
if (!Array.isArray(value)) {
return Either.left([{ path, expected: 'array' }])
return Result.error([{ path, expected: 'array' }])
}
const results: T[] = []
const errors: SanitizerFailure[] = []
for (let i = 0; i < value.length; i++) {
const result = sanitizer(value[i], `${path}[${i}]`)
if (Either.isRight(result)) {
results.push(result.right)
if (Result.isOk(result)) {
results.push(result.ok)
} else {
errors.push(...result.left)
errors.push(...result.error)
}
}
return errors.length > 0
? Either.left(errors)
: Either.right(results)
? Result.error(errors)
: Result.ok(results)
}
8 changes: 4 additions & 4 deletions sanitizers/src/asBoolean.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Either, Sanitizer } from './model'
import { Result, Sanitizer } from './model'

const BOOLEAN_REGEX = /^(true|false)$/

Expand All @@ -10,9 +10,9 @@ const isBooleanString = (value: unknown): value is string =>

export const asBoolean: Sanitizer<boolean> = (value, path) => {
if (isBoolean(value)) {
return Either.right(value)
return Result.ok(value)
} else if (isBooleanString(value)) {
return Either.right(value === 'true')
return Result.ok(value === 'true')
}
return Either.left([{ path, expected: 'boolean' }])
return Result.error([{ path, expected: 'boolean' }])
}
6 changes: 3 additions & 3 deletions sanitizers/src/asChecked.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import { Either, Sanitizer } from './model'
import { Result, Sanitizer } from './model'

export const asChecked = <T> (
sanitizer: Sanitizer<T>,
predicate: (value: T) => boolean,
expected: string = 'custom logic'
): Sanitizer<T> => (value, path) => {
const result = sanitizer(value, path)
if (Either.isRight(result) && !predicate(result.right)) {
return Either.left([{ path, expected }])
if (Result.isOk(result) && !predicate(result.ok)) {
return Result.error([{ path, expected }])
}
return result
}
8 changes: 4 additions & 4 deletions sanitizers/src/asFlatMapped.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { Either, Sanitizer, SanitizerFailure } from './model'
import { Result, Sanitizer, SanitizerFailure } from './model'

export const asFlatMapped = <T, U> (
sanitizer: Sanitizer<T>,
flatMapFn: (value: T, path: string) => Either<SanitizerFailure[], U>
flatMapFn: (value: T, path: string) => Result<SanitizerFailure[], U>
): Sanitizer<U> => (value, path) => {
const result = sanitizer(value, path)
if (Either.isRight(result)) {
return flatMapFn(result.right, path)
if (Result.isOk(result)) {
return flatMapFn(result.ok, path)
}
return result
}
6 changes: 3 additions & 3 deletions sanitizers/src/asMapped.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { Either, Sanitizer } from './model'
import { Result, Sanitizer } from './model'

export const asMapped = <T, U> (
sanitizer: Sanitizer<T>,
mapFn: (value: T) => U
): Sanitizer<U> => (value, path) => {
const result = sanitizer(value, path)
if (Either.isRight(result)) {
return Either.right(mapFn(result.right))
if (Result.isOk(result)) {
return Result.ok(mapFn(result.ok))
}
return result
}
6 changes: 3 additions & 3 deletions sanitizers/src/asMatching.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Either, Sanitizer } from './model'
import { Result, Sanitizer } from './model'

export const asMatching = (re: RegExp, message?: string): Sanitizer<string> =>
(value, path) => typeof value === 'string' && re.test(value)
? Either.right(value)
: Either.left([{ path, expected: message || `string matching ${re}` }])
? Result.ok(value)
: Result.error([{ path, expected: message || `string matching ${re}` }])
8 changes: 4 additions & 4 deletions sanitizers/src/asNumber.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Either, Sanitizer } from './model'
import { Result, Sanitizer } from './model'

const NUMBER_REGEX = /^-?\d*(\.\d+)?$/

Expand All @@ -10,9 +10,9 @@ const isNumberString = (value: unknown): value is string =>

export const asNumber: Sanitizer<number> = (value, path) => {
if (isNumber(value)) {
return Either.right(value)
return Result.ok(value)
} else if (isNumberString(value)) {
return Either.right(+value)
return Result.ok(+value)
}
return Either.left([{ path, expected: 'number' }])
return Result.error([{ path, expected: 'number' }])
}
14 changes: 7 additions & 7 deletions sanitizers/src/asObject.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,23 @@
import { Either, Sanitizer, SanitizerFailure, Schema } from './model'
import { Result, Sanitizer, SanitizerFailure, Schema } from './model'

export const asObject = <T extends object> (schema: Schema<T>): Sanitizer<T> =>
(value, path) => {
if (typeof value !== 'object' || value === null) {
return Either.left([{ path, expected: 'object' }])
return Result.error([{ path, expected: 'object' }])
}
const results: T = {} as any
const errors: SanitizerFailure[] = []
for (const key in schema) {
if (Object.hasOwnProperty.call(schema, key)) {
const result = schema[key]((value as T)[key], `${path}.${key}`)
if (Either.isRight(result)) {
results[key] = result.right
if (Result.isOk(result)) {
results[key] = result.ok
} else {
errors.push(...result.left)
errors.push(...result.error)
}
}
}
return errors.length > 0
? Either.left(errors)
: Either.right(results)
? Result.error(errors)
: Result.ok(results)
}
4 changes: 2 additions & 2 deletions sanitizers/src/asOptional.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Either, Sanitizer } from './model'
import { Result, Sanitizer } from './model'

export const asOptional = <T> (sanitizer: Sanitizer<T>): Sanitizer<T | undefined> =>
(value, path) => value == null
? Either.right(undefined)
? Result.ok(undefined)
: sanitizer(value, path)
Loading

0 comments on commit 565695b

Please sign in to comment.