@uniflowed/validator 0.0.0-alpha.10
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 +265 -0
- 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 +34 -0
- 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/schema.js
ADDED
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// `@uniflowed/validator/schema`: what a schema *is*.
|
|
4
|
+
//
|
|
5
|
+
// A schema is two things and no more: a function that turns `mixed` into a
|
|
6
|
+
// [`Result`], and a function that says what it accepts. Nothing here is a
|
|
7
|
+
// class, a registry, or an interpreter walking a description at run time. The
|
|
8
|
+
// engine that validates a `string()` is the four-line closure `string()`
|
|
9
|
+
// returned, which a JIT can inline into the object parser that calls it — and
|
|
10
|
+
// it is why the package tree-shakes, because no table holds a reference to a
|
|
11
|
+
// check nobody imported.
|
|
12
|
+
//
|
|
13
|
+
// # Why a description, and why it is a thunk
|
|
14
|
+
//
|
|
15
|
+
// The second function is new, and it is the price of `json-schema.js`: a
|
|
16
|
+
// closure cannot be read, so a schema that is only a closure can never be
|
|
17
|
+
// exported as anything. `describe` is a thunk rather than a value so that
|
|
18
|
+
// building a schema allocates nothing extra, and so that a recursive schema
|
|
19
|
+
// can describe itself at all — `lazy.js` returns a description that contains a
|
|
20
|
+
// function returning its own description, which a value could not do without
|
|
21
|
+
// looping forever.
|
|
22
|
+
//
|
|
23
|
+
// # Why the path is a mutable buffer, and why the async walk copies it
|
|
24
|
+
//
|
|
25
|
+
// Issues report where they happened, and the obvious way to carry that is a
|
|
26
|
+
// fresh array per field: `path.concat(key)`. That allocates once per field per
|
|
27
|
+
// parse, on the *successful* path, to produce a value almost every parse
|
|
28
|
+
// throws away. So the synchronous walk pushes and pops one array as it
|
|
29
|
+
// descends, and only an actual issue copies it. A thousand-row payload that
|
|
30
|
+
// validates cleanly allocates no paths at all.
|
|
31
|
+
//
|
|
32
|
+
// The asynchronous walk cannot do that. Its whole point is that an object's
|
|
33
|
+
// fields are checked at the same time — a form with two fields that each hit a
|
|
34
|
+
// database should cost one round trip, not two — and two parses interleaved on
|
|
35
|
+
// one buffer would report each other's paths. So [`runAtAsync`] hands each
|
|
36
|
+
// child its own array. A parse that is already waiting on IO is not the parse
|
|
37
|
+
// where an allocation per field matters.
|
|
38
|
+
//
|
|
39
|
+
// # Why asynchrony is decided when the schema is built
|
|
40
|
+
//
|
|
41
|
+
// A kernel has a `parse` or a `parseAsync` and, in the ordinary case, not
|
|
42
|
+
// both. A composite asks its children which they have and builds the matching
|
|
43
|
+
// one, so `object({ name: pipe(string(), checkAsync(isFree)) })` is an
|
|
44
|
+
// asynchronous schema from the moment it exists, and [`safeParse`] can refuse
|
|
45
|
+
// it with a message instead of returning a promise where a caller expected a
|
|
46
|
+
// result. The alternative — a kernel that returns "a result or a promise of
|
|
47
|
+
// one" — puts a `typeof result.then` test on every node of every synchronous
|
|
48
|
+
// parse to pay for a feature most schemas never use.
|
|
49
|
+
//
|
|
50
|
+
// The one place that cannot be decided at construction is a recursive schema,
|
|
51
|
+
// which does not exist yet when its parent is built. That is the whole reason
|
|
52
|
+
// `lazy.js` has two constructors.
|
|
53
|
+
|
|
54
|
+
import type { Issue, Path, PathBuffer } from "./issue.js";
|
|
55
|
+
import { issue } from "./issue.js";
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* What a parse produced, or why it did not.
|
|
59
|
+
*
|
|
60
|
+
* Covariant in `T`, which is what lets `fail` return a single `Result<empty>`
|
|
61
|
+
* and every caller accept it.
|
|
62
|
+
*/
|
|
63
|
+
export type Result<out T> =
|
|
64
|
+
| {| readonly ok: true, readonly value: T |}
|
|
65
|
+
| {| readonly ok: false, readonly issues: $ReadOnlyArray<Issue> |};
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* What a schema says about itself, for a consumer that has to write it down
|
|
69
|
+
* somewhere else.
|
|
70
|
+
*
|
|
71
|
+
* Structural rather than nominal: a description is plain data with no schema
|
|
72
|
+
* inside it, so `json-schema.js` — or a generator that has not been written
|
|
73
|
+
* yet — can walk one without being able to run a parse. `lazy` is the
|
|
74
|
+
* exception and has to be, because a recursive value has no finite spelling;
|
|
75
|
+
* its `id` is the identity a converter keys its `$defs` on.
|
|
76
|
+
*/
|
|
77
|
+
export type Description =
|
|
78
|
+
| {| readonly kind: "unknown" |}
|
|
79
|
+
| {| readonly kind: "never" |}
|
|
80
|
+
| {| readonly kind: "string" |}
|
|
81
|
+
| {| readonly kind: "number" |}
|
|
82
|
+
| {| readonly kind: "bigint" |}
|
|
83
|
+
| {| readonly kind: "boolean" |}
|
|
84
|
+
| {| readonly kind: "null" |}
|
|
85
|
+
| {| readonly kind: "undefined" |}
|
|
86
|
+
| {| readonly kind: "date" |}
|
|
87
|
+
| {| readonly kind: "instance", readonly name: string |}
|
|
88
|
+
| {| readonly kind: "custom", readonly name: string |}
|
|
89
|
+
| {| readonly kind: "literal", readonly value: string | number | boolean | null |}
|
|
90
|
+
| {| readonly kind: "enum", readonly values: $ReadOnlyArray<string> |}
|
|
91
|
+
| {| readonly kind: "array", readonly item: Description |}
|
|
92
|
+
| {| readonly kind: "tuple", readonly items: $ReadOnlyArray<Description> |}
|
|
93
|
+
| {| readonly kind: "record", readonly value: Description |}
|
|
94
|
+
| {| readonly kind: "map", readonly key: Description, readonly value: Description |}
|
|
95
|
+
| {| readonly kind: "set", readonly item: Description |}
|
|
96
|
+
| {|
|
|
97
|
+
readonly kind: "object",
|
|
98
|
+
readonly entries: $ReadOnlyArray<[string, Description]>,
|
|
99
|
+
readonly unknownKeys: "strip" | "reject" | "keep",
|
|
100
|
+
|}
|
|
101
|
+
| {| readonly kind: "union", readonly options: $ReadOnlyArray<Description> |}
|
|
102
|
+
| {|
|
|
103
|
+
readonly kind: "variant",
|
|
104
|
+
readonly key: string,
|
|
105
|
+
readonly branches: $ReadOnlyArray<[string, Description]>,
|
|
106
|
+
|}
|
|
107
|
+
| {| readonly kind: "intersect", readonly parts: $ReadOnlyArray<Description> |}
|
|
108
|
+
| {| readonly kind: "optional", readonly inner: Description |}
|
|
109
|
+
| {| readonly kind: "nullable", readonly inner: Description |}
|
|
110
|
+
| {| readonly kind: "nullish", readonly inner: Description |}
|
|
111
|
+
| {| readonly kind: "default", readonly inner: Description |}
|
|
112
|
+
| {| readonly kind: "fallback", readonly inner: Description |}
|
|
113
|
+
| {| readonly kind: "lazy", readonly id: symbol, readonly inner: () => Description |}
|
|
114
|
+
| {| readonly kind: "transformed", readonly inner: Description |}
|
|
115
|
+
| {|
|
|
116
|
+
readonly kind: "constrained",
|
|
117
|
+
readonly inner: Description,
|
|
118
|
+
readonly constraint: Constraint,
|
|
119
|
+
|};
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* What one `pipe` step narrowed.
|
|
123
|
+
*
|
|
124
|
+
* `opaque` is the honest answer for [`check`]: an arbitrary predicate has no
|
|
125
|
+
* spelling in any export format, and saying so is better than emitting a
|
|
126
|
+
* schema that claims the value is unconstrained.
|
|
127
|
+
*/
|
|
128
|
+
export type Constraint =
|
|
129
|
+
| {| readonly kind: "minLength", readonly value: number |}
|
|
130
|
+
| {| readonly kind: "maxLength", readonly value: number |}
|
|
131
|
+
| {| readonly kind: "length", readonly value: number |}
|
|
132
|
+
| {| readonly kind: "minItems", readonly value: number |}
|
|
133
|
+
| {| readonly kind: "maxItems", readonly value: number |}
|
|
134
|
+
| {| readonly kind: "min", readonly value: number |}
|
|
135
|
+
| {| readonly kind: "max", readonly value: number |}
|
|
136
|
+
| {| readonly kind: "integer" |}
|
|
137
|
+
| {| readonly kind: "multipleOf", readonly value: number |}
|
|
138
|
+
| {| readonly kind: "pattern", readonly source: string |}
|
|
139
|
+
| {| readonly kind: "format", readonly name: string |}
|
|
140
|
+
| {| readonly kind: "brand", readonly name: string |}
|
|
141
|
+
| {| readonly kind: "opaque", readonly label: string |};
|
|
142
|
+
|
|
143
|
+
type SchemaKernel<out T> = {|
|
|
144
|
+
readonly parse: null | ((mixed, PathBuffer) => Result<T>),
|
|
145
|
+
readonly parseAsync: null | ((mixed, PathBuffer) => Promise<Result<T>>),
|
|
146
|
+
readonly describe: () => Description,
|
|
147
|
+
|};
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* The object a `Schema` is.
|
|
151
|
+
*
|
|
152
|
+
* `__input` is a phantom. `TInput` — the type a *valid input* has, which is
|
|
153
|
+
* `string` for `pipe(string(), transform(Number))` whose output is `number` —
|
|
154
|
+
* is never consumed at run time, because every parse takes `mixed`. It needs a
|
|
155
|
+
* place in the type to be a parameter at all, and carrying it in return
|
|
156
|
+
* position keeps it covariant, which is what lets `Schema<string, string>` be
|
|
157
|
+
* passed where `Schema<mixed, mixed>` is wanted.
|
|
158
|
+
*/
|
|
159
|
+
type SchemaCarrier<out TOutput, out TInput> = {|
|
|
160
|
+
readonly __kind: "Schema",
|
|
161
|
+
readonly __input: () => TInput,
|
|
162
|
+
readonly __kernel: SchemaKernel<TOutput>,
|
|
163
|
+
|};
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* A parser from `mixed` to `TOutput`, whose valid inputs are `TInput`.
|
|
167
|
+
*
|
|
168
|
+
* Opaque with a supertype bound rather than fully opaque, and the difference
|
|
169
|
+
* is what makes this package more than one file. Fully opaque, `object.js`
|
|
170
|
+
* could not read the kernel out of a schema `primitive.js` built, so every
|
|
171
|
+
* builder would have to live beside the type — which is the argument
|
|
172
|
+
* `@uniflowed/effect` makes for staying in one module, and it is a real one.
|
|
173
|
+
* The bound splits the guarantee in two: any module may *read* a schema, and
|
|
174
|
+
* only this one may *mint* one, because `SchemaCarrier` is not exported and
|
|
175
|
+
* [`makeSchema`] is the only thing that returns the opaque type. An
|
|
176
|
+
* application still cannot forge a schema, hand-write a kernel, or depend on
|
|
177
|
+
* the carrier's shape.
|
|
178
|
+
*
|
|
179
|
+
* `TInput` defaults to `mixed` so that `Schema<User>` keeps meaning "a schema
|
|
180
|
+
* that produces a `User`" for the callers that only care about the output —
|
|
181
|
+
* `@uniflowed/form`'s resolver, `@uniflowed/fetch`'s response parser — and
|
|
182
|
+
* keeps compiling unchanged.
|
|
183
|
+
*/
|
|
184
|
+
export opaque type Schema<out TOutput, out TInput = mixed>: SchemaCarrier<
|
|
185
|
+
TOutput,
|
|
186
|
+
TInput,
|
|
187
|
+
> = SchemaCarrier<TOutput, TInput>;
|
|
188
|
+
|
|
189
|
+
const ASYNC_MESSAGE =
|
|
190
|
+
"@uniflowed/validator: this schema has an asynchronous step in it; use parseAsync or safeParseAsync";
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* The one value every schema's `__input` holds.
|
|
194
|
+
*
|
|
195
|
+
* It is never called. `TInput` exists so that [`InferInput`] has something to
|
|
196
|
+
* read, and a thrown error is how that is enforced rather than asserted.
|
|
197
|
+
*/
|
|
198
|
+
const phantomInput = (): empty => {
|
|
199
|
+
throw new Error("@uniflowed/validator: __input is a type-level marker and has no value");
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
/** Mint a synchronous schema. The only way a `Schema` comes into existence. */
|
|
203
|
+
export function makeSchema<TOutput, TInput>(
|
|
204
|
+
parse: (mixed, PathBuffer) => Result<TOutput>,
|
|
205
|
+
description: () => Description,
|
|
206
|
+
): Schema<TOutput, TInput> {
|
|
207
|
+
return {
|
|
208
|
+
__kind: "Schema",
|
|
209
|
+
__input: phantomInput,
|
|
210
|
+
__kernel: { parse, parseAsync: null, describe: description },
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Mint an asynchronous schema.
|
|
216
|
+
*
|
|
217
|
+
* Its `parse` is null, which is what [`run`] refuses and what a composite
|
|
218
|
+
* reads to decide it is asynchronous too.
|
|
219
|
+
*/
|
|
220
|
+
export function makeAsyncSchema<TOutput, TInput>(
|
|
221
|
+
parseAsync: (mixed, PathBuffer) => Promise<Result<TOutput>>,
|
|
222
|
+
description: () => Description,
|
|
223
|
+
): Schema<TOutput, TInput> {
|
|
224
|
+
return {
|
|
225
|
+
__kind: "Schema",
|
|
226
|
+
__input: phantomInput,
|
|
227
|
+
__kernel: { parse: null, parseAsync, describe: description },
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Whether `schema` needs [`safeParseAsync`]. Decided when it was built. */
|
|
232
|
+
export function isAsync(schema: Schema<mixed, mixed>): boolean {
|
|
233
|
+
return schema.__kernel.parse == null;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** What `schema` accepts, as data. See [`Description`]. */
|
|
237
|
+
export function describe(schema: Schema<mixed, mixed>): Description {
|
|
238
|
+
return schema.__kernel.describe();
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** Run `schema` where the walk currently is. Throws if `schema` is async. */
|
|
242
|
+
export function run<T>(schema: Schema<T, mixed>, value: mixed, path: PathBuffer): Result<T> {
|
|
243
|
+
const parse = schema.__kernel.parse;
|
|
244
|
+
if (parse == null) {
|
|
245
|
+
throw new Error(ASYNC_MESSAGE);
|
|
246
|
+
}
|
|
247
|
+
return parse(value, path);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Run `schema` one step deeper in the path.
|
|
252
|
+
*
|
|
253
|
+
* The push/pop pair is why the buffer stays balanced even when a nested schema
|
|
254
|
+
* returns early: nothing between them can throw except user code inside a
|
|
255
|
+
* `transform` or a `check`, and a schema that raised has already failed the
|
|
256
|
+
* whole parse.
|
|
257
|
+
*/
|
|
258
|
+
export function runAt<T>(
|
|
259
|
+
schema: Schema<T, mixed>,
|
|
260
|
+
value: mixed,
|
|
261
|
+
path: PathBuffer,
|
|
262
|
+
key: string,
|
|
263
|
+
): Result<T> {
|
|
264
|
+
path.push(key);
|
|
265
|
+
const result = run(schema, value, path);
|
|
266
|
+
path.pop();
|
|
267
|
+
return result;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Run `schema`, awaiting it if it is asynchronous and calling it if it is not. */
|
|
271
|
+
export function runAsync<T>(
|
|
272
|
+
schema: Schema<T, mixed>,
|
|
273
|
+
value: mixed,
|
|
274
|
+
path: PathBuffer,
|
|
275
|
+
): Promise<Result<T>> {
|
|
276
|
+
const kernel = schema.__kernel;
|
|
277
|
+
const parseAsync = kernel.parseAsync;
|
|
278
|
+
if (parseAsync != null) {
|
|
279
|
+
return parseAsync(value, path);
|
|
280
|
+
}
|
|
281
|
+
return Promise.resolve(run(schema, value, path));
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Run `schema` several steps deeper.
|
|
286
|
+
*
|
|
287
|
+
* `map` is the caller that needs more than one segment: an entry's key and its
|
|
288
|
+
* value are two different places to fail, and `["3", "key"]` says which
|
|
289
|
+
* without inventing a punctuation the rest of the package would have to parse.
|
|
290
|
+
*/
|
|
291
|
+
export function runUnder<T>(
|
|
292
|
+
schema: Schema<T, mixed>,
|
|
293
|
+
value: mixed,
|
|
294
|
+
path: PathBuffer,
|
|
295
|
+
keys: Path,
|
|
296
|
+
): Result<T> {
|
|
297
|
+
for (const key of keys) {
|
|
298
|
+
path.push(key);
|
|
299
|
+
}
|
|
300
|
+
const result = run(schema, value, path);
|
|
301
|
+
for (let depth = 0; depth < keys.length; depth += 1) {
|
|
302
|
+
path.pop();
|
|
303
|
+
}
|
|
304
|
+
return result;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** Run `schema` deeper, on a path of its own. See the module docs. */
|
|
308
|
+
export function runAtAsync<T>(
|
|
309
|
+
schema: Schema<T, mixed>,
|
|
310
|
+
value: mixed,
|
|
311
|
+
path: PathBuffer,
|
|
312
|
+
keys: Path,
|
|
313
|
+
): Promise<Result<T>> {
|
|
314
|
+
return runAsync(schema, value, path.concat(keys));
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** One child of a composite, for [`collectAsync`]. */
|
|
318
|
+
export type Job = {|
|
|
319
|
+
readonly keys: Path,
|
|
320
|
+
readonly schema: Schema<mixed, mixed>,
|
|
321
|
+
readonly value: mixed,
|
|
322
|
+
|};
|
|
323
|
+
|
|
324
|
+
/** Every child's outcome, in the order the jobs were given. */
|
|
325
|
+
export type Collected =
|
|
326
|
+
| {| readonly ok: true, readonly values: $ReadOnlyArray<mixed> |}
|
|
327
|
+
| {| readonly ok: false, readonly issues: $ReadOnlyArray<Issue> |};
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Run every child at once, and report all of their issues.
|
|
331
|
+
*
|
|
332
|
+
* The one function that keeps `object`, `array`, `tuple`, `record`, `map` and
|
|
333
|
+
* `set` from each growing a second copy of the same loop: their asynchronous
|
|
334
|
+
* halves differ only in how they name their children and what they build out
|
|
335
|
+
* of the answers.
|
|
336
|
+
*
|
|
337
|
+
* `Promise.all` rather than a loop of `await`s, because a form whose two
|
|
338
|
+
* fields each ask a server should cost one round trip. Issues still come back
|
|
339
|
+
* in child order, because the results are folded in the order they were
|
|
340
|
+
* requested rather than the order they settled.
|
|
341
|
+
*/
|
|
342
|
+
export async function collectAsync(
|
|
343
|
+
jobs: $ReadOnlyArray<Job>,
|
|
344
|
+
path: PathBuffer,
|
|
345
|
+
): Promise<Collected> {
|
|
346
|
+
const results = await Promise.all(
|
|
347
|
+
jobs.map((job) => runAtAsync(job.schema, job.value, path, job.keys)),
|
|
348
|
+
);
|
|
349
|
+
const values: Array<mixed> = [];
|
|
350
|
+
let issues: null | Array<Issue> = null;
|
|
351
|
+
for (const result of results) {
|
|
352
|
+
match (result) {
|
|
353
|
+
{ok: true, value: const value} => {
|
|
354
|
+
values.push(value);
|
|
355
|
+
}
|
|
356
|
+
{ok: false, issues: const found} => {
|
|
357
|
+
const into = issues ?? [];
|
|
358
|
+
for (const entry of found) {
|
|
359
|
+
into.push(entry);
|
|
360
|
+
}
|
|
361
|
+
issues = into;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
return issues == null ? { ok: true, values } : { ok: false, issues };
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** A successful result. */
|
|
369
|
+
export function ok<T>(value: T): Result<T> {
|
|
370
|
+
return { ok: true, value };
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** A result carrying one issue, at wherever the walk is. */
|
|
374
|
+
export function fail(code: string, message: string, path: Path): Result<empty> {
|
|
375
|
+
return { ok: false, issues: [issue(code, message, path)] };
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** Append `result`'s issues to `issues`, if it has any. */
|
|
379
|
+
export function mergeIssues(issues: Array<Issue>, result: Result<mixed>): void {
|
|
380
|
+
match (result) {
|
|
381
|
+
{ok: false, issues: const found} => {
|
|
382
|
+
for (const entry of found) {
|
|
383
|
+
issues.push(entry);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
_ => {}
|
|
387
|
+
}
|
|
388
|
+
}
|
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
|
+
}
|