@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/parse.js ADDED
@@ -0,0 +1,116 @@
1
+ // @flow
2
+ //
3
+ // `@uniflowed/validator/parse`: the four ways to run a schema.
4
+ //
5
+ // safeParse(User, body) // a Result: failure is a value
6
+ // parse(User, body) // the value, or a thrown ValidationError
7
+ // await safeParseAsync(…) // the same, for a schema that has to wait
8
+ // await parseAsync(…)
9
+ //
10
+ // Two axes, and both of them are a real choice rather than a style.
11
+ //
12
+ // # Throwing or not
13
+ //
14
+ // A boundary that already has a failure path — an HTTP handler building a 422,
15
+ // a form collecting field errors — wants [`safeParse`], because a `throw` there
16
+ // is a control-flow detour to reach a value it was going to inspect anyway.
17
+ // Code with no failure path — a configuration file the process cannot start
18
+ // without, a fixture in a test — wants [`parse`], because the alternative is an
19
+ // `if (!result.ok) throw` at every call site.
20
+ //
21
+ // [`parse`] raises [`ValidationError`], which carries the structured issues
22
+ // rather than only a joined message. An HTTP handler needs the field paths to
23
+ // build a response body, and re-parsing them out of a string is not a thing an
24
+ // API should make anybody do.
25
+ //
26
+ // # Waiting or not
27
+ //
28
+ // A schema is asynchronous, or it is not, and it knew which when it was built
29
+ // — `schema.js` says why. So the synchronous entry points do not return a
30
+ // promise sometimes: given a schema with a `checkAsync` in it they throw an
31
+ // `Error` naming the asynchronous pair, which is a programmer's mistake
32
+ // reported as one. It is not a [`ValidationError`], because nothing was
33
+ // invalid.
34
+ //
35
+ // The asynchronous entry points accept both kinds. A schema with nothing to
36
+ // wait for resolves on the first microtask, so a caller that does not know
37
+ // which it has — a generic resolver, a request handler taking a schema from a
38
+ // route table — can always use them.
39
+
40
+ import type { Result, Schema } from "./schema.js";
41
+ import { runAsync, run } from "./schema.js";
42
+ import { ValidationError } from "./issue.js";
43
+
44
+ /** Parse into a result, so failure is a value rather than control flow. */
45
+ export function safeParse<TOutput>(schema: Schema<TOutput, mixed>, value: mixed): Result<TOutput> {
46
+ return run(schema, value, []);
47
+ }
48
+
49
+ /** Parse, or raise a [`ValidationError`] carrying every issue found. */
50
+ export function parse<TOutput>(schema: Schema<TOutput, mixed>, value: mixed): TOutput {
51
+ const result = safeParse(schema, value);
52
+ if (result.ok) {
53
+ return result.value;
54
+ }
55
+ throw new ValidationError(result.issues);
56
+ }
57
+
58
+ /** [`safeParse`], for a schema with something to wait for. */
59
+ export function safeParseAsync<TOutput>(
60
+ schema: Schema<TOutput, mixed>,
61
+ value: mixed,
62
+ ): Promise<Result<TOutput>> {
63
+ return runAsync(schema, value, []);
64
+ }
65
+
66
+ /** [`parse`], for a schema with something to wait for. */
67
+ export async function parseAsync<TOutput>(
68
+ schema: Schema<TOutput, mixed>,
69
+ value: mixed,
70
+ ): Promise<TOutput> {
71
+ const result = await safeParseAsync(schema, value);
72
+ if (result.ok) {
73
+ return result.value;
74
+ }
75
+ throw new ValidationError(result.issues);
76
+ }
77
+
78
+ /**
79
+ * Whether `value` would parse.
80
+ *
81
+ * A `boolean` and not a type guard. Flow's `value is T` needs the predicate to
82
+ * be provable from the function's body, and here the proof is a closure the
83
+ * checker cannot see through; a guard would be a claim rather than a check.
84
+ * Narrow with [`safeParse`] and read `result.value`, which is the same
85
+ * information with the value attached.
86
+ */
87
+ export function is(schema: Schema<mixed, mixed>, value: mixed): boolean {
88
+ return safeParse(schema, value).ok;
89
+ }
90
+
91
+ /**
92
+ * A schema as a standalone function.
93
+ *
94
+ * `safeParse(schema, value)` needs both halves at the call site, which is fine
95
+ * where the schema is in scope and useless where it is not — a boundary that
96
+ * wants to validate what arrives takes a *function*, not a schema and an
97
+ * import of this package. `parser(User)` is that function, and it is why
98
+ * `@uniflowed/fetch` can check a response body without depending on the
99
+ * validator at all.
100
+ */
101
+ export function parser<TOutput>(schema: Schema<TOutput, mixed>): (value: mixed) => Result<TOutput> {
102
+ return (value: mixed) => safeParse(schema, value);
103
+ }
104
+
105
+ /**
106
+ * Validate a value during render.
107
+ *
108
+ * A hook rather than a plain call so the React Compiler memoises it with the
109
+ * rest of the component: re-rendering for an unrelated reason does not re-walk
110
+ * the payload. It is here rather than in a module of its own because it is
111
+ * [`safeParse`] at a render boundary and not a second idea — and because
112
+ * nothing in this package imports React to provide it.
113
+ */
114
+ export hook useValidation<TOutput>(schema: Schema<TOutput, mixed>, value: mixed): Result<TOutput> {
115
+ return safeParse(schema, value);
116
+ }
package/pipe.js ADDED
@@ -0,0 +1,300 @@
1
+ // @flow
2
+ //
3
+ // `@uniflowed/validator/pipe`: a schema with steps after it.
4
+ //
5
+ // const Handle = pipe(string(), trim(), minLength(3), startsWith("@"));
6
+ // const Age = pipe(string(), transform(Number), integer(), min(18));
7
+ //
8
+ // A step takes a schema and returns a schema, so a pipeline is a fold and
9
+ // nothing more. `pipe` is variadic because refinement is cumulative in
10
+ // practice — a handle is a string that is trimmed *and* long enough *and*
11
+ // starts with an at-sign — and making that `pipe(pipe(pipe(…)))` is a tax on
12
+ // the only case anybody has.
13
+ //
14
+ // # A pipeline may change the output type
15
+ //
16
+ // `transform` is the reason the type has two parameters. `pipe(string(),
17
+ // transform(Number))` accepts a `string` and produces a `number`, and both
18
+ // halves survive: `InferInput` is `string`, `InferOutput` is `number`. That is
19
+ // what lets `@uniflowed/form` type `defaultValues` as what the controls hold
20
+ // and `onValid` as what the application wanted, from one schema.
21
+ //
22
+ // A step is polymorphic in the input type — `<TInput>(Schema<TFrom, TInput>)
23
+ // => Schema<TTo, TInput>` — which is how the pipeline's input survives every
24
+ // step after the first. It is also why a step is written as a generic arrow
25
+ // rather than a plain one.
26
+ //
27
+ // # Why the overload table
28
+ //
29
+ // Flow cannot fold a type over a variadic argument list, so the relationship
30
+ // "the output of step *n* is the input of step *n+1*" has to be spelled out
31
+ // once per arity. Eight of them, which covers every pipeline this repository
32
+ // or Valibot's own examples contain; a ninth step is a type error, and the fix
33
+ // is to name the first eight and pipe the result. The implementation under the
34
+ // table is arity-agnostic and there is exactly one cast in this package to
35
+ // join the two, marked where it happens.
36
+ //
37
+ // The alternative was the previous signature, `pipe(schema, ...steps:
38
+ // Step<any, any>): Schema<any>`, which type-checked everything and knew
39
+ // nothing: it silently erased the output type of every pipeline in every
40
+ // application, and `uf lint` was right to reject it.
41
+ //
42
+ // # Where a cross-field rule reports
43
+ //
44
+ // [`check`] takes an optional path. A rule that compares two fields lives on
45
+ // the object — it needs both of them — but the message belongs under the
46
+ // control the user has to change:
47
+ //
48
+ // pipe(
49
+ // object({ password: string(), confirm: string() }),
50
+ // check((form) => form.password === form.confirm, "Passwords must match", ["confirm"]),
51
+ // );
52
+ //
53
+ // Without it the issue arrives with the object's own path and a form has
54
+ // nowhere to put it.
55
+
56
+ import type { Path } from "./issue.js";
57
+ import { issueUnder } from "./issue.js";
58
+ import type { Constraint, Description, Schema } from "./schema.js";
59
+ import { describe, isAsync, makeAsyncSchema, makeSchema, ok, run, runAsync } from "./schema.js";
60
+
61
+ /**
62
+ * One stage of a pipeline.
63
+ *
64
+ * Polymorphic in `TInput` so that the type of the pipeline's *input* is
65
+ * carried through every step rather than being flattened to `mixed` at the
66
+ * first one.
67
+ */
68
+ export type Step<TFrom, TTo> = <TInput>(Schema<TFrom, TInput>) => Schema<TTo, TInput>;
69
+
70
+ /**
71
+ * `pipe`'s type: one call signature per number of steps.
72
+ *
73
+ * Inexact, because an exact object type with call properties cannot be
74
+ * inhabited by a function.
75
+ */
76
+ export type Pipe = {
77
+ <A, AIn>(schema: Schema<A, AIn>): Schema<A, AIn>,
78
+ <A, AIn, B>(schema: Schema<A, AIn>, a: Step<A, B>): Schema<B, AIn>,
79
+ <A, AIn, B, C>(schema: Schema<A, AIn>, a: Step<A, B>, b: Step<B, C>): Schema<C, AIn>,
80
+ <A, AIn, B, C, D>(
81
+ schema: Schema<A, AIn>,
82
+ a: Step<A, B>,
83
+ b: Step<B, C>,
84
+ c: Step<C, D>,
85
+ ): Schema<D, AIn>,
86
+ <A, AIn, B, C, D, E>(
87
+ schema: Schema<A, AIn>,
88
+ a: Step<A, B>,
89
+ b: Step<B, C>,
90
+ c: Step<C, D>,
91
+ d: Step<D, E>,
92
+ ): Schema<E, AIn>,
93
+ <A, AIn, B, C, D, E, F>(
94
+ schema: Schema<A, AIn>,
95
+ a: Step<A, B>,
96
+ b: Step<B, C>,
97
+ c: Step<C, D>,
98
+ d: Step<D, E>,
99
+ e: Step<E, F>,
100
+ ): Schema<F, AIn>,
101
+ <A, AIn, B, C, D, E, F, G>(
102
+ schema: Schema<A, AIn>,
103
+ a: Step<A, B>,
104
+ b: Step<B, C>,
105
+ c: Step<C, D>,
106
+ d: Step<D, E>,
107
+ e: Step<E, F>,
108
+ f: Step<F, G>,
109
+ ): Schema<G, AIn>,
110
+ <A, AIn, B, C, D, E, F, G, H>(
111
+ schema: Schema<A, AIn>,
112
+ a: Step<A, B>,
113
+ b: Step<B, C>,
114
+ c: Step<C, D>,
115
+ d: Step<D, E>,
116
+ e: Step<E, F>,
117
+ f: Step<F, G>,
118
+ g: Step<G, H>,
119
+ ): Schema<H, AIn>,
120
+ <A, AIn, B, C, D, E, F, G, H, I>(
121
+ schema: Schema<A, AIn>,
122
+ a: Step<A, B>,
123
+ b: Step<B, C>,
124
+ c: Step<C, D>,
125
+ d: Step<D, E>,
126
+ e: Step<E, F>,
127
+ f: Step<F, G>,
128
+ g: Step<G, H>,
129
+ h: Step<H, I>,
130
+ ): Schema<I, AIn>,
131
+ ...
132
+ };
133
+
134
+ function applySteps<TInput>(
135
+ schema: Schema<mixed, TInput>,
136
+ ...steps: $ReadOnlyArray<Step<mixed, mixed>>
137
+ ): Schema<mixed, TInput> {
138
+ let piped = schema;
139
+ for (const step of steps) {
140
+ piped = step<TInput>(piped);
141
+ }
142
+ return piped;
143
+ }
144
+
145
+ /** Apply steps to a schema, left to right. */
146
+ // $FlowFixMe[incompatible-type] the overload table above is the checked surface.
147
+ export const pipe: Pipe = applySteps as Pipe;
148
+
149
+ /**
150
+ * A schema with one more thing that must be true of its output.
151
+ *
152
+ * Every named step in `action.js` is a call to this. The `constraint` is what
153
+ * makes the step visible to `json-schema.js`: a refinement that cannot say
154
+ * what it refined is invisible to every exporter.
155
+ */
156
+ export function refine<TOutput, TInput>(
157
+ schema: Schema<TOutput, TInput>,
158
+ accepts: (value: TOutput) => boolean,
159
+ code: string,
160
+ message: string,
161
+ constraint: Constraint,
162
+ at: Path = [],
163
+ ): Schema<TOutput, TInput> {
164
+ const description = (): Description => ({
165
+ kind: "constrained",
166
+ inner: describe(schema),
167
+ constraint,
168
+ });
169
+
170
+ if (isAsync(schema)) {
171
+ return makeAsyncSchema(async (value, path) => {
172
+ const result = await runAsync(schema, value, path);
173
+ if (!result.ok || accepts(result.value)) {
174
+ return result;
175
+ }
176
+ return { ok: false, issues: [issueUnder(code, message, path, at)] };
177
+ }, description);
178
+ }
179
+
180
+ return makeSchema((value, path) => {
181
+ const result = run(schema, value, path);
182
+ if (!result.ok || accepts(result.value)) {
183
+ return result;
184
+ }
185
+ return { ok: false, issues: [issueUnder(code, message, path, at)] };
186
+ }, description);
187
+ }
188
+
189
+ /**
190
+ * An arbitrary predicate, with the message it should report.
191
+ *
192
+ * Every other step in the package is a special case of this one. It exists so
193
+ * that a rule the library did not anticipate — a checksum, a business rule,
194
+ * one field agreeing with another — is a one-liner rather than a reason to
195
+ * abandon the schema and hand-roll validation.
196
+ *
197
+ * `at` is where the issue lands, relative to the value being checked. See the
198
+ * module docs for the cross-field case it is there for.
199
+ */
200
+ export function check<TValue>(
201
+ accepts: (value: TValue) => boolean,
202
+ message: string,
203
+ at: Path = [],
204
+ ): Step<TValue, TValue> {
205
+ return <TInput>(schema: Schema<TValue, TInput>): Schema<TValue, TInput> =>
206
+ refine(schema, accepts, "check", message, { kind: "opaque", label: message }, at);
207
+ }
208
+
209
+ /**
210
+ * A predicate that has to ask something.
211
+ *
212
+ * "Is this username taken" is a question with a network on the other end, and
213
+ * a schema containing one is asynchronous from here up: the object around it,
214
+ * the array around that, and [`safeParse`] will refuse it and say to use
215
+ * [`safeParseAsync`].
216
+ */
217
+ export function checkAsync<TValue>(
218
+ accepts: (value: TValue) => Promise<boolean>,
219
+ message: string,
220
+ at: Path = [],
221
+ ): Step<TValue, TValue> {
222
+ return <TInput>(schema: Schema<TValue, TInput>): Schema<TValue, TInput> =>
223
+ makeAsyncSchema(
224
+ async (value, path) => {
225
+ const result = await runAsync(schema, value, path);
226
+ if (!result.ok) {
227
+ return result;
228
+ }
229
+ return (await accepts(result.value))
230
+ ? result
231
+ : { ok: false, issues: [issueUnder("check", message, path, at)] };
232
+ },
233
+ () => ({
234
+ kind: "constrained",
235
+ inner: describe(schema),
236
+ constraint: { kind: "opaque", label: message },
237
+ }),
238
+ );
239
+ }
240
+
241
+ /**
242
+ * Change the value, and with it the schema's output type.
243
+ *
244
+ * Runs after everything before it in the pipeline has accepted, so a transform
245
+ * never sees a value the steps above rejected — which is why
246
+ * `pipe(string(), minLength(2), transform((text) => text.length))` is safe to
247
+ * write in that order and means something different in the other.
248
+ */
249
+ export function transform<TFrom, TTo>(change: (value: TFrom) => TTo): Step<TFrom, TTo> {
250
+ return <TInput>(schema: Schema<TFrom, TInput>): Schema<TTo, TInput> => {
251
+ const description = (): Description => ({ kind: "transformed", inner: describe(schema) });
252
+ if (isAsync(schema)) {
253
+ return makeAsyncSchema(async (value, path) => {
254
+ const result = await runAsync(schema, value, path);
255
+ return result.ok ? ok(change(result.value)) : result;
256
+ }, description);
257
+ }
258
+ return makeSchema((value, path) => {
259
+ const result = run(schema, value, path);
260
+ return result.ok ? ok(change(result.value)) : result;
261
+ }, description);
262
+ };
263
+ }
264
+
265
+ /** A transform that has to wait: a lookup, a hash, a decode off the main path. */
266
+ export function transformAsync<TFrom, TTo>(
267
+ change: (value: TFrom) => Promise<TTo>,
268
+ ): Step<TFrom, TTo> {
269
+ return <TInput>(schema: Schema<TFrom, TInput>): Schema<TTo, TInput> =>
270
+ makeAsyncSchema(
271
+ async (value, path) => {
272
+ const result = await runAsync(schema, value, path);
273
+ return result.ok ? ok(await change(result.value)) : result;
274
+ },
275
+ () => ({ kind: "transformed", inner: describe(schema) }),
276
+ );
277
+ }
278
+
279
+ /**
280
+ * A name on an otherwise ordinary value.
281
+ *
282
+ * It checks nothing at run time and it is not pretending to. The name reaches
283
+ * the description, so an exported schema can say "this string is a `UserId`";
284
+ * it does not reach the Flow type, because Flow's opaque types are declared in
285
+ * a module and cannot be produced by a call. `infer.js` says what to do
286
+ * instead when the distinction has to be enforced.
287
+ */
288
+ export function brand<TValue>(name: string): Step<TValue, TValue> {
289
+ return <TInput>(schema: Schema<TValue, TInput>): Schema<TValue, TInput> => {
290
+ const description = (): Description => ({
291
+ kind: "constrained",
292
+ inner: describe(schema),
293
+ constraint: { kind: "brand", name },
294
+ });
295
+ if (isAsync(schema)) {
296
+ return makeAsyncSchema((value, path) => runAsync(schema, value, path), description);
297
+ }
298
+ return makeSchema((value, path) => run(schema, value, path), description);
299
+ };
300
+ }
@@ -0,0 +1,105 @@
1
+ // @flow
2
+ //
3
+ // `@uniflowed/validator/plain-object`: reading and writing an object whose
4
+ // keys came from outside.
5
+ //
6
+ // Three functions, and they are separate from every schema that uses them
7
+ // because they are the package's whole answer to one question: what happens
8
+ // when a payload contains a key that means something to JavaScript.
9
+ //
10
+ // `{"__proto__": {"isAdmin": true}}` is valid JSON, and it is what an attacker
11
+ // sends. `out[key] = value` runs the legacy `__proto__` setter rather than
12
+ // adding a property, so the parsed object would come back with a prototype the
13
+ // attacker chose and every `record.isAdmin` downstream would read `true` from
14
+ // a property nobody ever validated. Reading is the mirror image: `input[key]`
15
+ // finds inherited properties, so a shape asking for `constructor` would be
16
+ // handed `Object`.
17
+ //
18
+ // `object.js`, `collection.js`, `union.js` and `issue.js` all build or read an
19
+ // object out of untrusted input, and every one of them goes through here
20
+ // rather than keeping its own copy of the rule. That is the reason this is a
21
+ // module and not three helpers at the top of the object parser: the defence is
22
+ // only a defence if there is exactly one of it.
23
+
24
+ /** Whether `value` is an object a shape or a record could be read from. */
25
+ export function isPlainObject(value: mixed): boolean {
26
+ return value != null && typeof value === "object" && !Array.isArray(value);
27
+ }
28
+
29
+ /**
30
+ * `value` as something with string keys.
31
+ *
32
+ * The single object boundary in the package. Every caller has already asked
33
+ * [`isPlainObject`], and every field that leaves a schema went through that
34
+ * schema first, so the `mixed` values this exposes are narrowed before they
35
+ * reach an output type.
36
+ */
37
+ export function plainRecord(value: mixed): { readonly [string]: mixed, ... } {
38
+ // $FlowFixMe[incompatible-type] guarded by `isPlainObject` at every call.
39
+ return value as { readonly [string]: mixed, ... };
40
+ }
41
+
42
+ /**
43
+ * The own keys of `value`, and nothing inherited.
44
+ *
45
+ * `Object.keys` already skips the prototype chain, which is why it is here
46
+ * rather than `for (const key in value)`.
47
+ */
48
+ export function ownKeys(value: { readonly [string]: mixed, ... }): $ReadOnlyArray<string> {
49
+ return Object.keys(value);
50
+ }
51
+
52
+ /**
53
+ * Read one key, without falling through to the prototype.
54
+ *
55
+ * `record.constructor` is `Object` on every object in the language; a shape
56
+ * with a `constructor` field would otherwise be handed a function and report
57
+ * that the payload was fine. It also makes `object(shape)` answer the same way
58
+ * for `{}` and for a class instance with getters on its prototype, which is
59
+ * the sort of difference that is discovered in production.
60
+ *
61
+ * `Object.hasOwn` on every field read costs about 13% of a field-dense parse,
62
+ * measured on the workload in `index.js`. That is the price of the paragraph
63
+ * above and it is being paid deliberately: a payload out of `JSON.parse` has
64
+ * only own keys, so the check earns nothing there, and it earns everything the
65
+ * first time somebody hands a schema an object they built themselves.
66
+ */
67
+ export function ownValue(source: { readonly [string]: mixed, ... }, key: string): mixed {
68
+ return Object.hasOwn(source, key) ? source[key] : undefined;
69
+ }
70
+
71
+ /**
72
+ * Write one parsed field into the object being built.
73
+ *
74
+ * `__proto__` is the only key that needs `defineProperty`, and it is worth
75
+ * knowing why rather than reaching for it on every field. `Object.prototype`
76
+ * has exactly one accessor on it — `__proto__` — and assignment to a key an
77
+ * accessor owns runs the setter instead of adding a property. Every other
78
+ * inherited name (`constructor`, `toString`, `valueOf`) is a *data* property,
79
+ * and assigning to one of those shadows it with an own property on the
80
+ * receiver, which is what a parsed field is supposed to be.
81
+ *
82
+ * So one string comparison is the whole defence against a hostile payload, and
83
+ * `defineProperty` is the slow path for the one key that needs it. That is not
84
+ * a micro-optimisation: `defineProperty` on every field made a thousand-record
85
+ * parse three times slower than the same parse with an assignment in it — more
86
+ * than the entire rest of the walk cost — and the measurement is in
87
+ * `index.js`.
88
+ *
89
+ * What this does not defend against is an `Object.prototype` that some other
90
+ * code has already given a setter to. Nothing in a parser can: a process whose
91
+ * `Object.prototype` is writable by an attacker has lost, and every read the
92
+ * consumer makes afterwards goes through the same polluted object.
93
+ */
94
+ export function put<Value>(out: { [string]: Value, ... }, key: string, value: Value): void {
95
+ if (key !== "__proto__") {
96
+ out[key] = value;
97
+ return;
98
+ }
99
+ Object.defineProperty(out, key, {
100
+ value,
101
+ writable: true,
102
+ enumerable: true,
103
+ configurable: true,
104
+ });
105
+ }