@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/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
+ }