Skip to content

Commit 8aa8730

Browse files
committed
feat: add alias and ref for recursive types
`t.constrain` has been replaced with `t.alias`. See the README for more details. BREAKING CHANGE: remove t.constrain (replaced by t.alias)
1 parent 0b665b8 commit 8aa8730

24 files changed

+427
-132
lines changed

README.md

Lines changed: 121 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -26,23 +26,31 @@ The validation errors are detailed. Adapted from the brilliant work in `flow-run
2626
- [`t.symbol()`](#tsymbol)
2727
- [`t.symbol(MySymbol)`](#tsymbolmysymbol)
2828
- [`t.null()` / `t.nullLiteral()`](#tnull--tnullliteral)
29+
- [`t.nullOr(t.string())`](#tnullortstring)
2930
- [`t.undefined()` / `t.undefinedLiteral()`](#tundefined--tundefinedliteral)
3031
- [`t.nullish()`](#tnullish)
31-
- [`t.nullOr(t.string())`](#tnullortstring)
32+
- [`t.nullishOr(t.string())`](#tnullishortstring)
3233
- [`t.array(t.number())`](#tarraytnumber)
3334
- [`t.simpleObject({ foo: t.string() })`](#tsimpleobject-foo-tstring-)
3435
- [`t.object`](#tobject)
3536
- [`t.record(t.string(), t.number())`](#trecordtstring-tnumber)
3637
- [`t.tuple(t.string(), t.number())`](#ttupletstring-tnumber)
3738
- [`t.intersection(A, B)`](#tintersectiona-b)
3839
- [`t.union(t.string(), t.number())`](#tuniontstring-tnumber)
39-
- [`t.constrain(type, ...constraints)`](#tconstraintype-constraints)
40-
- [`t.Type`](#ttype)
40+
- [`t.alias(name, type)`](#taliasname-type)
41+
- [`t.ref(() => typeAlias)`](#tref--typealias)
42+
- [`t.Type<T>`](#ttype)
4143
- [`accepts(input: any): boolean`](#acceptsinput-any-boolean)
42-
- [`assert(input: any, prefix = '', path?: (string | number | symbol)[]): V`](#assertinput-any-prefix---path-string--number--symbol-v)
43-
- [`validate(input: any, prefix = '', path?: (string | number | symbol)[]): Validation`](#validateinput-any-prefix---path-string--number--symbol-validation)
44+
- [`assert<V extends T>(input: any, prefix = '', path?: (string | number | symbol)[]): V`](#assertinput-any-prefix---path-string--number--symbol-v)
45+
- [`validate(input: any, prefix = '', path?: (string | number | symbol)[]): Validation<T>`](#validateinput-any-prefix---path-string--number--symbol-validation)
4446
- [`warn(input: any, prefix = '', path?: (string | number | symbol)[]): void`](#warninput-any-prefix---path-string--number--symbol-void)
4547
- [`toString(): string`](#tostring-string)
48+
- [`t.ExtractType<T extends Type<any>>`](#textracttype)
49+
- [`t.TypeAlias<T>`](#ttypealias)
50+
- [`readonly name: string`](#readonly-name-string)
51+
- [`addConstraint(...constraints: TypeConstraint<T>[]): this`](#addconstraintconstraints-typeconstraint-this)
52+
- [Custom Constraints](#custom-constraints)
53+
- [Recursive Types](#recursive-types)
4654

4755
<!-- tocstop -->
4856

@@ -99,7 +107,7 @@ const example: Post = PostValidator.assert({
99107
})
100108
```
101109

102-
Hover over `Post` in the IDE and you'll see, voilà:
110+
Hover over `Post` in VSCode and you'll see, voilà:
103111

104112
```ts
105113
type Post = {
@@ -199,6 +207,10 @@ A validator that requires the value to be `MySymbol`.
199207

200208
A validator that requires the value to be `null`.
201209

210+
### `t.nullOr(t.string())`
211+
212+
A validator that requires the value to be `string | null`
213+
202214
### `t.undefined()` / `t.undefinedLiteral()`
203215

204216
A validator that requires the value to be `undefined`.
@@ -207,9 +219,9 @@ A validator that requires the value to be `undefined`.
207219

208220
A validator that requires the value to be `null | undefined`.
209221

210-
### `t.nullOr(t.string())`
222+
### `t.nullishOr(t.string())`
211223

212-
A validator that requires the value to be `string | null`
224+
A validator that requires the value to be `string | null | undefined`.
213225

214226
### `t.array(t.number())`
215227

@@ -269,20 +281,18 @@ CommentedThingType.assert({ name: 'foo', comment: 'sweet' })
269281

270282
A validator that requires the value to be `string | number`. Accepts a variable number of arguments, though type generation is only overloaded up to 8 arguments.
271283

272-
### `t.constrain(type, ...constraints)`
284+
### `t.alias(name, type)`
273285

274-
Applies custom constraints to a type. For example:
286+
Creates a `TypeAlias` with the given `name` and `type`.
275287

276-
```ts
277-
const PositiveNumber = t.constrain(t.number(), (value: number):
278-
| string
279-
| null
280-
| undefined => {
281-
if (value < 0) return 'must be >= 0'
282-
})
288+
Type aliases serve two purposes:
283289

284-
PositiveNumber.assert(-1) // throws an error including "must be >= 0"
285-
```
290+
- They allow you to [create recursive type validators with `t.ref()`](#recursive-types)
291+
- You can [add custom constraints to them](#custom-constraints)
292+
293+
### `t.ref(() => typeAlias)`
294+
295+
Creates a reference to the given `TypeAlias`. See [Recursive Types](#recursive-types) for examples.
286296

287297
## `t.Type<T>`
288298

@@ -348,3 +358,95 @@ type Post = {
348358
tags: string[]
349359
}
350360
```
361+
362+
## `t.TypeAlias<T>`
363+
364+
### `readonly name: string`
365+
366+
The name of the alias.
367+
368+
### `addConstraint(...constraints: TypeConstraint<T>[]): this`
369+
370+
Adds custom constraints. `TypeConstraint<T>` is a function `(value: T) => string | null | undefined` which
371+
returns nullish if `value` is valid, or otherwise a `string` describing why `value` is invalid.
372+
373+
## Custom Constraints
374+
375+
It's nice to be able to validate that something is a `number`, but what if we want to make sure it's positive?
376+
We can do this by creating a type alias for `number` and adding a custom constraint to it:
377+
378+
```ts
379+
const PositiveNumberType = t
380+
.alias('PositiveNumber', t.number())
381+
.addConstraint((value: number) => (value > 0 ? undefined : 'must be > 0'))
382+
383+
PositiveNumberType.assert(-1)
384+
```
385+
386+
The assertion will throw a `t.RuntimeTypeError` with the following message:
387+
388+
```
389+
Value must be > 0
390+
391+
Expected: PositiveNumber
392+
393+
Actual Value: ${value}
394+
395+
Actual Type: number
396+
```
397+
398+
## Recursive Types
399+
400+
Creating validators for recursive types takes a bit of extra effort. Naively, we would want to do this:
401+
402+
```ts
403+
const NodeType = t.object<{
404+
value: any
405+
left?: any
406+
right?: any
407+
}>()({
408+
value: t.any(),
409+
left: t.optional(NodeType),
410+
right: t.optional(NodeType),
411+
})
412+
```
413+
414+
But `t.optional(NodeType)` causes the error `Block-scoped variable 'NodeType' referenced before its declaration`.
415+
416+
To work around, this we can create a `TypeAlias` and a reference to it:
417+
418+
```ts
419+
const NodeType: t.TypeAlias<{
420+
value: any
421+
left?: Node
422+
right?: Node
423+
}> = t.alias(
424+
'Node',
425+
t.object<{
426+
value: any
427+
left?: any
428+
right?: any
429+
}>()({
430+
value: t.any(),
431+
left: t.optional(t.ref(() => NodeType)),
432+
right: t.optional(t.ref(() => NodeType)),
433+
})
434+
)
435+
436+
type Node = t.ExtractType<typeof NodeType>
437+
438+
NodeType.assert({
439+
value: 'foo',
440+
left: {
441+
value: 2,
442+
right: {
443+
value: 3,
444+
},
445+
},
446+
right: {
447+
value: 6,
448+
},
449+
})
450+
```
451+
452+
Notice how we use a thunk function in `t.ref(() => NodeType)` to avoid referencing `NodeType` before its declaration.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,7 @@
123123
"codecov": "^3.1.0",
124124
"copy": "^0.3.2",
125125
"cross-env": "^5.2.0",
126+
"dedent-js": "^1.0.1",
126127
"eslint": "^5.9.0",
127128
"eslint-config-prettier": "^3.3.0",
128129
"eslint-watch": "^4.0.2",

src/array.spec.ts

Lines changed: 20 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import * as t from './'
22
import { expect } from 'chai'
3+
import dedent from 'dedent-js'
34

45
describe(`t.array`, function() {
56
it(`accepts matching arrays`, function() {
@@ -12,17 +13,18 @@ describe(`t.array`, function() {
1213
expect(t.array(t.number()).accepts({ foo: 'bar' })).to.be.false
1314
expect(() => t.array(t.number()).assert({ foo: 'bar' })).to.throw(
1415
t.RuntimeTypeError,
15-
`Value must be an Array
16+
dedent`
17+
Value must be an Array
1618
17-
Expected: Array<number>
19+
Expected: Array<number>
1820
19-
Actual Value: {
20-
"foo": "bar"
21-
}
21+
Actual Value: {
22+
"foo": "bar"
23+
}
2224
23-
Actual Type: {
24-
foo: string
25-
}`
25+
Actual Type: {
26+
foo: string
27+
}`
2628
)
2729
})
2830
it(`rejects nonmatching array elements`, function() {
@@ -31,26 +33,28 @@ Actual Type: {
3133
t.array(t.number()).assert([1, 'bar'], '', ['array'])
3234
).to.throw(
3335
t.RuntimeTypeError,
34-
`array[1] must be a number
36+
dedent`
37+
array[1] must be a number
3538
36-
Expected: number
39+
Expected: number
3740
38-
Actual Value: "bar"
41+
Actual Value: "bar"
3942
40-
Actual Type: string`
43+
Actual Type: string`
4144
)
4245
expect(t.array(t.string()).accepts(['foo', 2])).to.be.false
4346
expect(() =>
4447
t.array(t.string()).assert(['foo', 2], '', ['array'])
4548
).to.throw(
4649
t.RuntimeTypeError,
47-
`array[1] must be a string
50+
dedent`
51+
array[1] must be a string
4852
49-
Expected: string
53+
Expected: string
5054
51-
Actual Value: 2
55+
Actual Value: 2
5256
53-
Actual Type: number`
57+
Actual Type: number`
5458
)
5559
})
5660
})

src/boolean.spec.ts

Lines changed: 36 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import * as t from './'
22
import { expect } from 'chai'
3+
import dedent from 'dedent-js'
34

45
describe(`t.boolean`, function() {
56
it(`accepts booleans`, function() {
@@ -12,35 +13,38 @@ describe(`t.boolean`, function() {
1213
expect(t.boolean().accepts(2)).to.be.false
1314
expect(() => t.boolean().assert(2)).to.throw(
1415
t.RuntimeTypeError,
15-
`Value must be true or false
16+
dedent`
17+
Value must be true or false
1618
17-
Expected: boolean
19+
Expected: boolean
1820
19-
Actual Value: 2
21+
Actual Value: 2
2022
21-
Actual Type: number`
23+
Actual Type: number`
2224
)
2325
expect(t.boolean().accepts('foo')).to.be.false
2426
expect(() => t.boolean().assert('foo')).to.throw(
2527
t.RuntimeTypeError,
26-
`Value must be true or false
28+
dedent`
29+
Value must be true or false
2730
28-
Expected: boolean
31+
Expected: boolean
2932
30-
Actual Value: "foo"
33+
Actual Value: "foo"
3134
32-
Actual Type: string`
35+
Actual Type: string`
3336
)
3437
expect(t.boolean().accepts([])).to.be.false
3538
expect(() => t.boolean().assert([])).to.throw(
3639
t.RuntimeTypeError,
37-
`Value must be true or false
40+
dedent`
41+
Value must be true or false
3842
39-
Expected: boolean
43+
Expected: boolean
4044
41-
Actual Value: []
45+
Actual Value: []
4246
43-
Actual Type: Array`
47+
Actual Type: Array`
4448
)
4549
})
4650
})
@@ -60,43 +64,47 @@ describe(`t.boolean(literal)`, function() {
6064
expect(t.boolean(false).accepts([])).to.be.false
6165
expect(() => t.boolean(true).assert(false)).to.throw(
6266
t.RuntimeTypeError,
63-
`Value must be true
67+
dedent`
68+
Value must be true
6469
65-
Expected: true
70+
Expected: true
6671
67-
Actual Value: false
72+
Actual Value: false
6873
69-
Actual Type: boolean`
74+
Actual Type: boolean`
7075
)
7176
expect(() => t.boolean(false).assert(true)).to.throw(
7277
t.RuntimeTypeError,
73-
`Value must be false
78+
dedent`
79+
Value must be false
7480
75-
Expected: false
81+
Expected: false
7682
77-
Actual Value: true
83+
Actual Value: true
7884
79-
Actual Type: boolean`
85+
Actual Type: boolean`
8086
)
8187
expect(() => t.boolean(true).assert(2)).to.throw(
8288
t.RuntimeTypeError,
83-
`Value must be true
89+
dedent`
90+
Value must be true
8491
85-
Expected: true
92+
Expected: true
8693
87-
Actual Value: 2
94+
Actual Value: 2
8895
89-
Actual Type: number`
96+
Actual Type: number`
9097
)
9198
expect(() => t.boolean(false).assert('foo')).to.throw(
9299
t.RuntimeTypeError,
93-
`Value must be false
100+
dedent`
101+
Value must be false
94102
95-
Expected: false
103+
Expected: false
96104
97-
Actual Value: "foo"
105+
Actual Value: "foo"
98106
99-
Actual Type: string`
107+
Actual Type: string`
100108
)
101109
})
102110
})

0 commit comments

Comments
 (0)