@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.
- package/README.md +88 -558
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -1,12 +1,6 @@
|
|
|
1
1
|
# @sdxc/validate
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
|
|
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
|
|
14
|
+
npm add valibot
|
|
21
15
|
```
|
|
22
16
|
|
|
23
17
|
## Usage
|
|
24
18
|
|
|
25
|
-
###
|
|
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
|
-
|
|
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
|
-
|
|
47
|
-
|
|
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
|
-
###
|
|
38
|
+
### Validate a form submission
|
|
57
39
|
|
|
58
|
-
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
105
|
-
|
|
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
|
|
60
|
+
return Response.json({ errors: result.error.issues }, { status: 400 });
|
|
110
61
|
}
|
|
111
62
|
|
|
112
|
-
return
|
|
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
|
-
###
|
|
67
|
+
### Read the failure
|
|
132
68
|
|
|
133
|
-
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
console.log(
|
|
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<
|
|
149
|
-
|
|
150
|
-
Validates input data against a Standard Schema-compliant schema.
|
|
81
|
+
### `validate<Schema>(input, schema): Promise<Result<InferOutput<Schema>, ValidationError>>`
|
|
151
82
|
|
|
152
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
- `
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
191
|
-
- `issues`: `StandardSchemaV1.Issue[]` - Array of validation issues
|
|
99
|
+
## Pattern: One Schema Behind Several Entry Points
|
|
192
100
|
|
|
193
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
312
|
-
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
|
|
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
|
-
|
|
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
|
-
|
|
130
|
+
## Pattern: Rendering A Refused Submission
|
|
344
131
|
|
|
345
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
156
|
+
## Pattern: Normalizing While Validating
|
|
555
157
|
|
|
556
|
-
|
|
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()),
|
|
567
|
-
age: s.string().transform(Number),
|
|
568
|
-
tags: s.string().transform((
|
|
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
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
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(
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
617
|
-
|
|
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
|
-
##
|
|
193
|
+
## Versioning
|
|
629
194
|
|
|
630
|
-
|
|
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
|
-
|
|
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
|
-
|
|
199
|
+
Depend on one exact date, and move it when you are ready to take the change:
|
|
637
200
|
|
|
638
|
-
|
|
639
|
-
|
|
201
|
+
```json
|
|
202
|
+
{
|
|
203
|
+
"dependencies": {
|
|
204
|
+
"@sdxc/validate": "2026.9.4"
|
|
640
205
|
}
|
|
641
206
|
}
|
|
642
207
|
```
|
|
643
208
|
|
|
644
|
-
|
|
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
|
-
##
|
|
211
|
+
## License
|
|
676
212
|
|
|
677
|
-
|
|
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
|
-
##
|
|
215
|
+
## Author
|
|
682
216
|
|
|
683
|
-
|
|
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.
|
|
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.
|
|
12
|
-
"@sdxc/types": "2026.9.
|
|
11
|
+
"@sdxc/result": "2026.9.15",
|
|
12
|
+
"@sdxc/types": "2026.9.15",
|
|
13
13
|
"@standard-schema/spec": "^1.1.0"
|
|
14
14
|
},
|
|
15
|
-
"gitHead": "
|
|
15
|
+
"gitHead": "704fb5aba875369a1bdebf8ff23676bd8b3836ce",
|
|
16
16
|
"publishConfig": {
|
|
17
17
|
"access": "public"
|
|
18
18
|
},
|