@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/object.js ADDED
@@ -0,0 +1,241 @@
1
+ // @flow
2
+ //
3
+ // `@uniflowed/validator/object`: an object whose keys are known when the
4
+ // schema is written.
5
+ //
6
+ // Three of them, and the only difference is what happens to a key the shape
7
+ // does not name:
8
+ //
9
+ // - [`object`] drops it. The right default for reading somebody else's
10
+ // payload, where a new field appearing upstream is not your emergency.
11
+ // - [`strictObject`] rejects it. The right answer for a configuration file or
12
+ // an internal API, where an unrecognised key is almost always a typo the
13
+ // user would rather hear about than have ignored.
14
+ // - [`looseObject`] keeps it. For a boundary that has to forward what it did
15
+ // not understand — a webhook that is re-signed, a document that round-trips
16
+ // through a form and must come back whole.
17
+ //
18
+ // One walk serves all three, because "which keys does the shape name" is the
19
+ // same question in each and only the fourth line of the answer differs.
20
+ //
21
+ // # Why these take a shape and not a schema
22
+ //
23
+ // `partial({ name: string(), age: number() })`, not `partial(User)`. A schema
24
+ // here is a closure and a description, not a reified list of fields, so there
25
+ // is nothing on a built schema for `partial` to take apart — and adding one
26
+ // would mean every object schema carried its shape for the benefit of the
27
+ // callers that reshape it. Naming the shape once and passing it to both is the
28
+ // same amount of typing and keeps the built schema the size of its job:
29
+ //
30
+ // const account = { name: string(), age: number() };
31
+ // const Account = object(account);
32
+ // const Draft = partial(account);
33
+ //
34
+ // Valibot's `pick`, `omit` and `required` are absent for the same reason, and
35
+ // because over a shape they are object literal manipulation the language
36
+ // already has: `object({ name: account.name })` is `pick`.
37
+ //
38
+ // # Reading and writing
39
+ //
40
+ // Every read of an untrusted key goes through `plain-object.js`, and so does
41
+ // every write. `object.js` never touches `source[key]` or `out[key] = …`
42
+ // directly, and the reason is written down there.
43
+
44
+ import type { Shape, ShapeInput, ShapeOutput } from "./infer.js";
45
+ import type { Issue, PathBuffer } from "./issue.js";
46
+ import { issue } from "./issue.js";
47
+ import { isPlainObject, ownKeys, ownValue, plainRecord, put } from "./plain-object.js";
48
+ import type { Description, Result, Schema } from "./schema.js";
49
+ import {
50
+ collectAsync,
51
+ describe,
52
+ fail,
53
+ isAsync,
54
+ makeAsyncSchema,
55
+ makeSchema,
56
+ mergeIssues,
57
+ ok,
58
+ runAt,
59
+ } from "./schema.js";
60
+ import { optional } from "./optional.js";
61
+
62
+ /** What an object schema does with a key its shape does not name. */
63
+ type UnknownKeys = "strip" | "reject" | "keep";
64
+
65
+ /**
66
+ * Pairs of `[key, schema]`, resolved once when the schema is built.
67
+ *
68
+ * `Object.keys(shape)` on every parse re-reads the same descriptors for the
69
+ * life of the process. The shape cannot change after construction, so the walk
70
+ * belongs at construction.
71
+ */
72
+ function shapeEntries(shape: Shape): $ReadOnlyArray<[string, Schema<mixed, mixed>]> {
73
+ return Object.keys(shape).map((key) => [key, shape[key]]);
74
+ }
75
+
76
+ /** Issues for every own key the shape does not name. Empty unless rejecting. */
77
+ function unknownKeyIssues(
78
+ source: { readonly [string]: mixed, ... },
79
+ named: Set<string>,
80
+ path: PathBuffer,
81
+ ): Array<Issue> {
82
+ const issues: Array<Issue> = [];
83
+ for (const key of ownKeys(source)) {
84
+ if (!named.has(key)) {
85
+ path.push(key);
86
+ issues.push(issue("unknown_key", `unexpected key ${key}`, path));
87
+ path.pop();
88
+ }
89
+ }
90
+ return issues;
91
+ }
92
+
93
+ /** Copy every own key the shape does not name into the output, unchecked. */
94
+ function keepUnknownKeys(
95
+ source: { readonly [string]: mixed, ... },
96
+ named: Set<string>,
97
+ out: { [string]: mixed, ... },
98
+ ): void {
99
+ for (const key of ownKeys(source)) {
100
+ if (!named.has(key)) {
101
+ put(out, key, ownValue(source, key));
102
+ }
103
+ }
104
+ }
105
+
106
+ /**
107
+ * The one object parser.
108
+ *
109
+ * `TOutput` and `TInput` are supplied by the three exported spellings; nothing
110
+ * at run time depends on them, which is why one implementation can serve an
111
+ * exact result, a `Partial` one and an inexact one alike.
112
+ */
113
+ function buildObject<TOutput, TInput>(
114
+ shape: Shape,
115
+ unknownKeys: UnknownKeys,
116
+ ): Schema<TOutput, TInput> {
117
+ const entries = shapeEntries(shape);
118
+ const named = new Set(entries.map(([key]) => key));
119
+ const description = (): Description => ({
120
+ kind: "object",
121
+ entries: entries.map(([key, schema]) => [key, describe(schema)]),
122
+ unknownKeys,
123
+ });
124
+
125
+ function assemble(
126
+ source: { readonly [string]: mixed, ... },
127
+ out: { [string]: mixed, ... },
128
+ found: Array<Issue>,
129
+ path: PathBuffer,
130
+ ): Result<TOutput> {
131
+ const issues =
132
+ unknownKeys === "reject" ? found.concat(unknownKeyIssues(source, named, path)) : found;
133
+ if (issues.length > 0) {
134
+ return { ok: false, issues };
135
+ }
136
+ if (unknownKeys === "keep") {
137
+ keepUnknownKeys(source, named, out);
138
+ }
139
+ // $FlowFixMe[incompatible-type] every retained key was produced by the shape.
140
+ return ok(out as TOutput);
141
+ }
142
+
143
+ if (entries.some(([, schema]) => isAsync(schema))) {
144
+ return makeAsyncSchema(async (value, path) => {
145
+ if (!isPlainObject(value)) {
146
+ return fail("type", "expected object", path);
147
+ }
148
+ const source = plainRecord(value);
149
+ const collected = await collectAsync(
150
+ entries.map(([key, schema]) => ({ keys: [key], schema, value: ownValue(source, key) })),
151
+ path,
152
+ );
153
+ const out: { [string]: mixed, ... } = {};
154
+ const found: Array<Issue> = [];
155
+ match (collected) {
156
+ {ok: true, values: const values} => {
157
+ entries.forEach(([key], index) => {
158
+ put(out, key, values[index]);
159
+ });
160
+ }
161
+ {ok: false, issues: const collectedIssues} => {
162
+ for (const entry of collectedIssues) {
163
+ found.push(entry);
164
+ }
165
+ }
166
+ }
167
+ return assemble(source, out, found, path);
168
+ }, description);
169
+ }
170
+
171
+ return makeSchema((value, path) => {
172
+ if (!isPlainObject(value)) {
173
+ return fail("type", "expected object", path);
174
+ }
175
+ const source = plainRecord(value);
176
+ const out: { [string]: mixed, ... } = {};
177
+ const found: Array<Issue> = [];
178
+ for (const [key, schema] of entries) {
179
+ const result = runAt(schema, ownValue(source, key), path, key);
180
+ if (result.ok) {
181
+ put(out, key, result.value);
182
+ } else {
183
+ mergeIssues(found, result);
184
+ }
185
+ }
186
+ return assemble(source, out, found, path);
187
+ }, description);
188
+ }
189
+
190
+ /** An object with exactly the shape's keys; anything else is dropped. */
191
+ export function object<TShape extends Shape>(
192
+ shape: TShape,
193
+ ): Schema<ShapeOutput<TShape>, ShapeInput<TShape>> {
194
+ return buildObject(shape, "strip");
195
+ }
196
+
197
+ /**
198
+ * An object that rejects keys the shape does not name.
199
+ *
200
+ * The unknown-key scan runs whether or not the fields parsed. Returning early
201
+ * on a field failure meant `{ name: 1, extra: true }` reported the wrong type
202
+ * of `name` and said nothing about `extra`, so fixing the first error revealed
203
+ * the second — which is the whole reason this validator collects issues
204
+ * instead of stopping at one.
205
+ */
206
+ export function strictObject<TShape extends Shape>(
207
+ shape: TShape,
208
+ ): Schema<ShapeOutput<TShape>, ShapeInput<TShape>> {
209
+ return buildObject(shape, "reject");
210
+ }
211
+
212
+ /**
213
+ * An object that keeps the keys the shape does not name.
214
+ *
215
+ * The extra keys are in the parsed value and not in its type: they are
216
+ * `mixed`, because nothing validated them. The result type is inexact, which
217
+ * is Flow saying exactly that.
218
+ */
219
+ export function looseObject<TShape extends Shape>(
220
+ shape: TShape,
221
+ ): Schema<{ ...ShapeOutput<TShape>, ... }, { ...ShapeInput<TShape>, ... }> {
222
+ return buildObject(shape, "keep");
223
+ }
224
+
225
+ /**
226
+ * Every field of `shape`, each allowed to be missing.
227
+ *
228
+ * A key that was absent is present in the result holding `undefined`, rather
229
+ * than absent from it. One shape for the parsed value means a consumer reads
230
+ * `draft.name` without asking whether the key exists, and `Partial` says the
231
+ * same thing to the checker.
232
+ */
233
+ export function partial<TShape extends Shape>(
234
+ shape: TShape,
235
+ ): Schema<Partial<ShapeOutput<TShape>>, Partial<ShapeInput<TShape>>> {
236
+ const partialShape: { [string]: Schema<mixed, mixed>, ... } = {};
237
+ for (const key of Object.keys(shape)) {
238
+ put(partialShape, key, optional(shape[key]));
239
+ }
240
+ return buildObject(partialShape, "strip");
241
+ }
package/optional.js ADDED
@@ -0,0 +1,132 @@
1
+ // @flow
2
+ //
3
+ // `@uniflowed/validator/optional`: a value that might not be there.
4
+ //
5
+ // Four wrappers and one rule each, and they are together because the question
6
+ // they answer is one question — what counts as "not there", and what should
7
+ // the parse produce when it happens.
8
+ //
9
+ // optional(string()) // undefined is fine, and stays undefined
10
+ // nullable(string()) // null is fine, and stays null
11
+ // nullish(string()) // either is fine
12
+ // withDefault(string(), "") // undefined becomes ""
13
+ // fallback(number(), 8080) // *anything* that fails becomes 8080
14
+ //
15
+ // `undefined` and `null` are kept apart on purpose. A missing key and a key
16
+ // explicitly set to null mean different things in every wire format uf reads —
17
+ // a PATCH body, a GraphQL response, a form that cleared a field — and a
18
+ // validator that folded them together would hand the application a value it
19
+ // could no longer tell apart. `nullish` is there for the boundary that really
20
+ // does not care.
21
+ //
22
+ // # Where the input type stops matching the output type
23
+ //
24
+ // [`withDefault`] and [`fallback`] are the two schemas whose `InferInput` is
25
+ // genuinely wider than their `InferOutput`: a default's input may be missing
26
+ // and its output never is, and a fallback accepts literally anything. That is
27
+ // not a quirk of the typing, it is what those two are for, and it is the case
28
+ // `InferInput` exists to describe.
29
+
30
+ import type { Description, Result, Schema } from "./schema.js";
31
+ import { describe, isAsync, makeAsyncSchema, makeSchema, ok, run, runAsync } from "./schema.js";
32
+
33
+ /**
34
+ * Build a wrapper that answers for some inputs itself and delegates the rest.
35
+ *
36
+ * `shortcut` returns a result to use as-is, or null to mean "ask the inner
37
+ * schema". Every wrapper in this module is that shape, and writing it once is
38
+ * also what keeps the asynchronous variants from being four more copies of the
39
+ * same three lines.
40
+ */
41
+ function wrap<TOutput, TInput>(
42
+ inner: Schema<TOutput, mixed>,
43
+ shortcut: (value: mixed) => null | Result<TOutput>,
44
+ description: () => Description,
45
+ ): Schema<TOutput, TInput> {
46
+ if (isAsync(inner)) {
47
+ return makeAsyncSchema((value, path) => {
48
+ const answered = shortcut(value);
49
+ return answered == null ? runAsync(inner, value, path) : Promise.resolve(answered);
50
+ }, description);
51
+ }
52
+ return makeSchema((value, path) => {
53
+ const answered = shortcut(value);
54
+ return answered == null ? run(inner, value, path) : answered;
55
+ }, description);
56
+ }
57
+
58
+ /** `undefined` passes through; anything else goes to `schema`. */
59
+ export function optional<TOutput, TInput>(
60
+ schema: Schema<TOutput, TInput>,
61
+ ): Schema<void | TOutput, void | TInput> {
62
+ return wrap(
63
+ schema,
64
+ (value) => (value === undefined ? ok(undefined) : null),
65
+ () => ({ kind: "optional", inner: describe(schema) }),
66
+ );
67
+ }
68
+
69
+ /** `null` passes through; anything else goes to `schema`. */
70
+ export function nullable<TOutput, TInput>(
71
+ schema: Schema<TOutput, TInput>,
72
+ ): Schema<null | TOutput, null | TInput> {
73
+ return wrap(
74
+ schema,
75
+ (value) => (value === null ? ok(null) : null),
76
+ () => ({ kind: "nullable", inner: describe(schema) }),
77
+ );
78
+ }
79
+
80
+ /** Either `null` or `undefined` passes through, unchanged. */
81
+ export function nullish<TOutput, TInput>(
82
+ schema: Schema<TOutput, TInput>,
83
+ ): Schema<null | void | TOutput, null | void | TInput> {
84
+ return wrap(
85
+ schema,
86
+ (value) => (value == null ? ok(value === null ? null : undefined) : null),
87
+ () => ({ kind: "nullish", inner: describe(schema) }),
88
+ );
89
+ }
90
+
91
+ /**
92
+ * `undefined` becomes `value`; anything else goes to `schema`.
93
+ *
94
+ * The default is not validated. It is a value the program wrote, in the
95
+ * program's own types, and running it back through the parser would only be a
96
+ * chance for the two to disagree.
97
+ */
98
+ export function withDefault<TOutput, TInput>(
99
+ schema: Schema<TOutput, TInput>,
100
+ value: TOutput,
101
+ ): Schema<TOutput, void | TInput> {
102
+ return wrap(
103
+ schema,
104
+ (input) => (input === undefined ? ok(value) : null),
105
+ () => ({ kind: "default", inner: describe(schema) }),
106
+ );
107
+ }
108
+
109
+ /**
110
+ * A schema that never fails, substituting `value` when the inner one does.
111
+ *
112
+ * For the boundary where a bad field should not sink the whole payload — a
113
+ * cached response, a user preference — and where the alternative is a
114
+ * `safeParse` and a hand-written `if` at every call site. Its input type is
115
+ * `mixed`, because that is the truth: it accepts everything.
116
+ */
117
+ export function fallback<TOutput>(
118
+ schema: Schema<TOutput, mixed>,
119
+ value: TOutput,
120
+ ): Schema<TOutput, mixed> {
121
+ const description = (): Description => ({ kind: "fallback", inner: describe(schema) });
122
+ if (isAsync(schema)) {
123
+ return makeAsyncSchema(async (input, path) => {
124
+ const result = await runAsync(schema, input, path);
125
+ return result.ok ? result : ok(value);
126
+ }, description);
127
+ }
128
+ return makeSchema((input, path) => {
129
+ const result = run(schema, input, path);
130
+ return result.ok ? result : ok(value);
131
+ }, description);
132
+ }
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "@uniflowed/validator",
3
+ "version": "0.0.0-alpha.10",
4
+ "description": "Schemas that parse rather than assert: typed inference, issue paths and JSON Schema export, part of the Unified Toolchain for Flow.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "sideEffects": false,
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/ubugeeei-prod/uf.git",
11
+ "directory": "packages/validator"
12
+ },
13
+ "exports": {
14
+ ".": "./index.js",
15
+ "./action": "./action.js",
16
+ "./collection": "./collection.js",
17
+ "./infer": "./infer.js",
18
+ "./issue": "./issue.js",
19
+ "./json-schema": "./json-schema.js",
20
+ "./lazy": "./lazy.js",
21
+ "./namespace": "./namespace.js",
22
+ "./object": "./object.js",
23
+ "./optional": "./optional.js",
24
+ "./parse": "./parse.js",
25
+ "./pipe": "./pipe.js",
26
+ "./plain-object": "./plain-object.js",
27
+ "./primitive": "./primitive.js",
28
+ "./schema": "./schema.js",
29
+ "./union": "./union.js"
30
+ },
31
+ "files": [
32
+ "*.js"
33
+ ]
34
+ }
package/parse.js ADDED
@@ -0,0 +1,116 @@
1
+ // @flow
2
+ //
3
+ // `@uniflowed/validator/parse`: the four ways to run a schema.
4
+ //
5
+ // safeParse(User, body) // a Result: failure is a value
6
+ // parse(User, body) // the value, or a thrown ValidationError
7
+ // await safeParseAsync(…) // the same, for a schema that has to wait
8
+ // await parseAsync(…)
9
+ //
10
+ // Two axes, and both of them are a real choice rather than a style.
11
+ //
12
+ // # Throwing or not
13
+ //
14
+ // A boundary that already has a failure path — an HTTP handler building a 422,
15
+ // a form collecting field errors — wants [`safeParse`], because a `throw` there
16
+ // is a control-flow detour to reach a value it was going to inspect anyway.
17
+ // Code with no failure path — a configuration file the process cannot start
18
+ // without, a fixture in a test — wants [`parse`], because the alternative is an
19
+ // `if (!result.ok) throw` at every call site.
20
+ //
21
+ // [`parse`] raises [`ValidationError`], which carries the structured issues
22
+ // rather than only a joined message. An HTTP handler needs the field paths to
23
+ // build a response body, and re-parsing them out of a string is not a thing an
24
+ // API should make anybody do.
25
+ //
26
+ // # Waiting or not
27
+ //
28
+ // A schema is asynchronous, or it is not, and it knew which when it was built
29
+ // — `schema.js` says why. So the synchronous entry points do not return a
30
+ // promise sometimes: given a schema with a `checkAsync` in it they throw an
31
+ // `Error` naming the asynchronous pair, which is a programmer's mistake
32
+ // reported as one. It is not a [`ValidationError`], because nothing was
33
+ // invalid.
34
+ //
35
+ // The asynchronous entry points accept both kinds. A schema with nothing to
36
+ // wait for resolves on the first microtask, so a caller that does not know
37
+ // which it has — a generic resolver, a request handler taking a schema from a
38
+ // route table — can always use them.
39
+
40
+ import type { Result, Schema } from "./schema.js";
41
+ import { runAsync, run } from "./schema.js";
42
+ import { ValidationError } from "./issue.js";
43
+
44
+ /** Parse into a result, so failure is a value rather than control flow. */
45
+ export function safeParse<TOutput>(schema: Schema<TOutput, mixed>, value: mixed): Result<TOutput> {
46
+ return run(schema, value, []);
47
+ }
48
+
49
+ /** Parse, or raise a [`ValidationError`] carrying every issue found. */
50
+ export function parse<TOutput>(schema: Schema<TOutput, mixed>, value: mixed): TOutput {
51
+ const result = safeParse(schema, value);
52
+ if (result.ok) {
53
+ return result.value;
54
+ }
55
+ throw new ValidationError(result.issues);
56
+ }
57
+
58
+ /** [`safeParse`], for a schema with something to wait for. */
59
+ export function safeParseAsync<TOutput>(
60
+ schema: Schema<TOutput, mixed>,
61
+ value: mixed,
62
+ ): Promise<Result<TOutput>> {
63
+ return runAsync(schema, value, []);
64
+ }
65
+
66
+ /** [`parse`], for a schema with something to wait for. */
67
+ export async function parseAsync<TOutput>(
68
+ schema: Schema<TOutput, mixed>,
69
+ value: mixed,
70
+ ): Promise<TOutput> {
71
+ const result = await safeParseAsync(schema, value);
72
+ if (result.ok) {
73
+ return result.value;
74
+ }
75
+ throw new ValidationError(result.issues);
76
+ }
77
+
78
+ /**
79
+ * Whether `value` would parse.
80
+ *
81
+ * A `boolean` and not a type guard. Flow's `value is T` needs the predicate to
82
+ * be provable from the function's body, and here the proof is a closure the
83
+ * checker cannot see through; a guard would be a claim rather than a check.
84
+ * Narrow with [`safeParse`] and read `result.value`, which is the same
85
+ * information with the value attached.
86
+ */
87
+ export function is(schema: Schema<mixed, mixed>, value: mixed): boolean {
88
+ return safeParse(schema, value).ok;
89
+ }
90
+
91
+ /**
92
+ * A schema as a standalone function.
93
+ *
94
+ * `safeParse(schema, value)` needs both halves at the call site, which is fine
95
+ * where the schema is in scope and useless where it is not — a boundary that
96
+ * wants to validate what arrives takes a *function*, not a schema and an
97
+ * import of this package. `parser(User)` is that function, and it is why
98
+ * `@uniflowed/fetch` can check a response body without depending on the
99
+ * validator at all.
100
+ */
101
+ export function parser<TOutput>(schema: Schema<TOutput, mixed>): (value: mixed) => Result<TOutput> {
102
+ return (value: mixed) => safeParse(schema, value);
103
+ }
104
+
105
+ /**
106
+ * Validate a value during render.
107
+ *
108
+ * A hook rather than a plain call so the React Compiler memoises it with the
109
+ * rest of the component: re-rendering for an unrelated reason does not re-walk
110
+ * the payload. It is here rather than in a module of its own because it is
111
+ * [`safeParse`] at a render boundary and not a second idea — and because
112
+ * nothing in this package imports React to provide it.
113
+ */
114
+ export hook useValidation<TOutput>(schema: Schema<TOutput, mixed>, value: mixed): Result<TOutput> {
115
+ return safeParse(schema, value);
116
+ }