@uniflowed/validator 0.0.0-alpha.4 → 0.0.0-alpha.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/action.js +309 -0
- package/collection.js +338 -0
- package/index.js +245 -763
- package/infer.js +107 -0
- package/issue.js +129 -0
- package/json-schema.js +307 -0
- package/lazy.js +93 -0
- package/namespace.js +229 -0
- package/object.js +241 -0
- package/optional.js +132 -0
- package/package.json +19 -4
- package/parse.js +116 -0
- package/pipe.js +300 -0
- package/plain-object.js +105 -0
- package/primitive.js +183 -0
- package/schema.js +388 -0
- package/union.js +229 -0
package/union.js
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// `@uniflowed/validator/union`: several schemas over one value.
|
|
4
|
+
//
|
|
5
|
+
// Three ways to combine schemas that all look at the *same* value, rather than
|
|
6
|
+
// at different parts of one. [`union`] accepts if any of them does, [`variant`]
|
|
7
|
+
// is the same thing when the value says which one to use, and [`intersect`] is
|
|
8
|
+
// the dual: accept only if all of them do. They are together because the walk
|
|
9
|
+
// is the same walk and the acceptance rule is the only line that differs.
|
|
10
|
+
//
|
|
11
|
+
// # Why `variant` exists when `union` would work
|
|
12
|
+
//
|
|
13
|
+
// It would, and its errors would be useless. A four-branch union of shapes,
|
|
14
|
+
// given `{ kind: "circle", radius: "big" }`, reports every reason the value is
|
|
15
|
+
// not a square, not a triangle and not a line, on top of the one reason that
|
|
16
|
+
// matters. A discriminated union knows which branch was meant before it starts
|
|
17
|
+
// — that is what the discriminant is for — so it runs that one and reports
|
|
18
|
+
// `expected number at radius`. An unmatched discriminant names the ones that
|
|
19
|
+
// exist, which is the other half of the error a union cannot give.
|
|
20
|
+
//
|
|
21
|
+
// So: reach for `variant` whenever the branches share a tag, and leave `union`
|
|
22
|
+
// for the cases that genuinely have none, like `string | number`.
|
|
23
|
+
//
|
|
24
|
+
// # Why the union runs its branches in order, even when it is asynchronous
|
|
25
|
+
//
|
|
26
|
+
// "The first schema that accepts" is the contract, and a branch is allowed to
|
|
27
|
+
// have a `checkAsync` in it that talks to a server. Running the branches at
|
|
28
|
+
// once would ask every server on every parse, including the ones whose branch
|
|
29
|
+
// an earlier one had already made irrelevant. Sequential is slower on a value
|
|
30
|
+
// that only the last branch accepts and correct on every value; the parallel
|
|
31
|
+
// version is faster and wrong.
|
|
32
|
+
|
|
33
|
+
import type { InferInput, InferOutput, Options, Shape } from "./infer.js";
|
|
34
|
+
import type { Issue } from "./issue.js";
|
|
35
|
+
import { isPlainObject, ownValue, plainRecord, put } from "./plain-object.js";
|
|
36
|
+
import type { Description, Result, Schema } from "./schema.js";
|
|
37
|
+
import {
|
|
38
|
+
describe,
|
|
39
|
+
fail,
|
|
40
|
+
isAsync,
|
|
41
|
+
makeAsyncSchema,
|
|
42
|
+
makeSchema,
|
|
43
|
+
mergeIssues,
|
|
44
|
+
ok,
|
|
45
|
+
run,
|
|
46
|
+
runAsync,
|
|
47
|
+
} from "./schema.js";
|
|
48
|
+
|
|
49
|
+
function buildUnion<TOutput, TInput>(options: Options): Schema<TOutput, TInput> {
|
|
50
|
+
const description = (): Description => ({
|
|
51
|
+
kind: "union",
|
|
52
|
+
options: options.map((option) => describe(option)),
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
if (options.some((option) => isAsync(option))) {
|
|
56
|
+
return makeAsyncSchema(async (value, path) => {
|
|
57
|
+
const issues: Array<Issue> = [];
|
|
58
|
+
for (const option of options) {
|
|
59
|
+
const result = await runAsync(option, value, path);
|
|
60
|
+
if (result.ok) {
|
|
61
|
+
// $FlowFixMe[incompatible-type] the branch that accepted produced the output type.
|
|
62
|
+
return result as Result<TOutput>;
|
|
63
|
+
}
|
|
64
|
+
mergeIssues(issues, result);
|
|
65
|
+
}
|
|
66
|
+
return { ok: false, issues };
|
|
67
|
+
}, description);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return makeSchema((value, path) => {
|
|
71
|
+
const issues: Array<Issue> = [];
|
|
72
|
+
for (const option of options) {
|
|
73
|
+
const result = run(option, value, path);
|
|
74
|
+
if (result.ok) {
|
|
75
|
+
// $FlowFixMe[incompatible-type] the branch that accepted produced the output type.
|
|
76
|
+
return result as Result<TOutput>;
|
|
77
|
+
}
|
|
78
|
+
mergeIssues(issues, result);
|
|
79
|
+
}
|
|
80
|
+
return { ok: false, issues };
|
|
81
|
+
}, description);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The first schema that accepts the value.
|
|
86
|
+
*
|
|
87
|
+
* When none do, every branch's issues are reported, because there is no way to
|
|
88
|
+
* know which branch the author meant. That is also why [`variant`] exists.
|
|
89
|
+
*/
|
|
90
|
+
export function union<TOptions extends Options>(
|
|
91
|
+
options: TOptions,
|
|
92
|
+
): Schema<InferOutput<TOptions[number]>, InferInput<TOptions[number]>> {
|
|
93
|
+
return buildUnion(options);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Which branch a value asked for, or the issue that says it named none. */
|
|
97
|
+
type Chosen =
|
|
98
|
+
| {| readonly found: true, readonly branch: Schema<mixed, mixed> |}
|
|
99
|
+
| {| readonly found: false, readonly failure: Result<empty> |};
|
|
100
|
+
|
|
101
|
+
function buildVariant<TOutput, TInput>(key: string, branches: Shape): Schema<TOutput, TInput> {
|
|
102
|
+
const known = Object.keys(branches);
|
|
103
|
+
const message = `expected one of ${known.join(", ")}`;
|
|
104
|
+
const description = (): Description => ({
|
|
105
|
+
kind: "variant",
|
|
106
|
+
key,
|
|
107
|
+
branches: known.map((name) => [name, describe(branches[name])]),
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
/** The branch the value asked for, or the issue saying it asked for nothing. */
|
|
111
|
+
function choose(value: mixed, path: Array<string>): Chosen {
|
|
112
|
+
if (!isPlainObject(value)) {
|
|
113
|
+
return { found: false, failure: fail("type", "expected object", path) };
|
|
114
|
+
}
|
|
115
|
+
const discriminant = ownValue(plainRecord(value), key);
|
|
116
|
+
if (typeof discriminant !== "string" || !Object.hasOwn(branches, discriminant)) {
|
|
117
|
+
path.push(key);
|
|
118
|
+
const failure = fail("variant", message, path);
|
|
119
|
+
path.pop();
|
|
120
|
+
return { found: false, failure };
|
|
121
|
+
}
|
|
122
|
+
return { found: true, branch: branches[discriminant] };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (known.some((name) => isAsync(branches[name]))) {
|
|
126
|
+
return makeAsyncSchema(async (value, path) => {
|
|
127
|
+
const chosen = choose(value, path);
|
|
128
|
+
if (!chosen.found) {
|
|
129
|
+
return chosen.failure;
|
|
130
|
+
}
|
|
131
|
+
// $FlowFixMe[incompatible-type] a branch's output type is the variant's.
|
|
132
|
+
return (await runAsync(chosen.branch, value, path)) as Result<TOutput>;
|
|
133
|
+
}, description);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return makeSchema((value, path) => {
|
|
137
|
+
const chosen = choose(value, path);
|
|
138
|
+
if (!chosen.found) {
|
|
139
|
+
return chosen.failure;
|
|
140
|
+
}
|
|
141
|
+
// $FlowFixMe[incompatible-type] a branch's output type is the variant's.
|
|
142
|
+
return run(chosen.branch, value, path) as Result<TOutput>;
|
|
143
|
+
}, description);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* A union chosen by the value of one key.
|
|
148
|
+
*
|
|
149
|
+
* The discriminant is read first and the matching branch is the only one run.
|
|
150
|
+
* A discriminant that is missing, is not a string, or names no branch is
|
|
151
|
+
* reported at the discriminant's own path, so a form can put the message on
|
|
152
|
+
* the control that chooses it.
|
|
153
|
+
*/
|
|
154
|
+
export function variant<TBranches extends Shape>(
|
|
155
|
+
key: string,
|
|
156
|
+
branches: TBranches,
|
|
157
|
+
): Schema<InferOutput<TBranches[keyof TBranches]>, InferInput<TBranches[keyof TBranches]>> {
|
|
158
|
+
return buildVariant(key, branches);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Both schemas, over the same value.
|
|
163
|
+
*
|
|
164
|
+
* Binary rather than variadic: `intersect(intersect(a, b), c)` is the third
|
|
165
|
+
* one, the type is `A & B` with nothing for the checker to fold, and there is
|
|
166
|
+
* no arity table to keep in step with an implementation.
|
|
167
|
+
*
|
|
168
|
+
* Both sides run, and both sides' issues are reported, for the same reason
|
|
169
|
+
* `object` does not stop at the first bad field.
|
|
170
|
+
*
|
|
171
|
+
* What the result *is* depends on what the two produced. Two plain objects are
|
|
172
|
+
* merged, with the right-hand side winning a shared key — which is what makes
|
|
173
|
+
* `intersect(object(base), object(extra))` mean what it looks like. Two
|
|
174
|
+
* identical values are that value. Anything else is a `intersect` issue rather
|
|
175
|
+
* than a guess, because there is no defensible way to merge a `Date` with a
|
|
176
|
+
* string and pretend the result satisfies both.
|
|
177
|
+
*/
|
|
178
|
+
export function intersect<TLeftOut, TLeftIn, TRightOut, TRightIn>(
|
|
179
|
+
left: Schema<TLeftOut, TLeftIn>,
|
|
180
|
+
right: Schema<TRightOut, TRightIn>,
|
|
181
|
+
): Schema<TLeftOut & TRightOut, TLeftIn & TRightIn> {
|
|
182
|
+
const description = (): Description => ({
|
|
183
|
+
kind: "intersect",
|
|
184
|
+
parts: [describe(left), describe(right)],
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
function combine(
|
|
188
|
+
first: Result<TLeftOut>,
|
|
189
|
+
second: Result<TRightOut>,
|
|
190
|
+
path: Array<string>,
|
|
191
|
+
): Result<TLeftOut & TRightOut> {
|
|
192
|
+
if (!first.ok || !second.ok) {
|
|
193
|
+
const issues: Array<Issue> = [];
|
|
194
|
+
mergeIssues(issues, first);
|
|
195
|
+
mergeIssues(issues, second);
|
|
196
|
+
return { ok: false, issues };
|
|
197
|
+
}
|
|
198
|
+
if (isPlainObject(first.value) && isPlainObject(second.value)) {
|
|
199
|
+
const merged: { [string]: mixed, ... } = {};
|
|
200
|
+
for (const source of [plainRecord(first.value), plainRecord(second.value)]) {
|
|
201
|
+
for (const key of Object.keys(source)) {
|
|
202
|
+
put(merged, key, ownValue(source, key));
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
// $FlowFixMe[incompatible-type] both sides' own keys are in the merged object.
|
|
206
|
+
return ok(merged as TLeftOut & TRightOut);
|
|
207
|
+
}
|
|
208
|
+
if (Object.is(first.value, second.value)) {
|
|
209
|
+
// $FlowFixMe[incompatible-type] one value that both schemas accepted.
|
|
210
|
+
return ok(first.value as TLeftOut & TRightOut);
|
|
211
|
+
}
|
|
212
|
+
return fail("intersect", "expected both sides to agree on one value", path);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (isAsync(left) || isAsync(right)) {
|
|
216
|
+
return makeAsyncSchema(async (value, path) => {
|
|
217
|
+
const [first, second] = await Promise.all([
|
|
218
|
+
runAsync(left, value, path),
|
|
219
|
+
runAsync(right, value, path),
|
|
220
|
+
]);
|
|
221
|
+
return combine(first, second, path);
|
|
222
|
+
}, description);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return makeSchema(
|
|
226
|
+
(value, path) => combine(run(left, value, path), run(right, value, path), path),
|
|
227
|
+
description,
|
|
228
|
+
);
|
|
229
|
+
}
|