@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/action.js
ADDED
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// `@uniflowed/validator/action`: the steps that come ready-made.
|
|
4
|
+
//
|
|
5
|
+
// Every one of them is a call to `refine` or `transform` with a name, a
|
|
6
|
+
// message and a [`Constraint`] attached, and they are together because that is
|
|
7
|
+
// what they are: a catalogue, in the same sense as `@uniflowed/form`'s
|
|
8
|
+
// `rules.js`. Splitting it by the type each one refines would be splitting by
|
|
9
|
+
// argument rather than by subject, and would leave three modules that each say
|
|
10
|
+
// "this is a `check` with a better error message".
|
|
11
|
+
//
|
|
12
|
+
// The constraint is the part that is not decoration. `minLength(3)` records
|
|
13
|
+
// `{ kind: "minLength", value: 3 }`, which is what lets `json-schema.js` emit
|
|
14
|
+
// `"minLength": 3` rather than shrugging; a hand-written `check((text) =>
|
|
15
|
+
// text.length >= 3, …)` does the same thing at run time and exports as
|
|
16
|
+
// nothing, because a predicate has no spelling in any export format.
|
|
17
|
+
//
|
|
18
|
+
// # Messages
|
|
19
|
+
//
|
|
20
|
+
// Each carries a default that says what was wanted rather than what was found
|
|
21
|
+
// — "expected at least 3 characters" — because the value is already in the
|
|
22
|
+
// caller's hands and the expectation is the half it is missing. Where a
|
|
23
|
+
// message wants to be user-facing prose, `check(predicate, "Pick a longer
|
|
24
|
+
// name")` is the way to say it; these are for the boundary, not the label.
|
|
25
|
+
//
|
|
26
|
+
// # What is deliberately absent
|
|
27
|
+
//
|
|
28
|
+
// No `creditCard`, `emoji`, `mac`, `imei`, `cuid2` or the rest of the long
|
|
29
|
+
// tail. Each is a regular expression with a maintenance schedule attached —
|
|
30
|
+
// Unicode adds emoji, card issuers add prefixes — and a validator that ships a
|
|
31
|
+
// stale one is worse than an application that writes `check` with a rule it
|
|
32
|
+
// owns. The ones here are either structural (`length`, `min`) or defined by a
|
|
33
|
+
// specification that does not move under them (`uuid`, `isoDate`).
|
|
34
|
+
|
|
35
|
+
import type { Step } from "./pipe.js";
|
|
36
|
+
import { refine, transform } from "./pipe.js";
|
|
37
|
+
import type { Schema } from "./schema.js";
|
|
38
|
+
|
|
39
|
+
/** At least `value` characters. */
|
|
40
|
+
export function minLength(value: number): Step<string, string> {
|
|
41
|
+
return <TInput>(schema: Schema<string, TInput>): Schema<string, TInput> =>
|
|
42
|
+
refine(
|
|
43
|
+
schema,
|
|
44
|
+
(input: string) => input.length >= value,
|
|
45
|
+
"min_length",
|
|
46
|
+
`expected at least ${String(value)} characters`,
|
|
47
|
+
{ kind: "minLength", value },
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** At most `value` characters. */
|
|
52
|
+
export function maxLength(value: number): Step<string, string> {
|
|
53
|
+
return <TInput>(schema: Schema<string, TInput>): Schema<string, TInput> =>
|
|
54
|
+
refine(
|
|
55
|
+
schema,
|
|
56
|
+
(input: string) => input.length <= value,
|
|
57
|
+
"max_length",
|
|
58
|
+
`expected at most ${String(value)} characters`,
|
|
59
|
+
{ kind: "maxLength", value },
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Exactly `value` characters. */
|
|
64
|
+
export function length(value: number): Step<string, string> {
|
|
65
|
+
return <TInput>(schema: Schema<string, TInput>): Schema<string, TInput> =>
|
|
66
|
+
refine(
|
|
67
|
+
schema,
|
|
68
|
+
(input: string) => input.length === value,
|
|
69
|
+
"length",
|
|
70
|
+
`expected exactly ${String(value)} characters`,
|
|
71
|
+
{ kind: "length", value },
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* At least one character.
|
|
77
|
+
*
|
|
78
|
+
* Separate from `minLength(1)` because it is the check a form makes on every
|
|
79
|
+
* required text field, and `nonEmpty()` says why at the call site.
|
|
80
|
+
*/
|
|
81
|
+
export function nonEmpty(): Step<string, string> {
|
|
82
|
+
return <TInput>(schema: Schema<string, TInput>): Schema<string, TInput> =>
|
|
83
|
+
refine(schema, (input: string) => input.length > 0, "non_empty", "expected a value", {
|
|
84
|
+
kind: "minLength",
|
|
85
|
+
value: 1,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function startsWith(value: string): Step<string, string> {
|
|
90
|
+
return <TInput>(schema: Schema<string, TInput>): Schema<string, TInput> =>
|
|
91
|
+
refine(
|
|
92
|
+
schema,
|
|
93
|
+
(input: string) => input.startsWith(value),
|
|
94
|
+
"starts_with",
|
|
95
|
+
`expected prefix ${value}`,
|
|
96
|
+
{ kind: "pattern", source: `^${escapeRegExp(value)}` },
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function endsWith(value: string): Step<string, string> {
|
|
101
|
+
return <TInput>(schema: Schema<string, TInput>): Schema<string, TInput> =>
|
|
102
|
+
refine(
|
|
103
|
+
schema,
|
|
104
|
+
(input: string) => input.endsWith(value),
|
|
105
|
+
"ends_with",
|
|
106
|
+
`expected suffix ${value}`,
|
|
107
|
+
{ kind: "pattern", source: `${escapeRegExp(value)}$` },
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function includes(value: string): Step<string, string> {
|
|
112
|
+
return <TInput>(schema: Schema<string, TInput>): Schema<string, TInput> =>
|
|
113
|
+
refine(
|
|
114
|
+
schema,
|
|
115
|
+
(input: string) => input.includes(value),
|
|
116
|
+
"includes",
|
|
117
|
+
`expected ${value} somewhere in the value`,
|
|
118
|
+
{ kind: "pattern", source: escapeRegExp(value) },
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** `value` with every regular-expression metacharacter made literal. */
|
|
123
|
+
function escapeRegExp(value: string): string {
|
|
124
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* A string matching `pattern`.
|
|
129
|
+
*
|
|
130
|
+
* The pattern is tested against a reset `lastIndex` every time, because a
|
|
131
|
+
* caller who reaches for `/g` would otherwise get a schema that alternates
|
|
132
|
+
* between accepting and rejecting the same input.
|
|
133
|
+
*/
|
|
134
|
+
export function regex(pattern: RegExp, message?: string): Step<string, string> {
|
|
135
|
+
return <TInput>(schema: Schema<string, TInput>): Schema<string, TInput> =>
|
|
136
|
+
refine(
|
|
137
|
+
schema,
|
|
138
|
+
(input: string) => {
|
|
139
|
+
pattern.lastIndex = 0;
|
|
140
|
+
return pattern.test(input);
|
|
141
|
+
},
|
|
142
|
+
"regex",
|
|
143
|
+
message ?? `expected a match for ${String(pattern)}`,
|
|
144
|
+
{ kind: "pattern", source: pattern.source },
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Something shaped like an email address.
|
|
150
|
+
*
|
|
151
|
+
* Deliberately loose. The grammar in RFC 5322 accepts addresses no mail server
|
|
152
|
+
* will route and the regular expressions that implement it are famous for
|
|
153
|
+
* rejecting real ones; the only test that proves an address exists is sending
|
|
154
|
+
* something to it. This rejects the typos — a missing at-sign, a missing dot —
|
|
155
|
+
* and gets out of the way.
|
|
156
|
+
*/
|
|
157
|
+
export function email(): Step<string, string> {
|
|
158
|
+
return <TInput>(schema: Schema<string, TInput>): Schema<string, TInput> =>
|
|
159
|
+
refine(
|
|
160
|
+
schema,
|
|
161
|
+
(input: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(input),
|
|
162
|
+
"email",
|
|
163
|
+
"expected email address",
|
|
164
|
+
{ kind: "format", name: "email" },
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** A URL the platform's own parser accepts, so the parse is the check. */
|
|
169
|
+
export function url(): Step<string, string> {
|
|
170
|
+
return <TInput>(schema: Schema<string, TInput>): Schema<string, TInput> =>
|
|
171
|
+
refine(
|
|
172
|
+
schema,
|
|
173
|
+
(input: string) => {
|
|
174
|
+
try {
|
|
175
|
+
return new URL(input) != null;
|
|
176
|
+
} catch {
|
|
177
|
+
return false;
|
|
178
|
+
}
|
|
179
|
+
},
|
|
180
|
+
"url",
|
|
181
|
+
"expected a URL",
|
|
182
|
+
{ kind: "format", name: "uri" },
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
187
|
+
|
|
188
|
+
/** A UUID with a version and a variant, as RFC 9562 defines them. */
|
|
189
|
+
export function uuid(): Step<string, string> {
|
|
190
|
+
return <TInput>(schema: Schema<string, TInput>): Schema<string, TInput> =>
|
|
191
|
+
refine(schema, (input: string) => UUID.test(input), "uuid", "expected a UUID", {
|
|
192
|
+
kind: "format",
|
|
193
|
+
name: "uuid",
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const ISO_DATE = /^(\d{4})-(\d{2})-(\d{2})$/;
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* A calendar date as `YYYY-MM-DD`.
|
|
201
|
+
*
|
|
202
|
+
* The shape is not enough: `2026-02-30` matches the pattern and is not a day.
|
|
203
|
+
* The value is round-tripped through `Date` and compared back, which rejects
|
|
204
|
+
* every month that is shorter than the payload thought.
|
|
205
|
+
*/
|
|
206
|
+
export function isoDate(): Step<string, string> {
|
|
207
|
+
return <TInput>(schema: Schema<string, TInput>): Schema<string, TInput> =>
|
|
208
|
+
refine(
|
|
209
|
+
schema,
|
|
210
|
+
(input: string) => {
|
|
211
|
+
const parts = ISO_DATE.exec(input);
|
|
212
|
+
if (parts == null) {
|
|
213
|
+
return false;
|
|
214
|
+
}
|
|
215
|
+
const when = new Date(`${input}T00:00:00Z`);
|
|
216
|
+
return Number.isFinite(when.getTime()) && when.toISOString().slice(0, 10) === input;
|
|
217
|
+
},
|
|
218
|
+
"iso_date",
|
|
219
|
+
"expected a date as YYYY-MM-DD",
|
|
220
|
+
{ kind: "format", name: "date" },
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Whitespace off both ends, before whatever comes next in the pipeline. */
|
|
225
|
+
export function trim(): Step<string, string> {
|
|
226
|
+
return transform((input: string) => input.trim());
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export function toLowerCase(): Step<string, string> {
|
|
230
|
+
return transform((input: string) => input.toLowerCase());
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export function toUpperCase(): Step<string, string> {
|
|
234
|
+
return transform((input: string) => input.toUpperCase());
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** At least `value`. */
|
|
238
|
+
export function min(value: number): Step<number, number> {
|
|
239
|
+
return <TInput>(schema: Schema<number, TInput>): Schema<number, TInput> =>
|
|
240
|
+
refine(schema, (input: number) => input >= value, "min", `expected at least ${String(value)}`, {
|
|
241
|
+
kind: "min",
|
|
242
|
+
value,
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** At most `value`. */
|
|
247
|
+
export function max(value: number): Step<number, number> {
|
|
248
|
+
return <TInput>(schema: Schema<number, TInput>): Schema<number, TInput> =>
|
|
249
|
+
refine(schema, (input: number) => input <= value, "max", `expected at most ${String(value)}`, {
|
|
250
|
+
kind: "max",
|
|
251
|
+
value,
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export function integer(): Step<number, number> {
|
|
256
|
+
return <TInput>(schema: Schema<number, TInput>): Schema<number, TInput> =>
|
|
257
|
+
refine(schema, (input: number) => Number.isInteger(input), "integer", "expected an integer", {
|
|
258
|
+
kind: "integer",
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* A multiple of `value`.
|
|
264
|
+
*
|
|
265
|
+
* The remainder is compared with a tolerance rather than against zero, because
|
|
266
|
+
* `0.3 % 0.1` is `0.09999999999999998` and a step of `0.1` on a price field is
|
|
267
|
+
* the reason anybody asks for this.
|
|
268
|
+
*/
|
|
269
|
+
export function multipleOf(value: number): Step<number, number> {
|
|
270
|
+
return <TInput>(schema: Schema<number, TInput>): Schema<number, TInput> =>
|
|
271
|
+
refine(
|
|
272
|
+
schema,
|
|
273
|
+
(input: number) => {
|
|
274
|
+
const remainder = Math.abs(input % value);
|
|
275
|
+
return remainder < 1e-9 || Math.abs(remainder - Math.abs(value)) < 1e-9;
|
|
276
|
+
},
|
|
277
|
+
"multiple_of",
|
|
278
|
+
`expected a multiple of ${String(value)}`,
|
|
279
|
+
{ kind: "multipleOf", value },
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** At least `value` items. Arrays, where `minLength` is for strings. */
|
|
284
|
+
export function minItems<TItem>(value: number): Step<$ReadOnlyArray<TItem>, $ReadOnlyArray<TItem>> {
|
|
285
|
+
return <TInput>(
|
|
286
|
+
schema: Schema<$ReadOnlyArray<TItem>, TInput>,
|
|
287
|
+
): Schema<$ReadOnlyArray<TItem>, TInput> =>
|
|
288
|
+
refine(
|
|
289
|
+
schema,
|
|
290
|
+
(input: $ReadOnlyArray<TItem>) => input.length >= value,
|
|
291
|
+
"min_items",
|
|
292
|
+
`expected at least ${String(value)} items`,
|
|
293
|
+
{ kind: "minItems", value },
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** At most `value` items. */
|
|
298
|
+
export function maxItems<TItem>(value: number): Step<$ReadOnlyArray<TItem>, $ReadOnlyArray<TItem>> {
|
|
299
|
+
return <TInput>(
|
|
300
|
+
schema: Schema<$ReadOnlyArray<TItem>, TInput>,
|
|
301
|
+
): Schema<$ReadOnlyArray<TItem>, TInput> =>
|
|
302
|
+
refine(
|
|
303
|
+
schema,
|
|
304
|
+
(input: $ReadOnlyArray<TItem>) => input.length <= value,
|
|
305
|
+
"max_items",
|
|
306
|
+
`expected at most ${String(value)} items`,
|
|
307
|
+
{ kind: "maxItems", value },
|
|
308
|
+
);
|
|
309
|
+
}
|
package/collection.js
ADDED
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// `@uniflowed/validator/collection`: containers whose contents are only known
|
|
4
|
+
// when the value arrives.
|
|
5
|
+
//
|
|
6
|
+
// `object.js` names its keys when the schema is written. These five do not:
|
|
7
|
+
// an array has as many items as the payload has, a record has whatever keys it
|
|
8
|
+
// was sent, a map and a set have whatever they were built with. So the walk is
|
|
9
|
+
// the same in all five — visit every child, keep the ones that parsed, collect
|
|
10
|
+
// the issues from the ones that did not, and report all of them — and the only
|
|
11
|
+
// thing that differs is what the container is called and how the result is
|
|
12
|
+
// rebuilt. `tuple` is here rather than beside `object` for the same reason: its
|
|
13
|
+
// positions are its keys.
|
|
14
|
+
//
|
|
15
|
+
// # Every child is visited, including the ones after a failure
|
|
16
|
+
//
|
|
17
|
+
// A parse stops at the first bad field in most libraries. This one does not,
|
|
18
|
+
// because "row 3 is wrong" followed by "row 7 is wrong" on the next attempt is
|
|
19
|
+
// two round trips for information that was available at once — and for a form
|
|
20
|
+
// bound to an array of rows it is two renders where one would do.
|
|
21
|
+
//
|
|
22
|
+
// # Where an issue lands
|
|
23
|
+
//
|
|
24
|
+
// An array or tuple item is its index, as a decimal string: `["items", "2"]`.
|
|
25
|
+
// A record value is its key. A set element is its position in iteration order,
|
|
26
|
+
// which is insertion order.
|
|
27
|
+
//
|
|
28
|
+
// A map is the one that needs two segments. An entry can fail because its key
|
|
29
|
+
// was wrong or because its value was, and `["3", "key"]` against `["3",
|
|
30
|
+
// "value"]` says which — where a single index would have left the caller
|
|
31
|
+
// guessing at exactly the moment it needed to know.
|
|
32
|
+
//
|
|
33
|
+
// # What crosses a wire, and what does not
|
|
34
|
+
//
|
|
35
|
+
// `map` and `set` do not survive `JSON.stringify`, and `json-schema.js`
|
|
36
|
+
// reports them as unrepresentable rather than pretending. They are here
|
|
37
|
+
// because not every boundary is JSON: a value coming out of `structuredClone`,
|
|
38
|
+
// out of IndexedDB, or from another module in the same process is a real value
|
|
39
|
+
// with a real shape, and a validator that could only describe JSON would be a
|
|
40
|
+
// JSON validator.
|
|
41
|
+
|
|
42
|
+
import type { ItemsInput, ItemsOutput, Options } from "./infer.js";
|
|
43
|
+
import type { Issue } from "./issue.js";
|
|
44
|
+
import { isPlainObject, ownKeys, ownValue, plainRecord, put } from "./plain-object.js";
|
|
45
|
+
import type { Description, Result, Schema } from "./schema.js";
|
|
46
|
+
import {
|
|
47
|
+
collectAsync,
|
|
48
|
+
describe,
|
|
49
|
+
fail,
|
|
50
|
+
isAsync,
|
|
51
|
+
makeAsyncSchema,
|
|
52
|
+
makeSchema,
|
|
53
|
+
mergeIssues,
|
|
54
|
+
ok,
|
|
55
|
+
runAt,
|
|
56
|
+
runUnder,
|
|
57
|
+
} from "./schema.js";
|
|
58
|
+
|
|
59
|
+
/** Every item of an array, each parsed by `item`. */
|
|
60
|
+
export function array<TOutput, TInput>(
|
|
61
|
+
item: Schema<TOutput, TInput>,
|
|
62
|
+
): Schema<$ReadOnlyArray<TOutput>, $ReadOnlyArray<TInput>> {
|
|
63
|
+
const description = (): Description => ({ kind: "array", item: describe(item) });
|
|
64
|
+
|
|
65
|
+
if (isAsync(item)) {
|
|
66
|
+
return makeAsyncSchema(async (value, path) => {
|
|
67
|
+
if (!Array.isArray(value)) {
|
|
68
|
+
return fail("type", "expected array", path);
|
|
69
|
+
}
|
|
70
|
+
const collected = await collectAsync(
|
|
71
|
+
value.map((entry, index) => ({ keys: [String(index)], schema: item, value: entry })),
|
|
72
|
+
path,
|
|
73
|
+
);
|
|
74
|
+
if (!collected.ok) {
|
|
75
|
+
return { ok: false, issues: collected.issues };
|
|
76
|
+
}
|
|
77
|
+
// $FlowFixMe[incompatible-type] every element came out of `item`.
|
|
78
|
+
return ok(collected.values as $ReadOnlyArray<TOutput>);
|
|
79
|
+
}, description);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return makeSchema((value, path) => {
|
|
83
|
+
if (!Array.isArray(value)) {
|
|
84
|
+
return fail("type", "expected array", path);
|
|
85
|
+
}
|
|
86
|
+
const out: Array<TOutput> = [];
|
|
87
|
+
let issues: null | Array<Issue> = null;
|
|
88
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
89
|
+
const result = runAt(item, value[index], path, String(index));
|
|
90
|
+
if (result.ok) {
|
|
91
|
+
out.push(result.value);
|
|
92
|
+
} else {
|
|
93
|
+
issues = issues ?? [];
|
|
94
|
+
mergeIssues(issues, result);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return issues == null ? ok(out) : { ok: false, issues };
|
|
98
|
+
}, description);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* A fixed number of positions, each with a schema of its own.
|
|
103
|
+
*
|
|
104
|
+
* The arity is part of the type: a payload with one item too many is rejected
|
|
105
|
+
* rather than truncated, because a tuple whose length varied would be an array
|
|
106
|
+
* with extra steps.
|
|
107
|
+
*/
|
|
108
|
+
export function tuple<TItems extends Options>(
|
|
109
|
+
items: TItems,
|
|
110
|
+
): Schema<ItemsOutput<TItems>, ItemsInput<TItems>> {
|
|
111
|
+
const arity = items.length;
|
|
112
|
+
const message = `expected ${String(arity)} tuple items`;
|
|
113
|
+
const description = (): Description => ({
|
|
114
|
+
kind: "tuple",
|
|
115
|
+
items: items.map((item) => describe(item)),
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
if (items.some((item) => isAsync(item))) {
|
|
119
|
+
return makeAsyncSchema(async (value, path) => {
|
|
120
|
+
if (!Array.isArray(value)) {
|
|
121
|
+
return fail("type", "expected tuple", path);
|
|
122
|
+
}
|
|
123
|
+
if (value.length !== arity) {
|
|
124
|
+
return fail("length", message, path);
|
|
125
|
+
}
|
|
126
|
+
const collected = await collectAsync(
|
|
127
|
+
items.map((item, index) => ({ keys: [String(index)], schema: item, value: value[index] })),
|
|
128
|
+
path,
|
|
129
|
+
);
|
|
130
|
+
if (!collected.ok) {
|
|
131
|
+
return { ok: false, issues: collected.issues };
|
|
132
|
+
}
|
|
133
|
+
// $FlowFixMe[incompatible-type] position by position, each item's own schema built it.
|
|
134
|
+
return ok(collected.values as ItemsOutput<TItems>);
|
|
135
|
+
}, description);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return makeSchema((value, path) => {
|
|
139
|
+
if (!Array.isArray(value)) {
|
|
140
|
+
return fail("type", "expected tuple", path);
|
|
141
|
+
}
|
|
142
|
+
if (value.length !== arity) {
|
|
143
|
+
return fail("length", message, path);
|
|
144
|
+
}
|
|
145
|
+
const out: Array<mixed> = [];
|
|
146
|
+
let issues: null | Array<Issue> = null;
|
|
147
|
+
for (let index = 0; index < arity; index += 1) {
|
|
148
|
+
const result = runAt(items[index], value[index], path, String(index));
|
|
149
|
+
if (result.ok) {
|
|
150
|
+
out.push(result.value);
|
|
151
|
+
} else {
|
|
152
|
+
issues = issues ?? [];
|
|
153
|
+
mergeIssues(issues, result);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
// $FlowFixMe[incompatible-type] position by position, each item's own schema built it.
|
|
157
|
+
return issues == null ? ok(out as ItemsOutput<TItems>) : { ok: false, issues };
|
|
158
|
+
}, description);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* An object whose keys are not known ahead of time.
|
|
163
|
+
*
|
|
164
|
+
* Only own enumerable keys are read, so a payload carrying `__proto__` or
|
|
165
|
+
* `constructor` cannot smuggle an inherited value into the parsed result.
|
|
166
|
+
*/
|
|
167
|
+
export function record<TOutput, TInput>(
|
|
168
|
+
value: Schema<TOutput, TInput>,
|
|
169
|
+
): Schema<{ readonly [string]: TOutput, ... }, { readonly [string]: TInput, ... }> {
|
|
170
|
+
const description = (): Description => ({ kind: "record", value: describe(value) });
|
|
171
|
+
|
|
172
|
+
if (isAsync(value)) {
|
|
173
|
+
return makeAsyncSchema(async (input, path) => {
|
|
174
|
+
if (!isPlainObject(input)) {
|
|
175
|
+
return fail("type", "expected object", path);
|
|
176
|
+
}
|
|
177
|
+
const source = plainRecord(input);
|
|
178
|
+
const keys = ownKeys(source);
|
|
179
|
+
const collected = await collectAsync(
|
|
180
|
+
keys.map((key) => ({ keys: [key], schema: value, value: ownValue(source, key) })),
|
|
181
|
+
path,
|
|
182
|
+
);
|
|
183
|
+
if (!collected.ok) {
|
|
184
|
+
return { ok: false, issues: collected.issues };
|
|
185
|
+
}
|
|
186
|
+
const out: { [string]: TOutput, ... } = {};
|
|
187
|
+
keys.forEach((key, index) => {
|
|
188
|
+
// $FlowFixMe[incompatible-type] every value came out of `value`.
|
|
189
|
+
put(out, key, collected.values[index] as TOutput);
|
|
190
|
+
});
|
|
191
|
+
return ok(out);
|
|
192
|
+
}, description);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return makeSchema((input, path) => {
|
|
196
|
+
if (!isPlainObject(input)) {
|
|
197
|
+
return fail("type", "expected object", path);
|
|
198
|
+
}
|
|
199
|
+
const source = plainRecord(input);
|
|
200
|
+
const out: { [string]: TOutput, ... } = {};
|
|
201
|
+
let issues: null | Array<Issue> = null;
|
|
202
|
+
for (const key of ownKeys(source)) {
|
|
203
|
+
const result = runAt(value, ownValue(source, key), path, key);
|
|
204
|
+
if (result.ok) {
|
|
205
|
+
put(out, key, result.value);
|
|
206
|
+
} else {
|
|
207
|
+
issues = issues ?? [];
|
|
208
|
+
mergeIssues(issues, result);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return issues == null ? ok(out) : { ok: false, issues };
|
|
212
|
+
}, description);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* A `Map`, with a schema for its keys and one for its values.
|
|
217
|
+
*
|
|
218
|
+
* Rebuilt rather than checked in place, because a `key` schema may be a `pipe`
|
|
219
|
+
* that changes the key — and a map re-keyed in place would collide with itself
|
|
220
|
+
* halfway through.
|
|
221
|
+
*/
|
|
222
|
+
export function map<TKey, TKeyInput, TValue, TValueInput>(
|
|
223
|
+
key: Schema<TKey, TKeyInput>,
|
|
224
|
+
value: Schema<TValue, TValueInput>,
|
|
225
|
+
): Schema<$ReadOnlyMap<TKey, TValue>, $ReadOnlyMap<TKeyInput, TValueInput>> {
|
|
226
|
+
const description = (): Description => ({
|
|
227
|
+
kind: "map",
|
|
228
|
+
key: describe(key),
|
|
229
|
+
value: describe(value),
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
if (isAsync(key) || isAsync(value)) {
|
|
233
|
+
return makeAsyncSchema(async (input, path) => {
|
|
234
|
+
if (!(input instanceof Map)) {
|
|
235
|
+
return fail("type", "expected Map", path);
|
|
236
|
+
}
|
|
237
|
+
const entries = Array.from(input.entries());
|
|
238
|
+
const collected = await collectAsync(
|
|
239
|
+
entries.flatMap(([entryKey, entryValue], index) => [
|
|
240
|
+
{ keys: [String(index), "key"], schema: key, value: entryKey },
|
|
241
|
+
{ keys: [String(index), "value"], schema: value, value: entryValue },
|
|
242
|
+
]),
|
|
243
|
+
path,
|
|
244
|
+
);
|
|
245
|
+
if (!collected.ok) {
|
|
246
|
+
return { ok: false, issues: collected.issues };
|
|
247
|
+
}
|
|
248
|
+
const out = new Map<TKey, TValue>();
|
|
249
|
+
for (let index = 0; index < entries.length; index += 1) {
|
|
250
|
+
// $FlowFixMe[incompatible-type] the jobs were pushed key-then-value, in order.
|
|
251
|
+
out.set(collected.values[index * 2] as TKey, collected.values[index * 2 + 1] as TValue);
|
|
252
|
+
}
|
|
253
|
+
return ok(out);
|
|
254
|
+
}, description);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return makeSchema((input, path) => {
|
|
258
|
+
if (!(input instanceof Map)) {
|
|
259
|
+
return fail("type", "expected Map", path);
|
|
260
|
+
}
|
|
261
|
+
const out = new Map<TKey, TValue>();
|
|
262
|
+
let issues: null | Array<Issue> = null;
|
|
263
|
+
let index = 0;
|
|
264
|
+
for (const [entryKey, entryValue] of input.entries()) {
|
|
265
|
+
const at = String(index);
|
|
266
|
+
const parsedKey = runUnder(key, entryKey, path, [at, "key"]);
|
|
267
|
+
const parsedValue = runUnder(value, entryValue, path, [at, "value"]);
|
|
268
|
+
if (parsedKey.ok && parsedValue.ok) {
|
|
269
|
+
out.set(parsedKey.value, parsedValue.value);
|
|
270
|
+
} else {
|
|
271
|
+
issues = issues ?? [];
|
|
272
|
+
mergeIssues(issues, parsedKey);
|
|
273
|
+
mergeIssues(issues, parsedValue);
|
|
274
|
+
}
|
|
275
|
+
index += 1;
|
|
276
|
+
}
|
|
277
|
+
return issues == null ? ok(out) : { ok: false, issues };
|
|
278
|
+
}, description);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* A `Set`, every member parsed by `item`.
|
|
283
|
+
*
|
|
284
|
+
* A `pipe` on `item` can make two distinct inputs equal — `trim()` over
|
|
285
|
+
* `"a"` and `"a "` — and the rebuilt set then has one member where the input
|
|
286
|
+
* had two. That is what a set is for, and it is worth knowing before it
|
|
287
|
+
* surprises somebody counting rows.
|
|
288
|
+
*/
|
|
289
|
+
export function set<TOutput, TInput>(
|
|
290
|
+
item: Schema<TOutput, TInput>,
|
|
291
|
+
): Schema<$ReadOnlySet<TOutput>, $ReadOnlySet<TInput>> {
|
|
292
|
+
const description = (): Description => ({ kind: "set", item: describe(item) });
|
|
293
|
+
|
|
294
|
+
if (isAsync(item)) {
|
|
295
|
+
return makeAsyncSchema(async (value, path) => {
|
|
296
|
+
if (!(value instanceof Set)) {
|
|
297
|
+
return fail("type", "expected Set", path);
|
|
298
|
+
}
|
|
299
|
+
const collected = await collectAsync(
|
|
300
|
+
Array.from(value).map((member, index) => ({
|
|
301
|
+
keys: [String(index)],
|
|
302
|
+
schema: item,
|
|
303
|
+
value: member,
|
|
304
|
+
})),
|
|
305
|
+
path,
|
|
306
|
+
);
|
|
307
|
+
if (!collected.ok) {
|
|
308
|
+
return { ok: false, issues: collected.issues };
|
|
309
|
+
}
|
|
310
|
+
const out = new Set<TOutput>();
|
|
311
|
+
for (const member of collected.values) {
|
|
312
|
+
// $FlowFixMe[incompatible-type] every member came out of `item`.
|
|
313
|
+
out.add(member as TOutput);
|
|
314
|
+
}
|
|
315
|
+
return ok(out);
|
|
316
|
+
}, description);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
return makeSchema((value, path) => {
|
|
320
|
+
if (!(value instanceof Set)) {
|
|
321
|
+
return fail("type", "expected Set", path);
|
|
322
|
+
}
|
|
323
|
+
const out = new Set<TOutput>();
|
|
324
|
+
let issues: null | Array<Issue> = null;
|
|
325
|
+
let index = 0;
|
|
326
|
+
for (const member of value) {
|
|
327
|
+
const result = runAt(item, member, path, String(index));
|
|
328
|
+
if (result.ok) {
|
|
329
|
+
out.add(result.value);
|
|
330
|
+
} else {
|
|
331
|
+
issues = issues ?? [];
|
|
332
|
+
mergeIssues(issues, result);
|
|
333
|
+
}
|
|
334
|
+
index += 1;
|
|
335
|
+
}
|
|
336
|
+
return issues == null ? ok(out) : { ok: false, issues };
|
|
337
|
+
}, description);
|
|
338
|
+
}
|