Skip to content

Commit

Permalink
change coercions to include more type safety (ianstormtaylor#551)
Browse files Browse the repository at this point in the history
* change coercions to include more type safety

* add trimmed

* update docs

* fix linting
  • Loading branch information
ianstormtaylor authored Nov 24, 2020
1 parent f764272 commit 1f3164e
Show file tree
Hide file tree
Showing 17 changed files with 472 additions and 353 deletions.
1 change: 0 additions & 1 deletion .eslintrc
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,6 @@
"no-tabs": "error",
"no-this-before-super": "error",
"no-throw-literal": "error",
"no-undef": "error",
"no-unneeded-ternary": "error",
"no-unreachable": "error",
"no-unsafe-finally": "error",
Expand Down
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
.DS_Store
.vscode
lib/
node_modules/
npm-debug.log
package-lock.json
site/
tmp/
umd/
yarn-error.log
yarn-error.log
1 change: 1 addition & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
.vscode/
lib/
node_modules/
site/
Expand Down
14 changes: 6 additions & 8 deletions docs/guides/03-coercing-data.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,24 +50,22 @@ const User = defaulted(

We've already covered default values, but sometimes you'll need to create coercions that aren't just defaulted `undefined` values, but instead transforming the input data from one format to another.

For example, maybe you want to ensure that any string is trimmed before passing it into the validator. To do that you can define a custom coercion:
For example, maybe you want to ensure that a number is parsed from a string before passing it into the validator. To do that you can define a custom coercion:

```ts
import { coerce } from 'superstruct'
import { coerce, number, string, create } from 'superstruct'

const TrimmedString = coerce(string(), (value) => {
return typeof value === 'string' ? value.trim() : value
})
const MyNumber = coerce(number(), string(), (value) => parseFloat(value))
```

Now instead of using `assert()` or `is()` you can use `create()` to apply your custom coercion logic:

```ts
import { create } from 'superstruct'

const data = ' a wEird str1ng '
const output = create(data, TrimmedString)
// "a wEird str1ng"
const data = '3.14'
const output = create(data, MyNumber)
// 3.14
```

If the input data had been invalid or unable to be coerced an error would have been thrown instead.
22 changes: 16 additions & 6 deletions docs/reference/coercions.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,18 +30,28 @@ masked(

`masked` augments an object struct to strip any unknown properties from the input when coercing it.

### `trimmed`

```ts
trimmed(string())
```

`trimmed` arguments a struct to ensure that any string input values are trimmed.

### Custom Coercions

You can also define your own custom coercions that are specific to your application's requirements, like so:

```ts
import { coerce, string } from 'superstruct'
import { coerce, number, string, create } from 'superstruct'

const PositiveInteger = coerce(string(), (value) => {
return typeof value === 'string' ? value.trim() : value
})
const MyNumber = coerce(number(), string(), (value) => parseFloat(value))

const a = create(42, MyNumber) // 42
const b = create('42', MyNumber) // 42
const c = create(false, MyNumber) // error thrown!
```

This allows you to customize how lenient you want to be in accepting data with your structs.
The second argument to `coerce` is a struct narrowing the types of input values you want to try coercion. In the example above, the coercion functionn will only ever be called when the input is a string—booleans would ignore coercion and fail normally.

> 🤖 Note that the `value` argument passed to coercion handlers is of type `unknown`! This is because it has yet to be validated, so it could still be anything. Make sure your coercion functions guard against unknown types.
> 🤖 If you want to run coercion for any type of input, use the `unknown()` struct to run it in all cases.
12 changes: 6 additions & 6 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,14 +26,14 @@
"@types/lodash": "^4.14.144",
"@types/mocha": "^8.0.3",
"@types/node": "^14.0.6",
"@typescript-eslint/eslint-plugin": "^2.3.3",
"@typescript-eslint/parser": "^2.3.3",
"@typescript-eslint/eslint-plugin": "^4.8.2",
"@typescript-eslint/parser": "^4.8.2",
"babel-eslint": "^10.0.3",
"babel-plugin-dev-expression": "^0.2.2",
"eslint": "^6.5.1",
"eslint-config-prettier": "^6.4.0",
"eslint-plugin-import": "^2.18.2",
"eslint-plugin-prettier": "^3.1.1",
"eslint": "^7.14.0",
"eslint-config-prettier": "^6.15.0",
"eslint-plugin-import": "^2.22.1",
"eslint-plugin-prettier": "^3.1.4",
"is-email": "^1.0.0",
"is-url": "^1.2.4",
"is-uuid": "^1.0.2",
Expand Down
2 changes: 1 addition & 1 deletion src/error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export class StructError extends TypeError {
path: Array<number | string>
branch: Array<any>
failures: () => Array<Failure>;
[key: string]: any
[x: string]: any

constructor(failure: Failure, moreFailures: IterableIterator<Failure>) {
const {
Expand Down
33 changes: 25 additions & 8 deletions src/structs/coercions.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Struct, mask } from '../struct'
import { ObjectSchema, ObjectType, isPlainObject } from '../utils'
import { Struct, is } from '../struct'
import { isPlainObject } from '../utils'
import { string, unknown } from './types'

/**
* Augment a `Struct` to add an additional coercion step to its input.
Expand All @@ -12,23 +13,28 @@ import { ObjectSchema, ObjectType, isPlainObject } from '../utils'
* take effect! Using simply `assert()` or `is()` will not use coercion.
*/

export function coerce<T, S>(
export function coerce<T, S, C>(
struct: Struct<T, S>,
coercer: Struct<T, S>['coercer']
condition: Struct<C, any>,
coercer: (value: C) => any
): Struct<T, S> {
const fn = struct.coercer
return new Struct({
...struct,
coercer: (value) => {
return fn(coercer(value))
if (is(value, condition)) {
return fn(coercer(value))
} else {
return fn(value)
}
},
})
}

/**
* Augment a struct to replace `undefined` values with a default.
*
* Note: You must use `coerce(value, Struct)` on the value to have the coercion
* Note: You must use `create(value, Struct)` on the value to have the coercion
* take effect! Using simply `assert()` or `is()` will not use coercion.
*/

Expand All @@ -40,7 +46,7 @@ export function defaulted<T, S>(
} = {}
): Struct<T, S> {
const { strict } = options
return coerce(S, (x) => {
return coerce(S, unknown(), (x) => {
const f = typeof fallback === 'function' ? fallback() : fallback

if (x === undefined) {
Expand Down Expand Up @@ -75,7 +81,7 @@ export function defaulted<T, S>(
*/

export function masked<T, S>(struct: Struct<T, S>): Struct<T, S> {
return coerce(struct, (x) => {
return coerce(struct, unknown(), (x) => {
if (
typeof struct.schema !== 'object' ||
struct.schema == null ||
Expand All @@ -96,3 +102,14 @@ export function masked<T, S>(struct: Struct<T, S>): Struct<T, S> {
}
})
}

/**
* Augment a struct to trim string inputs.
*
* Note: You must use `create(value, Struct)` on the value to have the coercion
* take effect! Using simply `assert()` or `is()` will not use coercion.
*/

export function trimmed<T, S>(struct: Struct<T, S>): Struct<T, S> {
return coerce(struct, string(), (x) => x.trim())
}
20 changes: 10 additions & 10 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,15 +56,15 @@ export function* toFailures<T, S>(
* Check if a type is a tuple.
*/

export type IsTuple<T> = T extends [infer A]
export type IsTuple<T> = T extends [any]
? T
: T extends [infer A, infer B]
: T extends [any, any]
? T
: T extends [infer A, infer B, infer C]
: T extends [any, any, any]
? T
: T extends [infer A, infer B, infer C, infer D]
: T extends [any, any, any, any]
? T
: T extends [infer A, infer B, infer C, infer D, infer E]
: T extends [any, any, any, any, any]
? T
: never

Expand Down Expand Up @@ -169,19 +169,19 @@ export type StructSchema<T> = [T] extends [string]
| Error
| RegExp
? null
: T extends Map<infer K, infer V>
: T extends Map<any, any>
? null
: T extends WeakMap<infer K, infer V>
: T extends WeakMap<any, any>
? null
: T extends Set<infer E>
: T extends Set<any>
? null
: T extends WeakSet<infer E>
: T extends WeakSet<any>
? null
: T extends Array<infer E>
? T extends IsTuple<T>
? null
: Struct<E>
: T extends Promise<infer V>
: T extends Promise<any>
? null
: T extends object
? T extends IsRecord<T>
Expand Down
6 changes: 3 additions & 3 deletions test/typings/coerce.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { assert, coerce, string } from '../..'
import { assert, coerce, string, number } from '../..'
import { test } from '..'

test<string>((x) => {
test<number>((x) => {
assert(
x,
coerce(string(), (x) => x)
coerce(number(), string(), (x) => parseFloat(x))
)
return x
})
7 changes: 7 additions & 0 deletions test/typings/trimmed.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { assert, string, trimmed } from '../..'
import { test } from '..'

test<string>((x) => {
assert(x, trimmed(string()))
return x
})
6 changes: 4 additions & 2 deletions test/validation/coerce/changed.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { string, coerce } from '../../..'
import { string, unknown, coerce } from '../../..'

export const Struct = coerce(string(), (x) => (x == null ? 'unknown' : x))
export const Struct = coerce(string(), unknown(), (x) =>
x == null ? 'unknown' : x
)

export const data = null

Expand Down
17 changes: 17 additions & 0 deletions test/validation/coerce/condition-not-met.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { string, number, coerce } from '../../..'

export const Struct = coerce(string(), number(), (x) => 'known')

export const data = false

export const failures = [
{
value: false,
type: 'string',
refinement: undefined,
path: [],
branch: [data],
},
]

export const create = true
6 changes: 4 additions & 2 deletions test/validation/coerce/unchanged.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { string, coerce } from '../../..'
import { string, unknown, coerce } from '../../..'

export const Struct = coerce(string(), (x) => (x == null ? 'unknown' : x))
export const Struct = coerce(string(), unknown(), (x) =>
x == null ? 'unknown' : x
)

export const data = 'known'

Expand Down
17 changes: 17 additions & 0 deletions test/validation/trimmed/invalid.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { string, trimmed } from '../../..'

export const Struct = trimmed(string())

export const data = false

export const failures = [
{
value: false,
type: 'string',
refinement: undefined,
path: [],
branch: [data],
},
]

export const create = true
9 changes: 9 additions & 0 deletions test/validation/trimmed/valid.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { string, trimmed } from '../../..'

export const Struct = trimmed(string())

export const data = ' valid '

export const output = 'valid'

export const create = true
Loading

0 comments on commit 1f3164e

Please sign in to comment.