@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/primitive.js ADDED
@@ -0,0 +1,183 @@
1
+ // @flow
2
+ //
3
+ // `@uniflowed/validator/primitive`: the leaves.
4
+ //
5
+ // A schema that does not contain another schema. Every one of them is the same
6
+ // four lines — test the value, hand it back or say what was wanted — and they
7
+ // are together because that sameness is the whole subject: this is the list of
8
+ // things uf will recognise without being told how.
9
+ //
10
+ // Each is a function rather than a constant so that a project ships only the
11
+ // checks it called. `string` is not a value in a table somewhere that a
12
+ // bundler has to keep because the table is reachable.
13
+
14
+ import { plainRecord } from "./plain-object.js";
15
+ import type { Description, Schema } from "./schema.js";
16
+ import { fail, makeSchema, ok } from "./schema.js";
17
+
18
+ const stringly = (): Description => ({ kind: "string" });
19
+
20
+ export function string(): Schema<string, string> {
21
+ return makeSchema(
22
+ (value, path) =>
23
+ typeof value === "string" ? ok(value) : fail("type", "expected string", path),
24
+ stringly,
25
+ );
26
+ }
27
+
28
+ /**
29
+ * A finite number.
30
+ *
31
+ * `NaN` and the infinities are rejected. They are numbers to `typeof` and
32
+ * disasters to arithmetic, and a validator that lets `NaN` through has not
33
+ * validated anything — every comparison downstream silently answers `false`.
34
+ */
35
+ export function number(): Schema<number, number> {
36
+ return makeSchema(
37
+ (value, path) =>
38
+ typeof value === "number" && Number.isFinite(value)
39
+ ? ok(value)
40
+ : fail("type", "expected number", path),
41
+ () => ({ kind: "number" }),
42
+ );
43
+ }
44
+
45
+ /**
46
+ * A `bigint`.
47
+ *
48
+ * Separate from [`number`] because the two do not mix: `1n === 1` is false,
49
+ * `1n + 1` throws, and `JSON.stringify` refuses. A schema that accepted either
50
+ * would hand its caller a value whose arithmetic depends on the payload.
51
+ */
52
+ export function bigint(): Schema<bigint, bigint> {
53
+ return makeSchema(
54
+ (value, path) =>
55
+ typeof value === "bigint" ? ok(value) : fail("type", "expected bigint", path),
56
+ () => ({ kind: "bigint" }),
57
+ );
58
+ }
59
+
60
+ export function boolean(): Schema<boolean, boolean> {
61
+ return makeSchema(
62
+ (value, path) =>
63
+ typeof value === "boolean" ? ok(value) : fail("type", "expected boolean", path),
64
+ () => ({ kind: "boolean" }),
65
+ );
66
+ }
67
+
68
+ /** Anything at all, unexamined. The identity of this package. */
69
+ export function unknown(): Schema<mixed, mixed> {
70
+ return makeSchema(
71
+ (value) => ok(value),
72
+ () => ({ kind: "unknown" }),
73
+ );
74
+ }
75
+
76
+ /**
77
+ * Nothing at all.
78
+ *
79
+ * For the branch of a union that must not be reachable, and for a shape whose
80
+ * field is being removed: `never()` says so at the boundary instead of leaving
81
+ * a field that quietly still works.
82
+ */
83
+ export function never(): Schema<empty, empty> {
84
+ return makeSchema(
85
+ (value, path) => fail("never", "expected nothing here", path),
86
+ () => ({ kind: "never" }),
87
+ );
88
+ }
89
+
90
+ /** Exactly `null`. Distinct from a missing key, which is [`optional`]. */
91
+ export function null_(): Schema<null, null> {
92
+ return makeSchema(
93
+ (value, path) => (value === null ? ok(null) : fail("type", "expected null", path)),
94
+ () => ({ kind: "null" }),
95
+ );
96
+ }
97
+
98
+ /** Exactly `undefined`. */
99
+ export function undefined_(): Schema<void, void> {
100
+ return makeSchema(
101
+ (value, path) =>
102
+ value === undefined ? ok(undefined) : fail("type", "expected undefined", path),
103
+ () => ({ kind: "undefined" }),
104
+ );
105
+ }
106
+
107
+ export function literal<TValue extends string | number | boolean | null>(
108
+ expected: TValue,
109
+ ): Schema<TValue, TValue> {
110
+ return makeSchema(
111
+ (value, path) =>
112
+ value === expected ? ok(expected) : fail("literal", `expected ${String(expected)}`, path),
113
+ () => ({ kind: "literal", value: expected }),
114
+ );
115
+ }
116
+
117
+ /**
118
+ * One of a fixed list of strings.
119
+ *
120
+ * The message names every option, because a rejected enum is almost always a
121
+ * typo and the fix is in the list the caller could not see.
122
+ */
123
+ export function enum_<TValue extends string>(
124
+ values: $ReadOnlyArray<TValue>,
125
+ ): Schema<TValue, TValue> {
126
+ const message = `expected one of ${values.join(", ")}`;
127
+ return makeSchema(
128
+ (value, path) => {
129
+ for (const option of values) {
130
+ if (value === option) {
131
+ return ok(option);
132
+ }
133
+ }
134
+ return fail("enum", message, path);
135
+ },
136
+ () => ({ kind: "enum", values: values.slice() }),
137
+ );
138
+ }
139
+
140
+ /** A `Date` that is a date, rather than the `Invalid Date` a bad string makes. */
141
+ export function date(): Schema<Date, Date> {
142
+ return makeSchema(
143
+ (value, path) =>
144
+ value instanceof Date && Number.isFinite(value.getTime())
145
+ ? ok(value)
146
+ : fail("type", "expected Date", path),
147
+ () => ({ kind: "date" }),
148
+ );
149
+ }
150
+
151
+ /** An instance of `ClassValue`, by `instanceof`. */
152
+ export function instance<TValue>(ClassValue: Class<TValue>): Schema<TValue, TValue> {
153
+ const named = plainRecord(ClassValue).name;
154
+ const name = typeof named === "string" ? named : "instance";
155
+ return makeSchema(
156
+ (value, path) =>
157
+ value instanceof ClassValue ? ok(value as TValue) : fail("type", `expected ${name}`, path),
158
+ () => ({ kind: "instance", name }),
159
+ );
160
+ }
161
+
162
+ /**
163
+ * A leaf this package has no name for.
164
+ *
165
+ * The escape hatch, and the one place a caller's word is taken for a type:
166
+ * `accepts` returning true is what makes the value a `TValue`, and nothing
167
+ * checks that claim. Use it for a value with a shape of its own — a
168
+ * `Uint8Array`, a branded id from another library — and reach for [`check`]
169
+ * instead when the value is an ordinary type with a rule attached, because
170
+ * that keeps the type honest.
171
+ */
172
+ export function custom<TValue>(
173
+ accepts: (value: mixed) => boolean,
174
+ message: string,
175
+ name: string = "custom",
176
+ ): Schema<TValue, TValue> {
177
+ return makeSchema(
178
+ (value, path) =>
179
+ // $FlowFixMe[incompatible-type] `accepts` is the caller's promise, not a proof.
180
+ accepts(value) ? ok(value as TValue) : fail("custom", message, path),
181
+ () => ({ kind: "custom", name }),
182
+ );
183
+ }
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
+ }