@uniflowed/validator 0.0.0-alpha.2

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