@uniflowed/validator 0.0.0-alpha.4 → 0.0.0-alpha.6

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 CHANGED
@@ -1,783 +1,265 @@
1
1
  // @flow
2
2
  //
3
- // `@uniflowed/validator`: schemas as ordinary Flow-typed JavaScript, so they
4
- // work identically on Node.js, Deno and Bun with no native binding.
3
+ // `@uniflowed/validator`: a schema is a parser, not an assertion.
5
4
  //
6
- // # Why the package is one file
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
+ // });
7
11
  //
8
- // A schema library is one idea, so it is one module, and that module is the
9
- // entry point. The primitives, the combinators that nest them, the `pipe`
10
- // steps that refine them, and the two ways to run one are not four subjects
11
- // that happen to ship together: they are four views of the same closure.
12
- // `object` calls the same walk `array` does, `pipe` wraps the kernel every
13
- // primitive returns, and `parse` is `safeParse` plus a throw.
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
+ // ```
14
18
  //
15
- // The shape this replaced was `index.js` re-exporting a sibling that held all
16
- // of it. That arrangement costs a reader two files to find the first line of
17
- // code and tells them nothing in exchange the directory said "there is
18
- // structure here" where there was one subject and a list. A package with one
19
- // concept should be a file with that concept in it, and the exports below are
20
- // named individually so an application still ships only the checks it calls.
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.
21
23
  //
22
- // # Why every schema is one closure
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.
23
27
  //
24
- // A schema is a `parse` function and nothing else. No class, no registry, no
25
- // interpreter walking a description of a schema at runtime. The engine that
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
26
32
  // validates a `string()` *is* the four-line closure `string()` returned, which
27
- // a JIT can inline into the object parser that calls it. It is also why the
28
- // module tree-shakes: a project that never calls `date()` never ships the Date
29
- // check, because there is no table holding a reference to it.
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`.
30
37
  //
31
- // # Why the path is a mutable buffer
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`.
32
41
  //
33
- // Issues report where they happened `["users", "2", "email"]` and the
34
- // obvious way to carry that is a fresh array per field, `path.concat(key)`.
35
- // That allocates once per field per parse, on the *successful* path, to
36
- // produce a value almost every parse throws away. Instead one array is pushed
37
- // and popped as the walk descends, and only an actual issue copies it. A
38
- // thousand-row payload that validates cleanly allocates no paths at all.
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`.
39
46
  //
40
- // # Why parse throws a typed error
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.
41
52
  //
42
- // `parse` raises [`ValidationError`], which carries the structured issues, not
43
- // just a joined message. A caller writing an HTTP handler needs the field
44
- // paths to build a response body, and re-parsing them out of a string is not
45
- // a thing an API should make anyone do.
46
-
47
- /** Where an issue happened, as object keys and array indices from the root. */
48
- type Path = $ReadOnlyArray<string>;
49
-
50
- /** The mutable buffer the walk descends with. See the module docs. */
51
- type PathBuffer = Array<string>;
52
-
53
- type SchemaKernel<T> = {|
54
- readonly parse: (mixed, PathBuffer) => Result<T>,
55
- |};
56
-
57
- type SchemaCarrier<T> = {|
58
- readonly __kind: "Schema",
59
- readonly __type: (T) => T,
60
- readonly __kernel: SchemaKernel<T>,
61
- |};
62
-
63
- export opaque type Schema<T> = SchemaCarrier<T>;
64
-
65
- export type Issue = {|
66
- readonly code: string,
67
- readonly message: string,
68
- readonly path?: Path,
69
- |};
70
-
71
- export type Result<T> =
72
- | {| readonly ok: true, readonly value: T |}
73
- | {| readonly ok: false, readonly issues: $ReadOnlyArray<Issue> |};
74
-
75
- export type Step<TIn, TOut> = (schema: Schema<TIn>) => Schema<TOut>;
76
-
77
- export type Shape = { readonly [string]: Schema<mixed>, ... };
78
- export type Infer<TSchema> = TSchema extends Schema<infer T> ? T : empty;
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
- function describeIssue(entry: Issue): string {
98
- const at = entry.path == null || entry.path.length === 0 ? "" : ` at ${entry.path.join(".")}`;
99
- return `${entry.message}${at}`;
100
- }
101
-
102
- function makeSchema<T>(kernel: SchemaKernel<T>): Schema<T> {
103
- return { __kind: "Schema", __type: (value) => value, __kernel: kernel };
104
- }
105
-
106
- function readKernel<T>(schema: Schema<T>): SchemaKernel<T> {
107
- return schema.__kernel;
108
- }
109
-
110
- function issue(code: string, message: string, path: PathBuffer): Issue {
111
- return path.length === 0 ? { code, message } : { code, message, path: path.slice() };
112
- }
113
-
114
- function ok<T>(value: T): Result<T> {
115
- return { ok: true, value };
116
- }
117
-
118
- function failIssue(code: string, message: string, path: PathBuffer): Result<empty> {
119
- return { ok: false, issues: [issue(code, message, path)] };
120
- }
121
-
122
- function mergeIssues(issues: Array<Issue>, result: Result<mixed>): void {
123
- match (result) {
124
- {ok: false, issues: const nextIssues} => {
125
- for (const entry of nextIssues) {
126
- issues.push(entry);
127
- }
128
- }
129
- _ => {}
130
- }
131
- }
132
-
133
- function parseInternal<T>(schema: Schema<T>, value: mixed, path: PathBuffer): Result<T> {
134
- return readKernel(schema).parse(value, path);
135
- }
136
-
137
- /**
138
- * Parse `value` at one step deeper in the path.
139
- *
140
- * The push/pop pair is why the buffer stays balanced even when a nested schema
141
- * returns early: nothing between them can throw except user code inside a
142
- * `transform` or a `check`, and a schema that raised has already failed the
143
- * whole parse.
144
- */
145
- function parseAt<T>(schema: Schema<T>, value: mixed, path: PathBuffer, key: string): Result<T> {
146
- path.push(key);
147
- const result = parseInternal(schema, value, path);
148
- path.pop();
149
- return result;
150
- }
151
-
152
- function refine<T>(
153
- schema: Schema<T>,
154
- check: (T) => boolean,
155
- code: string,
156
- message: string,
157
- ): Schema<T> {
158
- return makeSchema({
159
- parse: (value, path) => {
160
- const result = parseInternal(schema, value, path);
161
- if (!result.ok) {
162
- return result;
163
- }
164
- return check(result.value) ? result : failIssue(code, message, path);
165
- },
166
- });
167
- }
168
-
169
- function isPlainObject(value: mixed): boolean {
170
- return value != null && typeof value === "object" && !Array.isArray(value);
171
- }
172
-
173
- function plainRecord(value: mixed): { readonly [string]: mixed, ... } {
174
- // This is the single object boundary in the validator kernel. Every caller checks
175
- // `isPlainObject` first, then schemas validate each exported field before exposing T.
176
- // $FlowFixMe[incompatible-type]
177
- return value as { readonly [string]: mixed, ... };
178
- }
179
-
180
- /**
181
- * Write a parsed field into the object being built.
182
- *
183
- * `out[key] = value` runs a setter when `key` is `__proto__`, so an input
184
- * carrying that key would change the object's prototype instead of adding a
185
- * field — and everything downstream would then read attacker-chosen values
186
- * from a prototype it never inspected. `defineProperty` writes an own
187
- * property whatever the key is called.
188
- */
189
- function put<Value>(out: { [string]: Value, ... }, key: string, value: Value): void {
190
- Object.defineProperty(out, key, {
191
- value,
192
- writable: true,
193
- enumerable: true,
194
- configurable: true,
195
- });
196
- }
197
-
198
- export function string(): Schema<string> {
199
- return makeSchema({
200
- parse: (value, path) =>
201
- typeof value === "string" ? ok(value) : failIssue("type", "expected string", path),
202
- });
203
- }
204
-
205
- /**
206
- * A finite number.
207
- *
208
- * `NaN` and the infinities are rejected. They are numbers to `typeof` and
209
- * disasters to arithmetic, and a validator that lets `NaN` through has not
210
- * validated anything — every comparison downstream silently answers `false`.
211
- */
212
- export function number(): Schema<number> {
213
- return makeSchema({
214
- parse: (value, path) =>
215
- typeof value === "number" && Number.isFinite(value)
216
- ? ok(value)
217
- : failIssue("type", "expected number", path),
218
- });
219
- }
220
-
221
- export function boolean(): Schema<boolean> {
222
- return makeSchema({
223
- parse: (value, path) =>
224
- typeof value === "boolean" ? ok(value) : failIssue("type", "expected boolean", path),
225
- });
226
- }
227
-
228
- export function unknown(): Schema<mixed> {
229
- return makeSchema({ parse: (value) => ok(value) });
230
- }
231
-
232
- export function literal<T extends string | number | boolean | null>(expected: T): Schema<T> {
233
- return makeSchema({
234
- parse: (value, path) =>
235
- value === expected
236
- ? ok(expected)
237
- : failIssue("literal", `expected ${String(expected)}`, path),
238
- });
239
- }
240
-
241
- export function enum_<T extends string>(values: $ReadOnlyArray<T>): Schema<T> {
242
- const message = `expected one of ${values.join(", ")}`;
243
- return makeSchema({
244
- parse: (value, path) => {
245
- for (const option of values) {
246
- if (value === option) {
247
- return ok(option);
248
- }
249
- }
250
- return failIssue("enum", message, path);
251
- },
252
- });
253
- }
254
-
255
- export function array<Item>(item: Schema<Item>): Schema<$ReadOnlyArray<Item>> {
256
- return makeSchema({
257
- parse: (value, path) => {
258
- if (!Array.isArray(value)) {
259
- return failIssue("type", "expected array", path);
260
- }
261
- const out: Array<Item> = [];
262
- let issues: null | Array<Issue> = null;
263
- for (let index = 0; index < value.length; index += 1) {
264
- const result = parseAt(item, value[index], path, String(index));
265
- if (result.ok) {
266
- out.push(result.value);
267
- } else {
268
- issues = issues ?? [];
269
- mergeIssues(issues, result);
270
- }
271
- }
272
- return issues == null ? ok(out) : { ok: false, issues };
273
- },
274
- });
275
- }
276
-
277
- export function tuple<TItems extends $ReadOnlyArray<Schema<mixed>>>(
278
- items: TItems,
279
- ): Schema<$ReadOnlyArray<mixed>> {
280
- const arity = items.length;
281
- return makeSchema({
282
- parse: (value, path) => {
283
- if (!Array.isArray(value)) {
284
- return failIssue("type", "expected tuple", path);
285
- }
286
- if (value.length !== arity) {
287
- return failIssue("length", `expected ${String(arity)} tuple items`, path);
288
- }
289
- const out: Array<mixed> = [];
290
- let issues: null | Array<Issue> = null;
291
- for (let index = 0; index < arity; index += 1) {
292
- const result = parseAt(items[index], value[index], path, String(index));
293
- if (result.ok) {
294
- out.push(result.value);
295
- } else {
296
- issues = issues ?? [];
297
- mergeIssues(issues, result);
298
- }
299
- }
300
- return issues == null ? ok(out) : { ok: false, issues };
301
- },
302
- });
303
- }
304
-
305
- /**
306
- * An object whose keys are not known ahead of time.
307
- *
308
- * Only own enumerable keys are read, so a payload carrying `__proto__` or
309
- * `constructor` cannot smuggle an inherited value into the parsed result.
310
- */
311
- export function record<Value>(value: Schema<Value>): Schema<{ readonly [string]: Value, ... }> {
312
- return makeSchema({
313
- parse: (input, path) => {
314
- if (!isPlainObject(input)) {
315
- return failIssue("type", "expected object", path);
316
- }
317
- const source = plainRecord(input);
318
- const out: { [string]: Value, ... } = {};
319
- let issues: null | Array<Issue> = null;
320
- for (const key of Object.keys(source)) {
321
- const result = parseAt(value, source[key], path, key);
322
- if (result.ok) {
323
- put(out, key, result.value);
324
- } else {
325
- issues = issues ?? [];
326
- mergeIssues(issues, result);
327
- }
328
- }
329
- // $FlowFixMe[incompatible-type] every retained key went through `value`.
330
- return issues == null ? ok(out) : { ok: false, issues };
331
- },
332
- });
333
- }
334
-
335
- /**
336
- * The first schema that accepts the value.
337
- *
338
- * When none do, every branch's issues are reported, because there is no way to
339
- * know which branch the author meant. That is also why [`variant`] exists: a
340
- * discriminated union can know, and its errors say one useful thing instead of
341
- * every possible thing.
342
- */
343
- export function union<T>(schemas: $ReadOnlyArray<Schema<T>>): Schema<T> {
344
- return makeSchema({
345
- parse: (value, path) => {
346
- const issues: Array<Issue> = [];
347
- for (const schema of schemas) {
348
- const result = parseInternal(schema, value, path);
349
- if (result.ok) {
350
- return result;
351
- }
352
- mergeIssues(issues, result);
353
- }
354
- return { ok: false, issues };
355
- },
356
- });
357
- }
358
-
359
- /**
360
- * A union chosen by the value of one key.
361
- *
362
- * The discriminant is read first and the matching branch is the only one run,
363
- * so an invalid `{ kind: "circle", radius: "big" }` reports `expected number
364
- * at radius` rather than every reason it is not a square, a triangle and a
365
- * line as well. Unmatched discriminants name the ones that exist.
366
- */
367
- export function variant<T>(
368
- key: string,
369
- branches: { readonly [string]: Schema<T>, ... },
370
- ): Schema<T> {
371
- const known = Object.keys(branches);
372
- return makeSchema({
373
- parse: (value, path) => {
374
- if (!isPlainObject(value)) {
375
- return failIssue("type", "expected object", path);
376
- }
377
- const discriminant = plainRecord(value)[key];
378
- if (typeof discriminant !== "string" || !Object.hasOwn(branches, discriminant)) {
379
- path.push(key);
380
- const failed = failIssue("variant", `expected one of ${known.join(", ")}`, path);
381
- path.pop();
382
- return failed;
383
- }
384
- return parseInternal(branches[discriminant], value, path);
385
- },
386
- });
387
- }
388
-
389
- /**
390
- * Pairs of `[key, schema]`, resolved once when the object schema is built.
391
- *
392
- * `for (const key in shape)` on every parse walks the prototype chain and
393
- * re-reads the same descriptors for the life of the process. The shape cannot
394
- * change after construction, so the walk belongs at construction.
395
- */
396
- function shapeEntries(shape: Shape): $ReadOnlyArray<[string, Schema<mixed>]> {
397
- return Object.keys(shape).map((key) => [key, shape[key]]);
398
- }
399
-
400
- function schemaObject<T extends { ... }>(shape: Shape): Schema<T> {
401
- const entries = shapeEntries(shape);
402
- return makeSchema({
403
- parse: (value, path) => {
404
- if (!isPlainObject(value)) {
405
- return failIssue("type", "expected object", path);
406
- }
407
- const record = plainRecord(value);
408
- const out: { [string]: mixed, ... } = {};
409
- let issues: null | Array<Issue> = null;
410
- for (const [key, schema] of entries) {
411
- const result = parseAt(schema, record[key], path, key);
412
- if (result.ok) {
413
- put(out, key, result.value);
414
- } else {
415
- issues = issues ?? [];
416
- mergeIssues(issues, result);
417
- }
418
- }
419
- // $FlowFixMe[incompatible-type] shape parsers have constructed every output field.
420
- return issues == null ? ok(out as T) : { ok: false, issues };
421
- },
422
- });
423
- }
424
-
425
- /**
426
- * An object that rejects keys the shape does not name.
427
- *
428
- * `object()` drops unknown keys silently, which is right for a tolerant reader
429
- * of someone else's payload. `strictObject()` is for the other case — a
430
- * configuration file, an internal API — where an unrecognised key is almost
431
- * always a typo the user would rather hear about than have ignored.
432
- */
433
- export function strictObject<T extends { ... }>(shape: Shape): Schema<T> {
434
- const inner = schemaObject<T>(shape);
435
- const allowed = new Set(Object.keys(shape));
436
- return makeSchema({
437
- parse: (value, path) => {
438
- const result = parseInternal(inner, value, path);
439
- if (!isPlainObject(value)) {
440
- return result;
441
- }
442
-
443
- // The unknown-key scan runs whether or not the fields parsed. Returning
444
- // early on a field failure meant `{ name: 1, extra: true }` reported the
445
- // wrong type of `name` and said nothing about `extra`, so fixing the
446
- // first error revealed the second — which is the whole reason this
447
- // validator collects issues instead of stopping at one.
448
- const issues: Array<Issue> = [];
449
- if (!result.ok) {
450
- mergeIssues(issues, result);
451
- }
452
- for (const key of Object.keys(plainRecord(value))) {
453
- if (!allowed.has(key)) {
454
- path.push(key);
455
- issues.push(issue("unknown_key", `unexpected key ${key}`, path));
456
- path.pop();
457
- }
458
- }
459
- return issues.length === 0 ? result : { ok: false, issues };
460
- },
461
- });
462
- }
463
-
464
- export function partial<T extends { ... }>(shape: Shape): Schema<Partial<T>> {
465
- const partialShape: { [string]: Schema<mixed>, ... } = {};
466
- for (const key of Object.keys(shape)) {
467
- partialShape[key] = optional(shape[key]);
468
- }
469
- return schemaObject<Partial<T>>(partialShape);
470
- }
471
-
472
- export { schemaObject as object };
473
-
474
- /**
475
- * A schema built on first use.
476
- *
477
- * The one way to write a recursive type: a comment tree cannot name its own
478
- * schema in its own initialiser, but it can name a function that returns it.
479
- * The result is memoised, so recursion costs one closure, not one per node.
480
- */
481
- export function lazy<T>(build: () => Schema<T>): Schema<T> {
482
- let built: null | Schema<T> = null;
483
- return makeSchema({
484
- parse: (value, path) => {
485
- if (built == null) {
486
- built = build();
487
- }
488
- return parseInternal(built, value, path);
489
- },
490
- });
491
- }
492
-
493
- export function optional<T>(schema: Schema<T>): Schema<void | T> {
494
- return makeSchema({
495
- parse: (value, path) =>
496
- value === undefined ? ok(undefined) : parseInternal(schema, value, path),
497
- });
498
- }
499
-
500
- export function nullable<T>(schema: Schema<T>): Schema<null | T> {
501
- return makeSchema({
502
- parse: (value, path) => (value === null ? ok(null) : parseInternal(schema, value, path)),
503
- });
504
- }
505
-
506
- /**
507
- * A schema that never fails, substituting `value` when the inner one does.
508
- *
509
- * For the boundary where a bad field should not sink the whole payload — a
510
- * cached response, a user preference — and where the alternative is a
511
- * `safeParse` and a hand-written `if` at every call site.
512
- */
513
- export function fallback<T>(schema: Schema<T>, value: T): Schema<T> {
514
- return makeSchema({
515
- parse: (input, path) => {
516
- const result = parseInternal(schema, input, path);
517
- return result.ok ? result : ok(value);
518
- },
519
- });
520
- }
521
-
522
- /**
523
- * Apply steps to a schema, left to right.
524
- *
525
- * Variadic, because refinement is cumulative in practice — an email is a
526
- * string that is long enough *and* looks like an address — and forcing
527
- * `pipe(pipe(pipe(...)))` for that is a tax on the common case.
528
- */
529
- export function pipe<A>(schema: Schema<A>, ...steps: $ReadOnlyArray<Step<any, any>>): Schema<any> {
530
- let piped: Schema<any> = schema;
531
- for (const step of steps) {
532
- piped = step(piped);
533
- }
534
- return piped;
535
- }
536
-
537
- export function transform<A, B>(change: (value: A) => B): Step<A, B> {
538
- return (schema) =>
539
- makeSchema({
540
- parse: (value, path) => {
541
- const result = parseInternal(schema, value, path);
542
- if (!result.ok) {
543
- return result;
544
- }
545
- return ok(change(result.value));
546
- },
547
- });
548
- }
549
-
550
- /**
551
- * An arbitrary predicate, with the message it should report.
552
- *
553
- * Every other step in this module is a special case of this one. It exists so
554
- * that a rule the library did not anticipate — a checksum, a business rule,
555
- * one field agreeing with another — is a one-liner rather than a reason to
556
- * abandon the schema and hand-roll validation.
557
- */
558
- export function check<T>(predicate: (value: T) => boolean, message: string): Step<T, T> {
559
- return (schema) => refine(schema, predicate, "check", message);
560
- }
561
-
562
- /**
563
- * A nominal marker on an otherwise ordinary value.
564
- *
565
- * It does not check anything at runtime, and it is not pretending to: the
566
- * point is the name in the schema, so that `UserId` and `PostId` read
567
- * differently at the call site even though both are strings underneath.
568
- */
569
- export function brand<T, Name extends string>(name: Name): Step<T, T> {
570
- return (schema) => refine(schema, () => true, "brand", `expected brand ${name}`);
571
- }
572
-
573
- export function minLength(value: number): Step<string, string> {
574
- return (schema) =>
575
- refine(
576
- schema,
577
- (input) => input.length >= value,
578
- "min_length",
579
- `expected at least ${String(value)} characters`,
580
- );
581
- }
582
-
583
- export function maxLength(value: number): Step<string, string> {
584
- return (schema) =>
585
- refine(
586
- schema,
587
- (input) => input.length <= value,
588
- "max_length",
589
- `expected at most ${String(value)} characters`,
590
- );
591
- }
592
-
593
- export function startsWith(value: string): Step<string, string> {
594
- return (schema) =>
595
- refine(schema, (input) => input.startsWith(value), "starts_with", `expected prefix ${value}`);
596
- }
597
-
598
- export function endsWith(value: string): Step<string, string> {
599
- return (schema) =>
600
- refine(schema, (input) => input.endsWith(value), "ends_with", `expected suffix ${value}`);
601
- }
602
-
603
- /**
604
- * A string matching `pattern`.
605
- *
606
- * The pattern is tested with `RegExp.prototype.test` against a fresh `lastIndex`
607
- * every time, because a caller who reaches for `/g` would otherwise get a
608
- * schema that alternates between accepting and rejecting the same input.
609
- */
610
- export function regex(pattern: RegExp, message?: string): Step<string, string> {
611
- return (schema) =>
612
- refine(
613
- schema,
614
- (input) => {
615
- pattern.lastIndex = 0;
616
- return pattern.test(input);
617
- },
618
- "regex",
619
- message ?? `expected a match for ${String(pattern)}`,
620
- );
621
- }
622
-
623
- export function trim(): Step<string, string> {
624
- return transform((input: string) => input.trim());
625
- }
626
-
627
- export function email(): Step<string, string> {
628
- return (schema) =>
629
- refine(schema, (input) => /.+@.+\..+/.test(input), "email", "expected email address");
630
- }
631
-
632
- export function min(value: number): Step<number, number> {
633
- return (schema) =>
634
- refine(schema, (input) => input >= value, "min", `expected at least ${String(value)}`);
635
- }
636
-
637
- export function max(value: number): Step<number, number> {
638
- return (schema) =>
639
- refine(schema, (input) => input <= value, "max", `expected at most ${String(value)}`);
640
- }
641
-
642
- export function integer(): Step<number, number> {
643
- return (schema) => refine(schema, Number.isInteger, "integer", "expected an integer");
644
- }
645
-
646
- export function date(): Schema<Date> {
647
- return makeSchema({
648
- parse: (value, path) =>
649
- value instanceof Date && Number.isFinite(value.getTime())
650
- ? ok(value)
651
- : failIssue("type", "expected Date", path),
652
- });
653
- }
654
-
655
- export function instance<T>(ClassValue: Class<T>): Schema<T> {
656
- return makeSchema({
657
- parse: (value, path) =>
658
- value instanceof ClassValue ? ok(value as T) : failIssue("type", "expected instance", path),
659
- });
660
- }
661
-
662
- /** Parse, or raise a [`ValidationError`] carrying every issue found. */
663
- export function parse<T>(schema: Schema<T>, value: mixed): T {
664
- const result = safeParse(schema, value);
665
- if (result.ok) {
666
- return result.value;
667
- }
668
- throw new ValidationError(result.issues);
669
- }
670
-
671
- /** Parse into a result, so failure is a value rather than control flow. */
672
- /**
673
- * A schema as a standalone function.
674
- *
675
- * `safeParse(schema, value)` needs both halves at the call site, which is fine
676
- * where the schema is in scope and useless where it is not — a boundary that
677
- * wants to validate what arrives takes a *function*, not a schema and an
678
- * import of this module. `parser(User)` is that function, and it is why
679
- * `@uniflowed/fetch` can check a response body without depending on the
680
- * validator at all.
681
- */
682
- export function parser<T>(schema: Schema<T>): (value: mixed) => Result<T> {
683
- return (value: mixed) => safeParse(schema, value);
684
- }
685
-
686
- export function safeParse<T>(schema: Schema<T>, value: mixed): Result<T> {
687
- return parseInternal(schema, value, []);
688
- }
689
-
690
- /**
691
- * Validate a value during render.
692
- *
693
- * A hook rather than a plain call so the React Compiler memoises it with the
694
- * rest of the component: re-rendering for an unrelated reason does not re-walk
695
- * the payload.
696
- */
697
- export hook useValidation<T>(schema: Schema<T>, value: mixed): Result<T> {
698
- return safeParse(schema, value);
699
- }
700
-
701
- /**
702
- * Every builder under one name, for callers who prefer `v.string()`.
703
- *
704
- * The named exports are the primary surface — they are what tree-shakes — and
705
- * this is the convenience alias, typed by `typeof` so the two can never drift.
706
- */
707
- export const v: {
708
- readonly string: typeof string,
709
- readonly number: typeof number,
710
- readonly boolean: typeof boolean,
711
- readonly unknown: typeof unknown,
712
- readonly literal: typeof literal,
713
- readonly enum: typeof enum_,
714
- readonly array: typeof array,
715
- readonly tuple: typeof tuple,
716
- readonly record: typeof record,
717
- readonly union: typeof union,
718
- readonly variant: typeof variant,
719
- readonly object: typeof schemaObject,
720
- readonly strictObject: typeof strictObject,
721
- readonly partial: typeof partial,
722
- readonly lazy: typeof lazy,
723
- readonly optional: typeof optional,
724
- readonly nullable: typeof nullable,
725
- readonly fallback: typeof fallback,
726
- readonly pipe: typeof pipe,
727
- readonly transform: typeof transform,
728
- readonly check: typeof check,
729
- readonly brand: typeof brand,
730
- readonly minLength: typeof minLength,
731
- readonly maxLength: typeof maxLength,
732
- readonly startsWith: typeof startsWith,
733
- readonly endsWith: typeof endsWith,
734
- readonly regex: typeof regex,
735
- readonly trim: typeof trim,
736
- readonly email: typeof email,
737
- readonly min: typeof min,
738
- readonly max: typeof max,
739
- readonly integer: typeof integer,
740
- readonly date: typeof date,
741
- readonly instance: typeof instance,
742
- readonly parse: typeof parse,
743
- readonly safeParse: typeof safeParse,
744
- readonly useValidation: typeof useValidation,
745
- } = {
746
- string,
747
- number,
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,
748
212
  boolean,
749
- unknown,
213
+ custom,
214
+ date,
215
+ enum_,
216
+ instance,
750
217
  literal,
751
- enum: enum_,
752
- array,
753
- tuple,
754
- record,
755
- union,
756
- variant,
757
- object: schemaObject,
758
- strictObject,
759
- partial,
760
- lazy,
761
- optional,
762
- nullable,
763
- fallback,
764
- pipe,
765
- transform,
766
- check,
767
- brand,
768
- minLength,
769
- maxLength,
770
- startsWith,
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,
771
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,
772
246
  regex,
247
+ startsWith,
248
+ toLowerCase,
249
+ toUpperCase,
773
250
  trim,
774
- email,
775
- min,
776
- max,
777
- integer,
778
- date,
779
- instance,
251
+ url,
252
+ uuid,
253
+ } from "./action.js";
254
+ export {
255
+ is,
780
256
  parse,
257
+ parseAsync,
258
+ parser,
781
259
  safeParse,
260
+ safeParseAsync,
782
261
  useValidation,
783
- };
262
+ } from "./parse.js";
263
+ export { toJsonSchema } from "./json-schema.js";
264
+
265
+ export { v } from "./namespace.js";