Skip to content

Commit 1f3164e

Browse files
change coercions to include more type safety (ianstormtaylor#551)
* change coercions to include more type safety * add trimmed * update docs * fix linting
1 parent f764272 commit 1f3164e

17 files changed

+472
-353
lines changed

.eslintrc

-1
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,6 @@
7878
"no-tabs": "error",
7979
"no-this-before-super": "error",
8080
"no-throw-literal": "error",
81-
"no-undef": "error",
8281
"no-unneeded-ternary": "error",
8382
"no-unreachable": "error",
8483
"no-unsafe-finally": "error",

.gitignore

+2-1
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
.DS_Store
2+
.vscode
23
lib/
34
node_modules/
45
npm-debug.log
56
package-lock.json
67
site/
78
tmp/
89
umd/
9-
yarn-error.log
10+
yarn-error.log

.prettierignore

+1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
.vscode/
12
lib/
23
node_modules/
34
site/

docs/guides/03-coercing-data.md

+6-8
Original file line numberDiff line numberDiff line change
@@ -50,24 +50,22 @@ const User = defaulted(
5050

5151
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.
5252

53-
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:
53+
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:
5454

5555
```ts
56-
import { coerce } from 'superstruct'
56+
import { coerce, number, string, create } from 'superstruct'
5757

58-
const TrimmedString = coerce(string(), (value) => {
59-
return typeof value === 'string' ? value.trim() : value
60-
})
58+
const MyNumber = coerce(number(), string(), (value) => parseFloat(value))
6159
```
6260

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

6563
```ts
6664
import { create } from 'superstruct'
6765

68-
const data = ' a wEird str1ng '
69-
const output = create(data, TrimmedString)
70-
// "a wEird str1ng"
66+
const data = '3.14'
67+
const output = create(data, MyNumber)
68+
// 3.14
7169
```
7270

7371
If the input data had been invalid or unable to be coerced an error would have been thrown instead.

docs/reference/coercions.md

+16-6
Original file line numberDiff line numberDiff line change
@@ -30,18 +30,28 @@ masked(
3030

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

33+
### `trimmed`
34+
35+
```ts
36+
trimmed(string())
37+
```
38+
39+
`trimmed` arguments a struct to ensure that any string input values are trimmed.
40+
3341
### Custom Coercions
3442

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

3745
```ts
38-
import { coerce, string } from 'superstruct'
46+
import { coerce, number, string, create } from 'superstruct'
3947

40-
const PositiveInteger = coerce(string(), (value) => {
41-
return typeof value === 'string' ? value.trim() : value
42-
})
48+
const MyNumber = coerce(number(), string(), (value) => parseFloat(value))
49+
50+
const a = create(42, MyNumber) // 42
51+
const b = create('42', MyNumber) // 42
52+
const c = create(false, MyNumber) // error thrown!
4353
```
4454

45-
This allows you to customize how lenient you want to be in accepting data with your structs.
55+
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.
4656

47-
> 🤖 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.
57+
> 🤖 If you want to run coercion for any type of input, use the `unknown()` struct to run it in all cases.

package.json

+6-6
Original file line numberDiff line numberDiff line change
@@ -26,14 +26,14 @@
2626
"@types/lodash": "^4.14.144",
2727
"@types/mocha": "^8.0.3",
2828
"@types/node": "^14.0.6",
29-
"@typescript-eslint/eslint-plugin": "^2.3.3",
30-
"@typescript-eslint/parser": "^2.3.3",
29+
"@typescript-eslint/eslint-plugin": "^4.8.2",
30+
"@typescript-eslint/parser": "^4.8.2",
3131
"babel-eslint": "^10.0.3",
3232
"babel-plugin-dev-expression": "^0.2.2",
33-
"eslint": "^6.5.1",
34-
"eslint-config-prettier": "^6.4.0",
35-
"eslint-plugin-import": "^2.18.2",
36-
"eslint-plugin-prettier": "^3.1.1",
33+
"eslint": "^7.14.0",
34+
"eslint-config-prettier": "^6.15.0",
35+
"eslint-plugin-import": "^2.22.1",
36+
"eslint-plugin-prettier": "^3.1.4",
3737
"is-email": "^1.0.0",
3838
"is-url": "^1.2.4",
3939
"is-uuid": "^1.0.2",

src/error.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ export class StructError extends TypeError {
2929
path: Array<number | string>
3030
branch: Array<any>
3131
failures: () => Array<Failure>;
32-
[key: string]: any
32+
[x: string]: any
3333

3434
constructor(failure: Failure, moreFailures: IterableIterator<Failure>) {
3535
const {

src/structs/coercions.ts

+25-8
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
import { Struct, mask } from '../struct'
2-
import { ObjectSchema, ObjectType, isPlainObject } from '../utils'
1+
import { Struct, is } from '../struct'
2+
import { isPlainObject } from '../utils'
3+
import { string, unknown } from './types'
34

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

15-
export function coerce<T, S>(
16+
export function coerce<T, S, C>(
1617
struct: Struct<T, S>,
17-
coercer: Struct<T, S>['coercer']
18+
condition: Struct<C, any>,
19+
coercer: (value: C) => any
1820
): Struct<T, S> {
1921
const fn = struct.coercer
2022
return new Struct({
2123
...struct,
2224
coercer: (value) => {
23-
return fn(coercer(value))
25+
if (is(value, condition)) {
26+
return fn(coercer(value))
27+
} else {
28+
return fn(value)
29+
}
2430
},
2531
})
2632
}
2733

2834
/**
2935
* Augment a struct to replace `undefined` values with a default.
3036
*
31-
* Note: You must use `coerce(value, Struct)` on the value to have the coercion
37+
* Note: You must use `create(value, Struct)` on the value to have the coercion
3238
* take effect! Using simply `assert()` or `is()` will not use coercion.
3339
*/
3440

@@ -40,7 +46,7 @@ export function defaulted<T, S>(
4046
} = {}
4147
): Struct<T, S> {
4248
const { strict } = options
43-
return coerce(S, (x) => {
49+
return coerce(S, unknown(), (x) => {
4450
const f = typeof fallback === 'function' ? fallback() : fallback
4551

4652
if (x === undefined) {
@@ -75,7 +81,7 @@ export function defaulted<T, S>(
7581
*/
7682

7783
export function masked<T, S>(struct: Struct<T, S>): Struct<T, S> {
78-
return coerce(struct, (x) => {
84+
return coerce(struct, unknown(), (x) => {
7985
if (
8086
typeof struct.schema !== 'object' ||
8187
struct.schema == null ||
@@ -96,3 +102,14 @@ export function masked<T, S>(struct: Struct<T, S>): Struct<T, S> {
96102
}
97103
})
98104
}
105+
106+
/**
107+
* Augment a struct to trim string inputs.
108+
*
109+
* Note: You must use `create(value, Struct)` on the value to have the coercion
110+
* take effect! Using simply `assert()` or `is()` will not use coercion.
111+
*/
112+
113+
export function trimmed<T, S>(struct: Struct<T, S>): Struct<T, S> {
114+
return coerce(struct, string(), (x) => x.trim())
115+
}

src/utils.ts

+10-10
Original file line numberDiff line numberDiff line change
@@ -56,15 +56,15 @@ export function* toFailures<T, S>(
5656
* Check if a type is a tuple.
5757
*/
5858

59-
export type IsTuple<T> = T extends [infer A]
59+
export type IsTuple<T> = T extends [any]
6060
? T
61-
: T extends [infer A, infer B]
61+
: T extends [any, any]
6262
? T
63-
: T extends [infer A, infer B, infer C]
63+
: T extends [any, any, any]
6464
? T
65-
: T extends [infer A, infer B, infer C, infer D]
65+
: T extends [any, any, any, any]
6666
? T
67-
: T extends [infer A, infer B, infer C, infer D, infer E]
67+
: T extends [any, any, any, any, any]
6868
? T
6969
: never
7070

@@ -169,19 +169,19 @@ export type StructSchema<T> = [T] extends [string]
169169
| Error
170170
| RegExp
171171
? null
172-
: T extends Map<infer K, infer V>
172+
: T extends Map<any, any>
173173
? null
174-
: T extends WeakMap<infer K, infer V>
174+
: T extends WeakMap<any, any>
175175
? null
176-
: T extends Set<infer E>
176+
: T extends Set<any>
177177
? null
178-
: T extends WeakSet<infer E>
178+
: T extends WeakSet<any>
179179
? null
180180
: T extends Array<infer E>
181181
? T extends IsTuple<T>
182182
? null
183183
: Struct<E>
184-
: T extends Promise<infer V>
184+
: T extends Promise<any>
185185
? null
186186
: T extends object
187187
? T extends IsRecord<T>

test/typings/coerce.ts

+3-3
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
1-
import { assert, coerce, string } from '../..'
1+
import { assert, coerce, string, number } from '../..'
22
import { test } from '..'
33

4-
test<string>((x) => {
4+
test<number>((x) => {
55
assert(
66
x,
7-
coerce(string(), (x) => x)
7+
coerce(number(), string(), (x) => parseFloat(x))
88
)
99
return x
1010
})

test/typings/trimmed.ts

+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { assert, string, trimmed } from '../..'
2+
import { test } from '..'
3+
4+
test<string>((x) => {
5+
assert(x, trimmed(string()))
6+
return x
7+
})

test/validation/coerce/changed.ts

+4-2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1-
import { string, coerce } from '../../..'
1+
import { string, unknown, coerce } from '../../..'
22

3-
export const Struct = coerce(string(), (x) => (x == null ? 'unknown' : x))
3+
export const Struct = coerce(string(), unknown(), (x) =>
4+
x == null ? 'unknown' : x
5+
)
46

57
export const data = null
68

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { string, number, coerce } from '../../..'
2+
3+
export const Struct = coerce(string(), number(), (x) => 'known')
4+
5+
export const data = false
6+
7+
export const failures = [
8+
{
9+
value: false,
10+
type: 'string',
11+
refinement: undefined,
12+
path: [],
13+
branch: [data],
14+
},
15+
]
16+
17+
export const create = true

test/validation/coerce/unchanged.ts

+4-2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1-
import { string, coerce } from '../../..'
1+
import { string, unknown, coerce } from '../../..'
22

3-
export const Struct = coerce(string(), (x) => (x == null ? 'unknown' : x))
3+
export const Struct = coerce(string(), unknown(), (x) =>
4+
x == null ? 'unknown' : x
5+
)
46

57
export const data = 'known'
68

test/validation/trimmed/invalid.ts

+17
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { string, trimmed } from '../../..'
2+
3+
export const Struct = trimmed(string())
4+
5+
export const data = false
6+
7+
export const failures = [
8+
{
9+
value: false,
10+
type: 'string',
11+
refinement: undefined,
12+
path: [],
13+
branch: [data],
14+
},
15+
]
16+
17+
export const create = true

test/validation/trimmed/valid.ts

+9
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { string, trimmed } from '../../..'
2+
3+
export const Struct = trimmed(string())
4+
5+
export const data = ' valid '
6+
7+
export const output = 'valid'
8+
9+
export const create = true

0 commit comments

Comments
 (0)