@sdxc/validate 2026.9.11 → 2026.9.16

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.
Files changed (2) hide show
  1. package/README.md +88 -558
  2. package/package.json +4 -4
package/README.md CHANGED
@@ -1,12 +1,6 @@
1
1
  # @sdxc/validate
2
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.
3
+ Validate a form submission, a request body, or a plain object against any Standard Schema, and get a `Result` back instead of a thrown error.
10
4
 
11
5
  ## Installation
12
6
 
@@ -14,674 +8,210 @@ The validation result is returned as a `Result<T, ValidationError>` type from `@
14
8
  npm add @sdxc/validate
15
9
  ```
16
10
 
17
- It works with any Standard Schema-compliant validation library, installed alongside it.
11
+ The schema itself comes from any library implementing [Standard Schema](https://standardschema.dev) — [`remix`](https://www.npmjs.com/package/remix), [Valibot](https://valibot.dev), [ArkType](https://arktype.io) — installed alongside this package. [`@sdxc/result`](https://www.npmjs.com/package/@sdxc/result) installs with it and supplies `isSuccess`, `isFailure` and `unwrap`.
18
12
 
19
13
  ```bash
20
- npm add remix # or valibot, arktype, etc.
14
+ npm add valibot
21
15
  ```
22
16
 
23
17
  ## Usage
24
18
 
25
- ### Basic Example with `remix/data-schema`
19
+ ### Validate a plain object
26
20
 
27
21
  ```typescript
28
- import { badRequest, created } from "@sdxc/response";
29
22
  import { isFailure } from "@sdxc/result";
30
23
  import { validate } from "@sdxc/validate";
31
24
  import * as s from "remix/data-schema";
32
25
  import { email, minLength } from "remix/data-schema/checks";
33
- import { createAction } from "remix/router";
34
-
35
- import routes from "~/routes/web";
36
26
 
37
27
  let schema = s.object({
38
28
  email: s.string().pipe(email()),
39
29
  name: s.string().pipe(minLength(2)),
40
30
  });
41
31
 
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);
32
+ let result = await validate({ email: "ada@example.com", name: "Ada" }, schema);
45
33
 
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
- });
34
+ if (isFailure(result)) console.error(result.error.issues);
35
+ else console.log(result.data.email); // typed `string` from the schema
54
36
  ```
55
37
 
56
- ### With FormData
38
+ ### Validate a form submission
57
39
 
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)
40
+ `FormData` and `URLSearchParams` flatten to an object first: a name submitted once becomes a value, and a name submitted several times becomes an array.
72
41
 
73
42
  ```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);
43
+ let form = new FormData();
44
+ form.append("email", "ada@example.com");
45
+ form.append("tags", "typescript");
46
+ form.append("tags", "validation");
77
47
 
78
- if (isFailure(result)) {
79
- return badRequest({ errors: result.error.issues });
80
- }
81
-
82
- return ok({ data: result.data });
83
- });
48
+ let result = await validate(form, s.object({ email: s.string(), tags: s.array(s.string()) }));
84
49
  ```
85
50
 
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
- ```
51
+ ### Validate a request
100
52
 
101
- ### With Request (JSON)
53
+ A `Request` is read according to its `Content-Type`, so one call covers every kind of submission a route accepts:
102
54
 
103
55
  ```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);
56
+ async function handler(request: Request) {
57
+ let result = await validate(request, schema);
107
58
 
108
59
  if (isFailure(result)) {
109
- return badRequest({ errors: result.error.issues });
60
+ return Response.json({ errors: result.error.issues }, { status: 400 });
110
61
  }
111
62
 
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);
63
+ return Response.json({ user: result.data });
128
64
  }
129
65
  ```
130
66
 
131
- ### With URLSearchParams
67
+ ### Read the failure
132
68
 
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);
69
+ Every issue carries the path it failed at, so a refusal can be reported field by field:
139
70
 
140
- if (isSuccess(result)) {
141
- console.log(result.data.email); // "user@example.com"
142
- console.log(result.data.name); // "Charlie"
71
+ ```typescript
72
+ if (isFailure(result)) {
73
+ for (let issue of result.error.issues) console.log(issue.path, issue.message);
74
+ // ["email"] Expected valid email
75
+ // ["name"] Expected at least 2 characters
143
76
  }
144
77
  ```
145
78
 
146
79
  ## API
147
80
 
148
- ### `validate<Schema>(input, schema): Promise<Result<T, ValidationError>>`
149
-
150
- Validates input data against a Standard Schema-compliant schema.
81
+ ### `validate<Schema>(input, schema): Promise<Result<InferOutput<Schema>, ValidationError>>`
151
82
 
152
- **Parameters:**
83
+ Normalizes `input`, runs it through `schema`, and resolves to a `Result` carrying either the schema's own output type or a `ValidationError`. A rejected submission is a value, not an exception.
153
84
 
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.)
85
+ `input` is any of:
163
86
 
164
- **Returns:**
87
+ - `FormData` or `URLSearchParams` — entries flatten to an object, a repeated name becoming an array.
88
+ - `Request` — the body is read by content type: `application/json`, `multipart/form-data`, or `application/x-www-form-urlencoded`. Any other type refuses with `Unsupported content-type: …`, and a body that is not valid JSON refuses with `Invalid JSON in request body`.
89
+ - A plain object, or any JSON value — handed to the schema as it stands.
165
90
 
166
- - `Promise<Result<T, ValidationError>>` where:
167
- - `Success<T>`: Contains validated and typed data
168
- - `Failure<ValidationError>`: Contains validation issues
91
+ Some schemas validate the raw source rather than a flattened object — `remix/data-schema/form-data`'s `object()` is one, and it rejects the flattened object with `Expected FormData or URLSearchParams`. That rejection is retried against the original `FormData` or `URLSearchParams`, so those schemas pass through the same call as any other.
169
92
 
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
- ```
93
+ `schema` is any Standard Schema V1 value, and the success type is inferred from it, so nothing restates the shape the schema already describes.
183
94
 
184
95
  ### `ValidationError`
185
96
 
186
- Error class containing validation issues from the schema.
187
-
188
- **Properties:**
97
+ What a refused submission carries. `message` is always `"Validation Error"`, and `issues` is the schema's own `StandardSchemaV1.Issue[]` — each a `message` plus the `path` it failed at, in whatever vocabulary the schema library produced.
189
98
 
190
- - `message`: Always `"Validation Error"`
191
- - `issues`: `StandardSchemaV1.Issue[]` - Array of validation issues
99
+ ## Pattern: One Schema Behind Several Entry Points
192
100
 
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`
101
+ A schema is a value, so the rule a form enforces in the browser and the rule an API enforces on the wire can be the same object rather than two that drift:
220
102
 
221
103
  ```typescript
104
+ // schemas.ts
222
105
  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:
106
+ import { email, min, minLength } from "remix/data-schema/checks";
265
107
 
266
- ```typescript
267
- let schema = s.object({
108
+ export let signup = s.object({
268
109
  email: s.string().pipe(email()),
269
- name: s.string(),
270
- age: s.number(),
110
+ name: s.string().pipe(minLength(2)),
111
+ age: s.optional(s.number().pipe(min(18))),
271
112
  });
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
113
  ```
287
114
 
288
- ## Integration with `remix/router`
289
-
290
- ### Basic Action
291
-
292
115
  ```typescript
293
- import { badRequest, created } from "@sdxc/response";
294
116
  import { isFailure } from "@sdxc/result";
295
117
  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
118
 
307
- /** POST /signup — registers an account. */
308
- export default createAction(routes.signup.action, async (ctx) => {
309
- let result = await validate(ctx.formData, schema);
119
+ import { signup } from "./schemas.js";
310
120
 
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
- });
121
+ async function handler(request: Request) {
122
+ let result = await validate(request, signup);
123
+ if (isFailure(result)) return Response.json({ errors: result.error.issues }, { status: 400 });
124
+ return Response.json({ user: await createUser(result.data) });
125
+ }
318
126
  ```
319
127
 
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
- ```
128
+ There is no `.partial()` helper, so a variant where every field is optional wraps each one in `s.optional()`.
342
129
 
343
- ### Displaying Errors in Components
130
+ ## Pattern: Rendering A Refused Submission
344
131
 
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:
132
+ `ValidationError.issues` already matches the issue shape [`@sdxc/ui`](https://www.npmjs.com/package/@sdxc/ui)'s `Form` takes, so a refusal re-renders the same page with the array passed straight through. Each field looks its own messages up by `name`, renders them, marks itself `aria-invalid`, and the first invalid field of the render takes focus — so nothing else is threaded down:
348
133
 
349
134
  ```tsx
350
135
  import type { Handle } from "remix/ui";
351
136
 
352
- import { redirect } from "@sdxc/response";
353
- import { isFailure } from "@sdxc/result";
354
137
  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
138
 
360
- function SignupPage(handle: Handle<{ issues?: ReadonlyArray<Form.Issue> }>) {
139
+ function SignupForm(handle: Handle<{ issues?: ReadonlyArray<Form.Issue> }>) {
361
140
  return () => (
362
141
  <Form method="post" issues={handle.props.issues}>
363
142
  <TextField label="Email" name="email" type="email" required />
364
143
  <TextField label="Password" name="password" type="password" required />
365
- <Button type="submit">Sign Up</Button>
144
+ <Button type="submit">Sign up</Button>
366
145
  </Form>
367
146
  );
368
147
  }
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
148
  ```
534
149
 
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";
150
+ ```tsx
151
+ let result = await validate(submission, schema);
544
152
 
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
- });
153
+ if (isFailure(result)) return <SignupForm issues={result.error.issues} />;
552
154
  ```
553
155
 
554
- ## Pattern: Transformations
156
+ ## Pattern: Normalizing While Validating
555
157
 
556
- Schema libraries can transform data during validation:
158
+ A schema transforms as it validates, so the handler receives values in the shape it wants rather than the shape the wire delivered. Everything arriving from a form is a string, which is where this earns the most:
557
159
 
558
160
  ```typescript
559
- import * as s from "remix/data-schema";
560
- import { email } from "remix/data-schema/checks";
561
-
562
161
  let schema = s.object({
563
162
  email: s
564
163
  .string()
565
164
  .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
165
+ .transform((value) => value.toLowerCase()),
166
+ age: s.string().transform(Number),
167
+ tags: s.string().transform((value) => value.split(",")),
569
168
  });
570
169
 
571
- let formData = new FormData();
572
- formData.append("email", "USER@EXAMPLE.COM");
573
- formData.append("age", "25");
574
- formData.append("tags", "javascript,typescript,react");
170
+ let form = new FormData();
171
+ form.append("email", "ADA@EXAMPLE.COM");
172
+ form.append("age", "25");
173
+ form.append("tags", "typescript,validation");
575
174
 
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
- }
175
+ let result = await validate(form, schema);
176
+ // { email: "ada@example.com", age: 25, tags: ["typescript", "validation"] }
586
177
  ```
587
178
 
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);
179
+ ## Pattern: Custom Messages
603
180
 
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
181
+ Checks are plain objects, so a custom message spreads the check and overrides its `message`. There is no regex check, so a pattern rule goes through `.refine()`, which takes its message as a second argument:
613
182
 
614
183
  ```typescript
615
184
  let schema = s.object({
616
- tags: s.array(s.string()),
617
- emails: s.array(s.string().pipe(email())),
185
+ email: s.string().pipe({ ...email(), message: "Please enter a valid email address" }),
186
+ password: s
187
+ .string()
188
+ .pipe({ ...minLength(8), message: "Password must be at least 8 characters" })
189
+ .refine((value) => /[A-Z]/.test(value), "Password must contain an uppercase letter"),
618
190
  });
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
191
  ```
627
192
 
628
- ## Error Handling
193
+ ## Versioning
629
194
 
630
- ### Checking Individual Fields
195
+ Releases are dated rather than semantic. A version is the UTC date it was published, written `YYYY.M.D`, so `2026.9.4` is the release from 4 September 2026. At most one release goes out per day.
631
196
 
632
- ```typescript
633
- if (isFailure(result)) {
634
- let emailIssues = result.error.issues.filter((issue) => issue.path?.[0] === "email");
197
+ Those numbers say when, not what: a later date means a later release and carries no compatibility promise. Any release may change or remove an export.
635
198
 
636
- let passwordIssues = result.error.issues.filter((issue) => issue.path?.[0] === "password");
199
+ Depend on one exact date, and move it when you are ready to take the change:
637
200
 
638
- if (emailIssues.length > 0) {
639
- console.log("Email errors:", emailIssues);
201
+ ```json
202
+ {
203
+ "dependencies": {
204
+ "@sdxc/validate": "2026.9.4"
640
205
  }
641
206
  }
642
207
  ```
643
208
 
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
209
+ A caret or tilde range reads the date as major, minor and patch, so it accepts every later release in the same year. An exact version keeps the upgrade yours to schedule.
674
210
 
675
- ## Related Packages
211
+ ## License
676
212
 
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
213
+ MIT
680
214
 
681
- ## Tips
215
+ ## Author
682
216
 
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
217
+ [Sergio Xalambrí](https://sergiodxa.com)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sdxc/validate",
3
- "version": "2026.9.11",
3
+ "version": "2026.9.16",
4
4
  "description": "Standard Schema validation utilities",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -8,11 +8,11 @@
8
8
  ".": "./dist/index.js"
9
9
  },
10
10
  "dependencies": {
11
- "@sdxc/result": "2026.9.11",
12
- "@sdxc/types": "2026.9.11",
11
+ "@sdxc/result": "2026.9.15",
12
+ "@sdxc/types": "2026.9.15",
13
13
  "@standard-schema/spec": "^1.1.0"
14
14
  },
15
- "gitHead": "3aedbf2a8ed2251cecac480501941ee4c4614ec3",
15
+ "gitHead": "704fb5aba875369a1bdebf8ff23676bd8b3836ce",
16
16
  "publishConfig": {
17
17
  "access": "public"
18
18
  },