@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 +21 -0
- package/README.md +687 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +77 -0
- package/dist/validation-error.d.ts +12 -0
- package/dist/validation-error.js +7 -0
- package/package.json +24 -0
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
|
package/dist/index.d.ts
ADDED
|
@@ -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
|
+
}
|
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
|
+
}
|