@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/index.js
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// `@uniflowed/validator`: a schema is a parser, not an assertion.
|
|
4
|
+
//
|
|
5
|
+
// ```js
|
|
6
|
+
// const Account = object({
|
|
7
|
+
// email: pipe(string(), trim(), email()),
|
|
8
|
+
// age: pipe(string(), transform(Number), integer(), min(18)),
|
|
9
|
+
// tags: array(pipe(string(), nonEmpty())),
|
|
10
|
+
// });
|
|
11
|
+
//
|
|
12
|
+
// const result = safeParse(Account, await request.json());
|
|
13
|
+
// if (!result.ok) {
|
|
14
|
+
// return respond(422, flatten(result.issues));
|
|
15
|
+
// }
|
|
16
|
+
// createAccount(result.value); // { email: string, age: number, tags: … }
|
|
17
|
+
// ```
|
|
18
|
+
//
|
|
19
|
+
// `result.value.age` is a `number` and nobody wrote that down. The schema is
|
|
20
|
+
// the only description of the account that exists, and both the type the form
|
|
21
|
+
// collects and the type the application uses are read off it — that is the
|
|
22
|
+
// whole promise of this package, and `infer.js` is where it is kept.
|
|
23
|
+
//
|
|
24
|
+
// Ordinary Flow-typed JavaScript with no native binding, so it behaves
|
|
25
|
+
// identically on Node.js, Deno and Bun, and every builder is a separate named
|
|
26
|
+
// export so an application ships only the checks it calls.
|
|
27
|
+
//
|
|
28
|
+
// # The four decisions everything else follows from
|
|
29
|
+
//
|
|
30
|
+
// **A schema is a closure, plus a description of itself.** No class, no
|
|
31
|
+
// registry, no interpreter walking a description at run time. The engine that
|
|
32
|
+
// validates a `string()` *is* the four-line closure `string()` returned, which
|
|
33
|
+
// a JIT can inline into the object parser that calls it. The description is a
|
|
34
|
+
// thunk beside it, allocated only when something asks — and it is what makes
|
|
35
|
+
// `toJsonSchema` possible at all, because a closure cannot be read.
|
|
36
|
+
// `schema.js`.
|
|
37
|
+
//
|
|
38
|
+
// **An issue says where it happened.** `["users", "2", "email"]`, as segments,
|
|
39
|
+
// not a string somebody has to parse back apart. A form binds errors to
|
|
40
|
+
// fields, and a field is a path. `issue.js`.
|
|
41
|
+
//
|
|
42
|
+
// **Every branch of the value is visited.** A bad third row does not hide a
|
|
43
|
+
// bad seventh one; one parse reports both, because the alternative is two
|
|
44
|
+
// round trips for information that was available at once. `collection.js`,
|
|
45
|
+
// `object.js`.
|
|
46
|
+
//
|
|
47
|
+
// **Asynchrony is decided when the schema is built, not when it runs.** A
|
|
48
|
+
// schema containing a `checkAsync` is asynchronous from there up, so
|
|
49
|
+
// `safeParse` refuses it with a message instead of returning a promise where
|
|
50
|
+
// its caller expected a result. `schema.js` sets out the alternative and what
|
|
51
|
+
// it would cost every synchronous parse.
|
|
52
|
+
//
|
|
53
|
+
// # How the package is laid out
|
|
54
|
+
//
|
|
55
|
+
// Fifteen modules beside this one. Nothing is under an `internal/`: each is a
|
|
56
|
+
// reasonable thing to import on purpose, and every one is reachable through a
|
|
57
|
+
// subpath so an application that only needs `parse` and `object` can say so.
|
|
58
|
+
//
|
|
59
|
+
// The leaves, which know nothing about schemas:
|
|
60
|
+
//
|
|
61
|
+
// - `plain-object.js` — reading and writing an object whose keys came from
|
|
62
|
+
// outside, without touching its prototype. The package's whole answer to
|
|
63
|
+
// `{"__proto__": …}`, in one place so that it is one answer.
|
|
64
|
+
// - `issue.js` — what a failure is, where it happened, and the error `parse`
|
|
65
|
+
// throws.
|
|
66
|
+
//
|
|
67
|
+
// The kernel:
|
|
68
|
+
//
|
|
69
|
+
// - `schema.js` — what a schema *is*: the opaque type, the two-function
|
|
70
|
+
// kernel, the description it carries, and the walk that runs one. Read this
|
|
71
|
+
// first.
|
|
72
|
+
// - `infer.js` — reading a value's type off its schema. Types only; it
|
|
73
|
+
// compiles to nothing.
|
|
74
|
+
//
|
|
75
|
+
// The vocabulary, one module per kind of thing a schema can be:
|
|
76
|
+
//
|
|
77
|
+
// - `primitive.js` — the leaves: `string`, `number`, `literal`, `enum_`, and
|
|
78
|
+
// the rest of what uf will recognise without being told how.
|
|
79
|
+
// - `object.js` — keys known when the schema was written, in the three
|
|
80
|
+
// flavours that differ only in what happens to an unknown key.
|
|
81
|
+
// - `collection.js` — arrays, tuples, records, maps and sets: containers whose
|
|
82
|
+
// contents are only known when the value arrives.
|
|
83
|
+
// - `union.js` — several schemas over one value: `union`, the discriminated
|
|
84
|
+
// `variant` that gives an error worth reading, and `intersect`.
|
|
85
|
+
// - `optional.js` — a value that might not be there, and what to put there
|
|
86
|
+
// when it is not.
|
|
87
|
+
// - `lazy.js` — a schema that does not exist yet, which is how a comment tree
|
|
88
|
+
// is spelled.
|
|
89
|
+
//
|
|
90
|
+
// The pipeline:
|
|
91
|
+
//
|
|
92
|
+
// - `pipe.js` — a schema with steps after it, the `Step` type, and the
|
|
93
|
+
// overload table that carries the output type through a `transform`.
|
|
94
|
+
// - `action.js` — the steps that come ready-made, each with the constraint
|
|
95
|
+
// that makes it visible to an exporter.
|
|
96
|
+
//
|
|
97
|
+
// The edges:
|
|
98
|
+
//
|
|
99
|
+
// - `parse.js` — the four ways to run a schema, and why there are four.
|
|
100
|
+
// - `json-schema.js` — a schema as a document somebody else can read, and an
|
|
101
|
+
// honest list of what JSON Schema could not say.
|
|
102
|
+
// - `namespace.js` — `v`, the alias that holds every builder. Separate because
|
|
103
|
+
// it is the one module that has to import all of them, and an entry point
|
|
104
|
+
// that did that would make every application carry every check.
|
|
105
|
+
//
|
|
106
|
+
// # Readiness
|
|
107
|
+
//
|
|
108
|
+
// **Implemented and tested.** The schema vocabulary above, including
|
|
109
|
+
// discriminated unions, intersections, recursive schemas, maps and sets;
|
|
110
|
+
// issue paths through every composite; both entry points in both synchronous
|
|
111
|
+
// and asynchronous forms; `InferInput` and `InferOutput` over objects, shapes,
|
|
112
|
+
// tuples, unions, variants and pipelines; a `pipe` that changes the output
|
|
113
|
+
// type with the change surviving into the inferred type; `toJsonSchema` with
|
|
114
|
+
// `$defs` for recursion and a reported list of what it could not express.
|
|
115
|
+
// `tests/library/validator.test.js` covers each of those, including
|
|
116
|
+
// `@uniflowed/form`'s resolver over both a synchronous and an asynchronous
|
|
117
|
+
// schema, and `tests/library/form.test.js` covers that resolver inside a real
|
|
118
|
+
// form.
|
|
119
|
+
//
|
|
120
|
+
// **Experimental.** [`describe`] and the [`Description`] type. The shape is
|
|
121
|
+
// right for `json-schema.js` and it is the shape a code generator should read,
|
|
122
|
+
// but no generator exists yet to prove the second half — see below — so the
|
|
123
|
+
// type may gain cases before it is stable.
|
|
124
|
+
//
|
|
125
|
+
// **Not implemented, deliberately.**
|
|
126
|
+
//
|
|
127
|
+
// - `pick`, `omit` and `required` over a *schema*. These take a shape here or
|
|
128
|
+
// not at all, and `object.js` says why: a built schema is a closure and a
|
|
129
|
+
// description, not a reified field list.
|
|
130
|
+
// - A nominal `brand`. `brand("UserId")` is a name in the description and
|
|
131
|
+
// nothing to the checker, because Flow's opaque types are declared in a
|
|
132
|
+
// module and cannot be produced by a call. `infer.js` says what to write
|
|
133
|
+
// instead when the distinction has to be enforced.
|
|
134
|
+
// - Typed field paths. Flow has no template-literal types; an `Issue`'s `path`
|
|
135
|
+
// is `$ReadOnlyArray<string>` and `@uniflowed/form` makes the same call for
|
|
136
|
+
// the same reason.
|
|
137
|
+
// - The long tail of format checks — `creditCard`, `emoji`, `mac`, `cuid2`.
|
|
138
|
+
// `action.js` says why a stale regular expression in a library is worse than
|
|
139
|
+
// a `check` an application owns.
|
|
140
|
+
// - More than eight `pipe` steps in one call. Flow cannot fold a type over a
|
|
141
|
+
// variadic list, so the arities are spelled out; a ninth step is a type
|
|
142
|
+
// error and the fix is to pipe the result of a pipe.
|
|
143
|
+
//
|
|
144
|
+
// **Not implemented, and a gap.** `uf prepare` lists a
|
|
145
|
+
// `GenerateValidatorTypes` step and nothing implements it: no crate reads a
|
|
146
|
+
// schema and writes Flow types or a JSON Schema file to disk. The half that
|
|
147
|
+
// belongs in this package — a description complete enough to generate from —
|
|
148
|
+
// is here and is exercised by `toJsonSchema`. The build-time half is not
|
|
149
|
+
// written, and this package should not grow it: walking a repository's sources
|
|
150
|
+
// is Rust's job under the same rule that puts the formatter and the checker
|
|
151
|
+
// there.
|
|
152
|
+
//
|
|
153
|
+
// # Measured, against Valibot
|
|
154
|
+
//
|
|
155
|
+
// Valibot 1.4.2 from npm, Node 25.8.1, an Apple M2 Max (12 cores, macOS 26.5),
|
|
156
|
+
// best of five timed runs after two warm-up runs, both libraries given the
|
|
157
|
+
// same payload and schemas built out of the same pieces — an object of seven
|
|
158
|
+
// fields with a nested object, an array of strings, and `minLength`,
|
|
159
|
+
// `maxLength`, `integer`, `min` and `max` steps. The two agree on every case
|
|
160
|
+
// the harness runs, which it asserts before it times anything: the same
|
|
161
|
+
// verdict, the same number of issues, the same transformed value.
|
|
162
|
+
//
|
|
163
|
+
// | Workload | uf | Valibot | |
|
|
164
|
+
// | --- | --- | --- | --- |
|
|
165
|
+
// | 1,000-record list, all valid (per record) | **0.49 µs** | 0.65 µs | 1.34x |
|
|
166
|
+
// | 1,000-record list, one field bad in ten | **0.50 µs** | 0.66 µs | 1.34x |
|
|
167
|
+
// | One form object, four fields, one transform | **0.20 µs** | 0.35 µs | 1.73x |
|
|
168
|
+
// | `safeParse(string(), "ada")` | **5.0 ns** | 15.6 ns | 3.1x |
|
|
169
|
+
// | Building the record schema | **0.47 µs** | 6.5 µs | 14x |
|
|
170
|
+
//
|
|
171
|
+
// The harness is not in the repository — it needs Valibot from npm, and this
|
|
172
|
+
// repository installs no dependency it does not ship — so it lives with the
|
|
173
|
+
// change that produced these numbers. It runs as:
|
|
174
|
+
//
|
|
175
|
+
// UF_PROJECT_ROOT=$PWD node --import ./packages/host/register.js \
|
|
176
|
+
// bench-validator.js path/to/valibot/dist/index.cjs
|
|
177
|
+
//
|
|
178
|
+
// The construction column is the one to read first: a Valibot schema is a
|
|
179
|
+
// tree of objects with an `~run` method and metadata on each node, and this
|
|
180
|
+
// package's is a closure. That is fourteen times cheaper to build and it is
|
|
181
|
+
// why the leaf parse is three times cheaper to run.
|
|
182
|
+
//
|
|
183
|
+
// The row that was *worse* is worth recording too, because finding it is what
|
|
184
|
+
// made the others true. Writing every parsed field with
|
|
185
|
+
// `Object.defineProperty` — which is how this package used to keep a
|
|
186
|
+
// `__proto__` key out of the prototype — cost more than the entire rest of the
|
|
187
|
+
// walk: the list workload took 298 µs per hundred records instead of 49, and
|
|
188
|
+
// this package was 2.4 times *slower* than Valibot rather than 1.34 times
|
|
189
|
+
// faster. `plain-object.js` has the one-line fix and the argument that it
|
|
190
|
+
// keeps the guarantee intact.
|
|
191
|
+
|
|
192
|
+
export type { FlatIssues, Issue, Path } from "./issue.js";
|
|
193
|
+
export type { Constraint, Description, Result, Schema } from "./schema.js";
|
|
194
|
+
export type {
|
|
195
|
+
Infer,
|
|
196
|
+
InferInput,
|
|
197
|
+
InferOutput,
|
|
198
|
+
ItemsInput,
|
|
199
|
+
ItemsOutput,
|
|
200
|
+
Options,
|
|
201
|
+
Shape,
|
|
202
|
+
ShapeInput,
|
|
203
|
+
ShapeOutput,
|
|
204
|
+
} from "./infer.js";
|
|
205
|
+
export type { Pipe, Step } from "./pipe.js";
|
|
206
|
+
export type { JsonSchemaExport, JsonSchemaNode, Unrepresentable } from "./json-schema.js";
|
|
207
|
+
|
|
208
|
+
export { ValidationError, flatten } from "./issue.js";
|
|
209
|
+
export { describe, isAsync } from "./schema.js";
|
|
210
|
+
export {
|
|
211
|
+
bigint,
|
|
212
|
+
boolean,
|
|
213
|
+
custom,
|
|
214
|
+
date,
|
|
215
|
+
enum_,
|
|
216
|
+
instance,
|
|
217
|
+
literal,
|
|
218
|
+
never,
|
|
219
|
+
null_,
|
|
220
|
+
number,
|
|
221
|
+
string,
|
|
222
|
+
undefined_,
|
|
223
|
+
unknown,
|
|
224
|
+
} from "./primitive.js";
|
|
225
|
+
export { looseObject, object, partial, strictObject } from "./object.js";
|
|
226
|
+
export { array, map, record, set, tuple } from "./collection.js";
|
|
227
|
+
export { intersect, union, variant } from "./union.js";
|
|
228
|
+
export { fallback, nullable, nullish, optional, withDefault } from "./optional.js";
|
|
229
|
+
export { lazy, lazyAsync } from "./lazy.js";
|
|
230
|
+
export { brand, check, checkAsync, pipe, refine, transform, transformAsync } from "./pipe.js";
|
|
231
|
+
export {
|
|
232
|
+
email,
|
|
233
|
+
endsWith,
|
|
234
|
+
includes,
|
|
235
|
+
integer,
|
|
236
|
+
isoDate,
|
|
237
|
+
length,
|
|
238
|
+
max,
|
|
239
|
+
maxItems,
|
|
240
|
+
maxLength,
|
|
241
|
+
min,
|
|
242
|
+
minItems,
|
|
243
|
+
minLength,
|
|
244
|
+
multipleOf,
|
|
245
|
+
nonEmpty,
|
|
246
|
+
regex,
|
|
247
|
+
startsWith,
|
|
248
|
+
toLowerCase,
|
|
249
|
+
toUpperCase,
|
|
250
|
+
trim,
|
|
251
|
+
url,
|
|
252
|
+
uuid,
|
|
253
|
+
} from "./action.js";
|
|
254
|
+
export {
|
|
255
|
+
is,
|
|
256
|
+
parse,
|
|
257
|
+
parseAsync,
|
|
258
|
+
parser,
|
|
259
|
+
safeParse,
|
|
260
|
+
safeParseAsync,
|
|
261
|
+
useValidation,
|
|
262
|
+
} from "./parse.js";
|
|
263
|
+
export { toJsonSchema } from "./json-schema.js";
|
|
264
|
+
|
|
265
|
+
export { v } from "./namespace.js";
|
package/infer.js
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// `@uniflowed/validator/infer`: reading a value's type off its schema.
|
|
4
|
+
//
|
|
5
|
+
// This module compiles to nothing. Every name in it is a type, and the reason
|
|
6
|
+
// they are here rather than beside the parsers is that they are the answer to
|
|
7
|
+
// a question the parsers do not ask: given a schema, what is the type of the
|
|
8
|
+
// thing it produces, and what is the type of the thing you may hand it?
|
|
9
|
+
//
|
|
10
|
+
// const Account = object({
|
|
11
|
+
// email: pipe(string(), email()),
|
|
12
|
+
// age: pipe(string(), transform(Number), min(18)),
|
|
13
|
+
// });
|
|
14
|
+
//
|
|
15
|
+
// type Raw = InferInput<typeof Account>; // {| email: string, age: string |}
|
|
16
|
+
// type Account = InferOutput<typeof Account>; // {| email: string, age: number |}
|
|
17
|
+
//
|
|
18
|
+
// Neither type was written down. That is the point: a schema is the single
|
|
19
|
+
// source of truth, and a hand-written `type Account` beside it is a second
|
|
20
|
+
// truth that will disagree with the first on a Tuesday.
|
|
21
|
+
//
|
|
22
|
+
// # What Flow can do here
|
|
23
|
+
//
|
|
24
|
+
// More than this package used to assume. Conditional types with `infer` are
|
|
25
|
+
// what read a parameter back out of `Schema<Out, In>`; mapped types are what
|
|
26
|
+
// turn a shape — an object whose values are schemas — into an object whose
|
|
27
|
+
// values are those schemas' outputs, key by key, without naming the keys.
|
|
28
|
+
// `object`, `partial`, `looseObject`, `tuple`, `union`, `variant` and `record`
|
|
29
|
+
// all infer, so none of them takes an explicit type argument any more, and a
|
|
30
|
+
// field added to a shape appears in the inferred type with nothing else
|
|
31
|
+
// edited.
|
|
32
|
+
//
|
|
33
|
+
// The inferred object types are **exact**. `InferOutput<typeof Account>` does
|
|
34
|
+
// not accept an extra property, which matches what `object()` does at run time
|
|
35
|
+
// — it drops keys the shape does not name — and is why `looseObject` has a
|
|
36
|
+
// separate spelling with `...` in its result.
|
|
37
|
+
//
|
|
38
|
+
// # What it cannot
|
|
39
|
+
//
|
|
40
|
+
// **Paths are not typed.** An `Issue`'s `path` is `$ReadOnlyArray<string>`.
|
|
41
|
+
// Flow has no template-literal types, so there is no way to say "one of the
|
|
42
|
+
// paths that exist in this schema", and a `SchemaPath<S>` alias that was
|
|
43
|
+
// really `string` would be a type that looks like it checks something and does
|
|
44
|
+
// not. `@uniflowed/form` makes the same call for the same reason.
|
|
45
|
+
//
|
|
46
|
+
// **Branded outputs are not nominal.** `pipe(string(), brand("UserId"))` is
|
|
47
|
+
// `Schema<string, string>`. Flow's opaque types are per module and cannot be
|
|
48
|
+
// generated from a call, so the brand is a label in the description — real for
|
|
49
|
+
// `json-schema.js`, and honest about being nothing to the checker. A project
|
|
50
|
+
// that wants `UserId` to be a distinct type should declare
|
|
51
|
+
// `opaque type UserId = string` in the module that owns it and annotate there.
|
|
52
|
+
//
|
|
53
|
+
// **`InferInput` describes shape, not provenance.** It says a valid input to
|
|
54
|
+
// `pipe(string(), transform(Number))` is a `string`. It cannot say that the
|
|
55
|
+
// string has to parse as a number, because that is what the parse is for.
|
|
56
|
+
//
|
|
57
|
+
// **A recursive schema still needs one annotation.** `lazy(() => …)` builds a
|
|
58
|
+
// type that mentions itself, and Flow will not solve for that on its own; the
|
|
59
|
+
// type argument on `lazy` is where the cycle is cut. `lazy.js` has the
|
|
60
|
+
// example.
|
|
61
|
+
|
|
62
|
+
import type { Schema } from "./schema.js";
|
|
63
|
+
|
|
64
|
+
/** The type a schema produces. The one every consumer wants. */
|
|
65
|
+
export type InferOutput<TSchema> = TSchema extends Schema<infer TValue, infer TSource>
|
|
66
|
+
? TValue
|
|
67
|
+
: empty;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The type a valid input to a schema has.
|
|
71
|
+
*
|
|
72
|
+
* Equal to [`InferOutput`] until a `transform` is in the pipeline. Where they
|
|
73
|
+
* differ, this is the one a form's `defaultValues` wants and the other is the
|
|
74
|
+
* one its `onValid` receives.
|
|
75
|
+
*/
|
|
76
|
+
export type InferInput<TSchema> = TSchema extends Schema<infer TValue, infer TSource>
|
|
77
|
+
? TSource
|
|
78
|
+
: empty;
|
|
79
|
+
|
|
80
|
+
/** The older name for [`InferOutput`], kept so existing annotations compile. */
|
|
81
|
+
export type Infer<TSchema> = InferOutput<TSchema>;
|
|
82
|
+
|
|
83
|
+
/** An object whose values are schemas: what `object` and `variant` take. */
|
|
84
|
+
export type Shape = { readonly [string]: Schema<mixed, mixed>, ... };
|
|
85
|
+
|
|
86
|
+
/** A list of schemas: what `union` and `tuple` take. */
|
|
87
|
+
export type Options = $ReadOnlyArray<Schema<mixed, mixed>>;
|
|
88
|
+
|
|
89
|
+
/** The object a shape produces, key by key. */
|
|
90
|
+
export type ShapeOutput<TShape extends Shape> = {
|
|
91
|
+
[Key in keyof TShape]: InferOutput<TShape[Key]>,
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
/** The object a shape accepts, key by key. */
|
|
95
|
+
export type ShapeInput<TShape extends Shape> = {
|
|
96
|
+
[Key in keyof TShape]: InferInput<TShape[Key]>,
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
/** The tuple a list of schemas produces, position by position. */
|
|
100
|
+
export type ItemsOutput<TItems extends Options> = {
|
|
101
|
+
[Index in keyof TItems]: InferOutput<TItems[Index]>,
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
/** The tuple a list of schemas accepts, position by position. */
|
|
105
|
+
export type ItemsInput<TItems extends Options> = {
|
|
106
|
+
[Index in keyof TItems]: InferInput<TItems[Index]>,
|
|
107
|
+
};
|
package/issue.js
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// `@uniflowed/validator/issue`: what a failure is, and where it happened.
|
|
4
|
+
//
|
|
5
|
+
// A validator that answers "no" has told the caller nothing. A form needs to
|
|
6
|
+
// put a message under one input, an HTTP handler needs to name the field in
|
|
7
|
+
// its 422 body, and a log needs to say which row of a thousand was wrong. All
|
|
8
|
+
// three want the same thing: the path from the root of the value to the place
|
|
9
|
+
// that failed, as data.
|
|
10
|
+
//
|
|
11
|
+
// So an issue is `{ code, message, path }` — `["users", "2", "email"]`, not
|
|
12
|
+
// `"users[2].email"`. Segments compose without a grammar: joining them with a
|
|
13
|
+
// dot is one line at the boundary that wants a string, and splitting a string
|
|
14
|
+
// back into segments is a parser nobody should have to write. Array indices
|
|
15
|
+
// are their decimal spelling, because a path is a path whether the container
|
|
16
|
+
// was an object or an array, and a consumer that has to branch on the segment
|
|
17
|
+
// type gains nothing from the distinction.
|
|
18
|
+
//
|
|
19
|
+
// # Why this is separate from the schemas
|
|
20
|
+
//
|
|
21
|
+
// Because nothing here knows what a schema is. `issue.js` is a leaf: it is
|
|
22
|
+
// imported by the kernel, by every combinator that reports a failure, and by
|
|
23
|
+
// `@uniflowed/form`, and it imports one thing itself. Keeping it that way is
|
|
24
|
+
// what lets a consumer translate issues — into field errors, into a response
|
|
25
|
+
// body — without resolving the schema engine at all.
|
|
26
|
+
|
|
27
|
+
import { put } from "./plain-object.js";
|
|
28
|
+
|
|
29
|
+
/** Where an issue happened, as object keys and array indices from the root. */
|
|
30
|
+
export type Path = $ReadOnlyArray<string>;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The mutable buffer the synchronous walk descends with.
|
|
34
|
+
*
|
|
35
|
+
* `schema.js` explains why one array is pushed and popped rather than a fresh
|
|
36
|
+
* array being allocated per field. The type is separate from [`Path`] so that
|
|
37
|
+
* the distinction between "the buffer, which is being mutated right now" and
|
|
38
|
+
* "a path, which is a value" is visible in every signature.
|
|
39
|
+
*/
|
|
40
|
+
export type PathBuffer = Array<string>;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* One reason a value was rejected.
|
|
44
|
+
*
|
|
45
|
+
* `code` is for programs — `"type"`, `"min_length"`, `"unknown_key"` — and is
|
|
46
|
+
* stable across message changes, so a caller can tell "this is not an email
|
|
47
|
+
* address" from "we need an email address" without matching on prose.
|
|
48
|
+
*
|
|
49
|
+
* `path` is absent rather than empty when the issue is about the whole value,
|
|
50
|
+
* because the overwhelmingly common case is a successful parse and an object
|
|
51
|
+
* with one fewer field is one fewer allocation on the path that matters.
|
|
52
|
+
*/
|
|
53
|
+
export type Issue = {|
|
|
54
|
+
readonly code: string,
|
|
55
|
+
readonly message: string,
|
|
56
|
+
readonly path?: Path,
|
|
57
|
+
|};
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* An issue at wherever the walk currently is.
|
|
61
|
+
*
|
|
62
|
+
* The `slice` is the only copy of a path the package makes, and it happens
|
|
63
|
+
* exactly when a value was going to be rejected anyway.
|
|
64
|
+
*/
|
|
65
|
+
export function issue(code: string, message: string, path: Path): Issue {
|
|
66
|
+
return path.length === 0 ? { code, message } : { code, message, path: path.slice() };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** An issue at `path` with `keys` appended: a cross-field rule's landing spot. */
|
|
70
|
+
export function issueUnder(code: string, message: string, path: Path, keys: Path): Issue {
|
|
71
|
+
const at = path.concat(keys);
|
|
72
|
+
return at.length === 0 ? { code, message } : { code, message, path: at };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function describeIssue(entry: Issue): string {
|
|
76
|
+
const at = entry.path == null || entry.path.length === 0 ? "" : ` at ${entry.path.join(".")}`;
|
|
77
|
+
return `${entry.message}${at}`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* What [`parse`] raises.
|
|
82
|
+
*
|
|
83
|
+
* A real `Error` subclass so it survives `instanceof`, logging and a `catch`
|
|
84
|
+
* that only knows about errors, and it carries `issues` so a caller can build
|
|
85
|
+
* a field-by-field response without parsing the message back apart.
|
|
86
|
+
*/
|
|
87
|
+
export class ValidationError extends Error {
|
|
88
|
+
readonly issues: $ReadOnlyArray<Issue>;
|
|
89
|
+
|
|
90
|
+
constructor(issues: $ReadOnlyArray<Issue>) {
|
|
91
|
+
super(issues.map(describeIssue).join("; "));
|
|
92
|
+
this.name = "ValidationError";
|
|
93
|
+
this.issues = issues;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Issues grouped the way a form renders them.
|
|
99
|
+
*
|
|
100
|
+
* `root` is everything that was about the value as a whole; `nested` is keyed
|
|
101
|
+
* by the dotted path, which is the same string `@uniflowed/form`'s `register`
|
|
102
|
+
* was given. Written through [`put`] because a payload's own `__proto__` key
|
|
103
|
+
* reaches this function as a path segment.
|
|
104
|
+
*/
|
|
105
|
+
export type FlatIssues = {|
|
|
106
|
+
readonly root: $ReadOnlyArray<string>,
|
|
107
|
+
readonly nested: { readonly [string]: $ReadOnlyArray<string>, ... },
|
|
108
|
+
|};
|
|
109
|
+
|
|
110
|
+
/** Group `issues` by their path, for a caller that renders per field. */
|
|
111
|
+
export function flatten(issues: $ReadOnlyArray<Issue>): FlatIssues {
|
|
112
|
+
const root: Array<string> = [];
|
|
113
|
+
const nested: { [string]: Array<string>, ... } = {};
|
|
114
|
+
for (const entry of issues) {
|
|
115
|
+
const path = entry.path;
|
|
116
|
+
if (path == null || path.length === 0) {
|
|
117
|
+
root.push(entry.message);
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
const key = path.join(".");
|
|
121
|
+
const already = Object.hasOwn(nested, key) ? nested[key] : null;
|
|
122
|
+
if (already == null) {
|
|
123
|
+
put(nested, key, [entry.message]);
|
|
124
|
+
} else {
|
|
125
|
+
already.push(entry.message);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return { root, nested };
|
|
129
|
+
}
|