@sdxc/validate 0.0.0-pre.1

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/LICENSE.md ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sergio Xalambrí
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,687 @@
1
+ # @sdxc/validate
2
+
3
+ Type-safe form and request validation using Standard Schema with Result types.
4
+
5
+ ## Overview
6
+
7
+ This package provides a unified validation function that works with FormData, Request objects (both form-encoded and JSON), and plain JavaScript objects. It uses the [Standard Schema](https://standardschema.dev) specification, which means it works with any schema validation library that implements the standard, including `remix/data-schema`, Valibot, ArkType, and more.
8
+
9
+ The validation result is returned as a `Result<T, ValidationError>` type from `@sdxc/result`, making error handling explicit and type-safe.
10
+
11
+ ## Installation
12
+
13
+ ```bash
14
+ npm add @sdxc/validate
15
+ ```
16
+
17
+ It works with any Standard Schema-compliant validation library, installed alongside it.
18
+
19
+ ```bash
20
+ npm add remix # or valibot, arktype, etc.
21
+ ```
22
+
23
+ ## Usage
24
+
25
+ ### Basic Example with `remix/data-schema`
26
+
27
+ ```typescript
28
+ import { badRequest, created } from "@sdxc/response";
29
+ import { isFailure } from "@sdxc/result";
30
+ import { validate } from "@sdxc/validate";
31
+ import * as s from "remix/data-schema";
32
+ import { email, minLength } from "remix/data-schema/checks";
33
+ import { createAction } from "remix/router";
34
+
35
+ import routes from "~/routes/web";
36
+
37
+ let schema = s.object({
38
+ email: s.string().pipe(email()),
39
+ name: s.string().pipe(minLength(2)),
40
+ });
41
+
42
+ /** POST /api/users — creates a user from the submitted body. */
43
+ export default createAction(routes.api.users.create, async (ctx) => {
44
+ let result = await validate(ctx.request, schema);
45
+
46
+ if (isFailure(result)) {
47
+ return badRequest({ errors: result.error.issues });
48
+ }
49
+
50
+ // TypeScript knows result.data is { email: string, name: string }
51
+ let user = await saveUser(result.data);
52
+ return created({ user });
53
+ });
54
+ ```
55
+
56
+ ### With FormData
57
+
58
+ ```typescript
59
+ let formData = new FormData();
60
+ formData.append("email", "user@example.com");
61
+ formData.append("name", "Alice");
62
+
63
+ let result = await validate(formData, schema);
64
+
65
+ if (isSuccess(result)) {
66
+ console.log(result.data.email); // "user@example.com"
67
+ console.log(result.data.name); // "Alice"
68
+ }
69
+ ```
70
+
71
+ ### With Request (Form-Encoded)
72
+
73
+ ```typescript
74
+ export default createAction(routes.signup.action, async (ctx) => {
75
+ // Request with Content-Type: application/x-www-form-urlencoded
76
+ let result = await validate(ctx.request, schema);
77
+
78
+ if (isFailure(result)) {
79
+ return badRequest({ errors: result.error.issues });
80
+ }
81
+
82
+ return ok({ data: result.data });
83
+ });
84
+ ```
85
+
86
+ With the `formData()` middleware installed, the body is already parsed by the time the
87
+ handler runs, so pass `ctx.formData` and leave `ctx.request` untouched:
88
+
89
+ ```typescript
90
+ export default createAction(routes.signup.action, async (ctx) => {
91
+ let result = await validate(ctx.formData, schema);
92
+
93
+ if (isFailure(result)) {
94
+ return badRequest({ errors: result.error.issues });
95
+ }
96
+
97
+ return ok({ data: result.data });
98
+ });
99
+ ```
100
+
101
+ ### With Request (JSON)
102
+
103
+ ```typescript
104
+ export default createAction(routes.api.users.create, async (ctx) => {
105
+ // Request with Content-Type: application/json
106
+ let result = await validate(ctx.request, schema);
107
+
108
+ if (isFailure(result)) {
109
+ return badRequest({ errors: result.error.issues });
110
+ }
111
+
112
+ return ok({ data: result.data });
113
+ });
114
+ ```
115
+
116
+ ### With Plain Objects
117
+
118
+ ```typescript
119
+ let data = {
120
+ email: "user@example.com",
121
+ name: "Bob",
122
+ };
123
+
124
+ let result = await validate(data, schema);
125
+
126
+ if (isSuccess(result)) {
127
+ console.log(result.data);
128
+ }
129
+ ```
130
+
131
+ ### With URLSearchParams
132
+
133
+ ```typescript
134
+ let params = new URLSearchParams();
135
+ params.append("email", "user@example.com");
136
+ params.append("name", "Charlie");
137
+
138
+ let result = await validate(params, schema);
139
+
140
+ if (isSuccess(result)) {
141
+ console.log(result.data.email); // "user@example.com"
142
+ console.log(result.data.name); // "Charlie"
143
+ }
144
+ ```
145
+
146
+ ## API
147
+
148
+ ### `validate<Schema>(input, schema): Promise<Result<T, ValidationError>>`
149
+
150
+ Validates input data against a Standard Schema-compliant schema.
151
+
152
+ **Parameters:**
153
+
154
+ - `input`: `FormData | URLSearchParams | Request | Record<string, unknown>` - The data to validate
155
+ - `FormData`: Converts entries to an object and validates
156
+ - `URLSearchParams`: Converts entries to an object and validates
157
+ - `Request`: Automatically detects content type
158
+ - `application/json`: Parses JSON body
159
+ - `application/x-www-form-urlencoded`: Parses as URLSearchParams
160
+ - `multipart/form-data`: Parses as FormData
161
+ - `Record<string, unknown>`: Validates plain object directly
162
+ - `schema`: Any Standard Schema V1 compliant schema (`remix/data-schema`, Valibot, ArkType, etc.)
163
+
164
+ **Returns:**
165
+
166
+ - `Promise<Result<T, ValidationError>>` where:
167
+ - `Success<T>`: Contains validated and typed data
168
+ - `Failure<ValidationError>`: Contains validation issues
169
+
170
+ **Example:**
171
+
172
+ ```typescript
173
+ let result = await validate(request, schema);
174
+
175
+ if (isSuccess(result)) {
176
+ // result.data is fully typed based on your schema
177
+ console.log(result.data);
178
+ } else {
179
+ // result.error is ValidationError with detailed issues
180
+ console.error(result.error.issues);
181
+ }
182
+ ```
183
+
184
+ ### `ValidationError`
185
+
186
+ Error class containing validation issues from the schema.
187
+
188
+ **Properties:**
189
+
190
+ - `message`: Always `"Validation Error"`
191
+ - `issues`: `StandardSchemaV1.Issue[]` - Array of validation issues
192
+
193
+ **Issue Structure:**
194
+
195
+ ```typescript
196
+ interface Issue {
197
+ message: string; // Error message
198
+ path?: ReadonlyArray<PropertyKey | PathSegment> | undefined; // Path to the field
199
+ }
200
+ ```
201
+
202
+ **Example:**
203
+
204
+ ```typescript
205
+ if (isFailure(result)) {
206
+ console.log(result.error.message); // "Validation Error"
207
+ console.log(result.error.issues);
208
+ // [
209
+ // { message: "Expected valid email", path: ["email"] },
210
+ // { message: "Expected at least 2 characters", path: ["name"] }
211
+ // ]
212
+ }
213
+ ```
214
+
215
+ ## Schema Libraries
216
+
217
+ This package works with any library that implements the Standard Schema specification.
218
+
219
+ ### `remix/data-schema`
220
+
221
+ ```typescript
222
+ import * as s from "remix/data-schema";
223
+ import { email, min } from "remix/data-schema/checks";
224
+
225
+ let schema = s.object({
226
+ email: s.string().pipe(email()),
227
+ age: s.number().pipe(min(18)),
228
+ role: s.enum_(["user", "admin"]),
229
+ });
230
+
231
+ let result = await validate(formData, schema);
232
+ ```
233
+
234
+ ### Valibot
235
+
236
+ ```typescript
237
+ import * as v from "valibot";
238
+
239
+ let schema = v.object({
240
+ email: v.pipe(v.string(), v.email()),
241
+ age: v.pipe(v.number(), v.minValue(18)),
242
+ role: v.picklist(["user", "admin"]),
243
+ });
244
+
245
+ let result = await validate(formData, schema);
246
+ ```
247
+
248
+ ### ArkType
249
+
250
+ ```typescript
251
+ import { type } from "arktype";
252
+
253
+ let schema = type({
254
+ email: "string.email",
255
+ "age>=": 18,
256
+ role: "'user'|'admin'",
257
+ });
258
+
259
+ let result = await validate(formData, schema);
260
+ ```
261
+
262
+ ## Type Safety
263
+
264
+ The `validate` function automatically infers the output type from your schema:
265
+
266
+ ```typescript
267
+ let schema = s.object({
268
+ email: s.string().pipe(email()),
269
+ name: s.string(),
270
+ age: s.number(),
271
+ });
272
+
273
+ let result = await validate(request, schema);
274
+
275
+ if (isSuccess(result)) {
276
+ // TypeScript knows:
277
+ // result.data = {
278
+ // email: string;
279
+ // name: string;
280
+ // age: number;
281
+ // }
282
+ let email: string = result.data.email; // ✓ Type-safe
283
+ let name: string = result.data.name; // ✓ Type-safe
284
+ let age: number = result.data.age; // ✓ Type-safe
285
+ }
286
+ ```
287
+
288
+ ## Integration with `remix/router`
289
+
290
+ ### Basic Action
291
+
292
+ ```typescript
293
+ import { badRequest, created } from "@sdxc/response";
294
+ import { isFailure } from "@sdxc/result";
295
+ import { validate } from "@sdxc/validate";
296
+ import * as s from "remix/data-schema";
297
+ import { email, minLength } from "remix/data-schema/checks";
298
+ import { createAction } from "remix/router";
299
+
300
+ import routes from "~/routes/web";
301
+
302
+ let schema = s.object({
303
+ email: s.string().pipe(email()),
304
+ password: s.string().pipe(minLength(8)),
305
+ });
306
+
307
+ /** POST /signup — registers an account. */
308
+ export default createAction(routes.signup.action, async (ctx) => {
309
+ let result = await validate(ctx.formData, schema);
310
+
311
+ if (isFailure(result)) {
312
+ return badRequest({ errors: result.error.issues });
313
+ }
314
+
315
+ let user = await createUser(result.data);
316
+ return created({ user });
317
+ });
318
+ ```
319
+
320
+ ### With Early Returns
321
+
322
+ ```typescript
323
+ export default createAction(routes.login.action, async (ctx) => {
324
+ let validation = await validate(ctx.formData, loginSchema);
325
+ if (isFailure(validation)) {
326
+ return badRequest({ errors: validation.error.issues });
327
+ }
328
+
329
+ let userResult = await findUser(validation.data.email);
330
+ if (isFailure(userResult)) {
331
+ return notFound({ error: "User not found" });
332
+ }
333
+
334
+ let passwordValid = await verifyPassword(validation.data.password, userResult.data.passwordHash);
335
+ if (!passwordValid) {
336
+ return unauthorized({ error: "Invalid credentials" });
337
+ }
338
+
339
+ return ok({ user: userResult.data });
340
+ });
341
+ ```
342
+
343
+ ### Displaying Errors in Components
344
+
345
+ A `Form` takes the issues straight off the failed result and hands each field its own
346
+ messages by `name` through component context, so a refused submission re-renders the same
347
+ page with `issues` set and nothing else threaded down:
348
+
349
+ ```tsx
350
+ import type { Handle } from "remix/ui";
351
+
352
+ import { redirect } from "@sdxc/response";
353
+ import { isFailure } from "@sdxc/result";
354
+ import { Button, Form, TextField } from "@sdxc/ui";
355
+ import { validate } from "@sdxc/validate";
356
+ import { createController } from "remix/router";
357
+
358
+ import routes from "~/routes/web";
359
+
360
+ function SignupPage(handle: Handle<{ issues?: ReadonlyArray<Form.Issue> }>) {
361
+ return () => (
362
+ <Form method="post" issues={handle.props.issues}>
363
+ <TextField label="Email" name="email" type="email" required />
364
+ <TextField label="Password" name="password" type="password" required />
365
+ <Button type="submit">Sign Up</Button>
366
+ </Form>
367
+ );
368
+ }
369
+
370
+ export default createController(routes.signup, {
371
+ actions: {
372
+ /** GET /signup — the empty form. */
373
+ index(ctx) {
374
+ return ctx.render(<SignupPage />);
375
+ },
376
+
377
+ /** POST /signup — registers the account, or re-renders the form with its issues. */
378
+ async action(ctx) {
379
+ let result = await validate(ctx.formData, schema);
380
+
381
+ if (isFailure(result)) {
382
+ return ctx.render(<SignupPage issues={result.error.issues} />, { status: 400 });
383
+ }
384
+
385
+ await createUser(result.data);
386
+ return redirect(routes.dashboard.href(), { status: redirect.Status.SeeOther });
387
+ },
388
+ },
389
+ });
390
+ ```
391
+
392
+ `ValidationError.issues` already matches the `Form.Issue` shape, so the array passes through
393
+ as-is. Each field looks its own messages up by `name`, renders them, marks itself
394
+ `aria-invalid`, and the first invalid field of the render takes focus.
395
+
396
+ ## Content Type Detection
397
+
398
+ The `validate` function automatically detects and handles different content types when given a `Request`:
399
+
400
+ ### Supported Content Types
401
+
402
+ - **`application/json`** - Parses JSON request body
403
+ - **`multipart/form-data`** - Parses multipart form data
404
+ - **`application/x-www-form-urlencoded`** - Parses URL-encoded form data
405
+
406
+ ### JSON Requests
407
+
408
+ ```typescript
409
+ // Request with Content-Type: application/json
410
+ let request = new Request("https://api.example.com/users", {
411
+ method: "POST",
412
+ headers: { "Content-Type": "application/json" },
413
+ body: JSON.stringify({ email: "user@example.com", name: "Alice" }),
414
+ });
415
+
416
+ let result = await validate(request, schema);
417
+ // Automatically parses JSON body
418
+ ```
419
+
420
+ ### Form Requests (multipart/form-data)
421
+
422
+ ```typescript
423
+ // When you pass FormData to Request, it automatically sets
424
+ // Content-Type: multipart/form-data with boundary
425
+ let formData = new FormData();
426
+ formData.append("email", "user@example.com");
427
+ formData.append("name", "Alice");
428
+
429
+ let request = new Request("https://example.com/submit", {
430
+ method: "POST",
431
+ body: formData,
432
+ });
433
+
434
+ let result = await validate(request, schema);
435
+ // Automatically parses FormData
436
+ ```
437
+
438
+ ### Form Requests (application/x-www-form-urlencoded)
439
+
440
+ ```typescript
441
+ // URL-encoded form data
442
+ let params = new URLSearchParams();
443
+ params.append("email", "user@example.com");
444
+ params.append("name", "Alice");
445
+
446
+ let request = new Request("https://example.com/submit", {
447
+ method: "POST",
448
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
449
+ body: params,
450
+ });
451
+
452
+ let result = await validate(request, schema);
453
+ // Automatically parses URL-encoded data
454
+ ```
455
+
456
+ ### Unsupported Content Types
457
+
458
+ If a Request has an unsupported content-type, validation will fail:
459
+
460
+ ```typescript
461
+ let request = new Request("https://example.com/submit", {
462
+ method: "POST",
463
+ headers: { "Content-Type": "text/plain" },
464
+ body: "plain text",
465
+ });
466
+
467
+ let result = await validate(request, schema);
468
+
469
+ if (isFailure(result)) {
470
+ console.log(result.error.issues[0].message);
471
+ // "Unsupported content-type: text/plain. Expected application/json, multipart/form-data, or application/x-www-form-urlencoded"
472
+ }
473
+ ```
474
+
475
+ ### Invalid JSON Handling
476
+
477
+ ```typescript
478
+ let request = new Request("https://api.example.com/users", {
479
+ method: "POST",
480
+ headers: { "Content-Type": "application/json" },
481
+ body: "{ invalid json",
482
+ });
483
+
484
+ let result = await validate(request, schema);
485
+
486
+ if (isFailure(result)) {
487
+ console.log(result.error.issues[0].message);
488
+ // "Invalid JSON in request body"
489
+ }
490
+ ```
491
+
492
+ ## Pattern: Reusable Schemas
493
+
494
+ Define schemas once and reuse them across your application:
495
+
496
+ ```typescript
497
+ // schemas.ts
498
+ import * as s from "remix/data-schema";
499
+ import { email, min, minLength } from "remix/data-schema/checks";
500
+
501
+ export let userSchema = s.object({
502
+ email: s.string().pipe(email()),
503
+ name: s.string().pipe(minLength(2)),
504
+ age: s.optional(s.number().pipe(min(18))),
505
+ });
506
+
507
+ export let loginSchema = s.object({
508
+ email: s.string().pipe(email()),
509
+ password: s.string().pipe(minLength(8)),
510
+ });
511
+
512
+ // There is no `.partial()` helper, so a schema where every field is optional is
513
+ // spelled out by wrapping each one in `s.optional()`.
514
+ export let updateUserSchema = s.object({
515
+ email: s.optional(s.string().pipe(email())),
516
+ name: s.optional(s.string().pipe(minLength(2))),
517
+ age: s.optional(s.number().pipe(min(18))),
518
+ });
519
+ ```
520
+
521
+ ```typescript
522
+ // app/http/controllers/signup.ts
523
+ import { validate } from "@sdxc/validate";
524
+ import { createAction } from "remix/router";
525
+
526
+ import routes from "~/routes/web";
527
+ import { userSchema } from "~/schemas";
528
+
529
+ export default createAction(routes.signup.action, async (ctx) => {
530
+ let result = await validate(ctx.formData, userSchema);
531
+ // ...
532
+ });
533
+ ```
534
+
535
+ ## Pattern: Custom Error Messages
536
+
537
+ Checks are plain objects, so a custom message is applied by spreading the check
538
+ and overriding its `message`. There is no regex check, so pattern rules go
539
+ through `.refine()`, which takes the message as its second argument.
540
+
541
+ ```typescript
542
+ import * as s from "remix/data-schema";
543
+ import { email, minLength } from "remix/data-schema/checks";
544
+
545
+ let schema = s.object({
546
+ email: s.string().pipe({ ...email(), message: "Please enter a valid email address" }),
547
+ password: s
548
+ .string()
549
+ .pipe({ ...minLength(8), message: "Password must be at least 8 characters" })
550
+ .refine((value) => /[A-Z]/.test(value), "Password must contain at least one uppercase letter"),
551
+ });
552
+ ```
553
+
554
+ ## Pattern: Transformations
555
+
556
+ Schema libraries can transform data during validation:
557
+
558
+ ```typescript
559
+ import * as s from "remix/data-schema";
560
+ import { email } from "remix/data-schema/checks";
561
+
562
+ let schema = s.object({
563
+ email: s
564
+ .string()
565
+ .pipe(email())
566
+ .transform((value) => value.toLowerCase()), // Transform to lowercase
567
+ age: s.string().transform(Number), // Convert string to number
568
+ tags: s.string().transform((str) => str.split(",")), // Split into array
569
+ });
570
+
571
+ let formData = new FormData();
572
+ formData.append("email", "USER@EXAMPLE.COM");
573
+ formData.append("age", "25");
574
+ formData.append("tags", "javascript,typescript,react");
575
+
576
+ let result = await validate(formData, schema);
577
+
578
+ if (isSuccess(result)) {
579
+ console.log(result.data);
580
+ // {
581
+ // email: "user@example.com", // Transformed to lowercase
582
+ // age: 25, // Transformed to number
583
+ // tags: ["javascript", "typescript", "react"] // Transformed to array
584
+ // }
585
+ }
586
+ ```
587
+
588
+ ## Pattern: Nested Objects
589
+
590
+ ```typescript
591
+ let schema = s.object({
592
+ user: s.object({
593
+ name: s.string(),
594
+ email: s.string().pipe(email()),
595
+ }),
596
+ preferences: s.object({
597
+ newsletter: s.boolean(),
598
+ theme: s.enum_(["light", "dark"]),
599
+ }),
600
+ });
601
+
602
+ let result = await validate(data, schema);
603
+
604
+ if (isFailure(result)) {
605
+ // Issues include paths like ["user", "email"] or ["preferences", "theme"]
606
+ result.error.issues.forEach((issue) => {
607
+ console.log(issue.path, issue.message);
608
+ });
609
+ }
610
+ ```
611
+
612
+ ## Pattern: Array Validation
613
+
614
+ ```typescript
615
+ let schema = s.object({
616
+ tags: s.array(s.string()),
617
+ emails: s.array(s.string().pipe(email())),
618
+ });
619
+
620
+ // With FormData, you can append multiple values with the same name
621
+ let formData = new FormData();
622
+ formData.append("tags", "javascript");
623
+ formData.append("tags", "typescript");
624
+ formData.append("emails", "user1@example.com");
625
+ formData.append("emails", "user2@example.com");
626
+ ```
627
+
628
+ ## Error Handling
629
+
630
+ ### Checking Individual Fields
631
+
632
+ ```typescript
633
+ if (isFailure(result)) {
634
+ let emailIssues = result.error.issues.filter((issue) => issue.path?.[0] === "email");
635
+
636
+ let passwordIssues = result.error.issues.filter((issue) => issue.path?.[0] === "password");
637
+
638
+ if (emailIssues.length > 0) {
639
+ console.log("Email errors:", emailIssues);
640
+ }
641
+ }
642
+ ```
643
+
644
+ ### Grouping Errors by Field
645
+
646
+ ```typescript
647
+ if (isFailure(result)) {
648
+ let errorsByField = result.error.issues.reduce(
649
+ (acc, issue) => {
650
+ let field = issue.path?.[0]?.toString() ?? "general";
651
+ if (!acc[field]) acc[field] = [];
652
+ acc[field].push(issue.message);
653
+ return acc;
654
+ },
655
+ {} as Record<string, string[]>,
656
+ );
657
+
658
+ console.log(errorsByField);
659
+ // {
660
+ // email: ["Invalid email format"],
661
+ // password: ["Password too short", "Password must contain uppercase"]
662
+ // }
663
+ }
664
+ ```
665
+
666
+ ## Why Standard Schema?
667
+
668
+ The Standard Schema specification allows this package to work with any compliant validation library without needing adapters or library-specific code. Benefits include:
669
+
670
+ 1. **Library agnostic** - Switch between `remix/data-schema`, Valibot, ArkType without changing validation code
671
+ 2. **Future-proof** - New libraries implementing the standard work automatically
672
+ 3. **Smaller bundles** - Use lightweight libraries like Valibot if bundle size matters
673
+ 4. **Ecosystem growth** - More tools and libraries are adopting the standard
674
+
675
+ ## Related Packages
676
+
677
+ - [`@sdxc/result`](https://www.npmjs.com/package/@sdxc/result) - Result type for explicit error handling
678
+ - [`@sdxc/response`](https://www.npmjs.com/package/@sdxc/response) - Semantic helpers that build plain `Response` objects for JSON APIs and redirects
679
+ - [Standard Schema](https://standardschema.dev) - The specification this package implements
680
+
681
+ ## Tips
682
+
683
+ 1. **Prefer early returns** - Check for validation failures early and return error responses
684
+ 2. **Reuse schemas** - Define schemas in a central location and import them
685
+ 3. **Custom messages** - Provide user-friendly error messages in your schemas
686
+ 4. **Transform data** - Use schema transformations to normalize data (lowercase emails, trim strings, etc.)
687
+ 5. **Type inference** - Let TypeScript infer types from your schemas instead of duplicating type definitions
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Normalizes `FormData`, `URLSearchParams`, `Request` bodies, and plain
3
+ * objects into a single value, then runs it through a Standard Schema,
4
+ * returning a `Result` instead of throwing on failure.
5
+ *
6
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
7
+ * @copyright Sergio Xalambrí 2026
8
+ */
9
+ import type { Result } from "@sdxc/result";
10
+ import type { JSONValue } from "@sdxc/types";
11
+ import type { StandardSchemaV1 } from "@standard-schema/spec";
12
+ import { ValidationError } from "./validation-error.js";
13
+ export { ValidationError };
14
+ /**
15
+ * Some schemas (e.g. `remix/data-schema/form-data`'s `object()`) validate
16
+ * the raw `FormData`/`URLSearchParams` source instead of a flattened plain
17
+ * object; a raw-source rejection triggers a retry against that raw source.
18
+ */
19
+ export declare function validate<Schema extends StandardSchemaV1>(input: FormData | URLSearchParams | Request | Record<string, unknown> | JSONValue, schema: Schema): Promise<Result<StandardSchemaV1.InferOutput<Schema>, ValidationError>>;
package/dist/index.js ADDED
@@ -0,0 +1,77 @@
1
+ import { success, failure, isFailure } from "@sdxc/result";
2
+ import { ValidationError } from "./validation-error.js";
3
+ export { ValidationError };
4
+ function formDataToObject(formData) {
5
+ let data = new Map();
6
+ let keys = new Set(formData.keys());
7
+ for (let key of keys) {
8
+ let values = formData.getAll(key);
9
+ data.set(key, values.length === 1 ? values.at(0) : values);
10
+ }
11
+ return Object.fromEntries(data.entries());
12
+ }
13
+ function urlSearchParamsToObject(params) {
14
+ let data = new Map();
15
+ let keys = new Set(params.keys());
16
+ for (let key of keys) {
17
+ let values = params.getAll(key);
18
+ data.set(key, values.length === 1 ? values.at(0) : values);
19
+ }
20
+ return Object.fromEntries(data.entries());
21
+ }
22
+ /**
23
+ * Some schemas (e.g. `remix/data-schema/form-data`'s `object()`) validate
24
+ * the raw `FormData`/`URLSearchParams` source instead of a flattened plain
25
+ * object; a raw-source rejection triggers a retry against that raw source.
26
+ */
27
+ export async function validate(input, schema) {
28
+ if (input instanceof Request) {
29
+ let contentType = input.headers.get("content-type");
30
+ if (contentType?.includes("application/json")) {
31
+ try {
32
+ let data = (await input.json());
33
+ return validate(data, schema);
34
+ }
35
+ catch {
36
+ return failure(new ValidationError([{ message: "Invalid JSON in request body" }]));
37
+ }
38
+ }
39
+ if (contentType?.includes("application/x-www-form-urlencoded")) {
40
+ let text = await input.text();
41
+ let params = new URLSearchParams(text);
42
+ return validate(params, schema);
43
+ }
44
+ if (contentType?.includes("multipart/form-data")) {
45
+ let formData = await input.formData();
46
+ return validate(formData, schema);
47
+ }
48
+ return failure(new ValidationError([
49
+ {
50
+ message: `Unsupported content-type: ${contentType}. Expected application/json, multipart/form-data, or application/x-www-form-urlencoded`,
51
+ },
52
+ ]));
53
+ }
54
+ if (input instanceof FormData || input instanceof URLSearchParams) {
55
+ let data = input instanceof FormData ? formDataToObject(input) : urlSearchParamsToObject(input);
56
+ let result = await validate(data, schema);
57
+ let wantsRawSource = isFailure(result) &&
58
+ result.error.issues.some((issue) => issue.message === "Expected FormData or URLSearchParams");
59
+ if (!wantsRawSource) {
60
+ return result;
61
+ }
62
+ let rawResult = schema["~standard"].validate(input);
63
+ if (rawResult instanceof Promise)
64
+ rawResult = await rawResult;
65
+ if (rawResult.issues) {
66
+ return failure(new ValidationError(rawResult.issues));
67
+ }
68
+ return success(rawResult.value);
69
+ }
70
+ let result = schema["~standard"].validate(input);
71
+ if (result instanceof Promise)
72
+ result = await result;
73
+ if (result.issues) {
74
+ return failure(new ValidationError(result.issues));
75
+ }
76
+ return success(result.value);
77
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Carries the Standard Schema issues from a failed `validate()` call as a
3
+ * typed `Error`, so callers can pattern-match on it in a `Result`.
4
+ *
5
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
6
+ * @copyright Sergio Xalambrí 2026
7
+ */
8
+ import type { StandardSchemaV1 } from "@standard-schema/spec";
9
+ export declare class ValidationError extends Error {
10
+ issues: StandardSchemaV1.Issue[];
11
+ constructor(issues: readonly StandardSchemaV1.Issue[]);
12
+ }
@@ -0,0 +1,7 @@
1
+ export class ValidationError extends Error {
2
+ issues;
3
+ constructor(issues) {
4
+ super("Validation Error");
5
+ this.issues = [...issues];
6
+ }
7
+ }
package/package.json ADDED
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "@sdxc/validate",
3
+ "version": "0.0.0-pre.1",
4
+ "description": "Standard Schema validation utilities",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "exports": {
8
+ ".": "./dist/index.js"
9
+ },
10
+ "dependencies": {
11
+ "@sdxc/result": "2026.9.5",
12
+ "@sdxc/types": "2026.9.5",
13
+ "@standard-schema/spec": "^1.1.0"
14
+ },
15
+ "gitHead": "3aedbf2a8ed2251cecac480501941ee4c4614ec3",
16
+ "publishConfig": {
17
+ "access": "public"
18
+ },
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/sergiodxa/monorepo.git",
22
+ "directory": "packages/validate"
23
+ }
24
+ }