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