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