@sdxc/validate 2026.10.2 → 2026.10.13
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 +116 -62
- package/package.json +13 -4
package/README.md
CHANGED
|
@@ -1,18 +1,14 @@
|
|
|
1
1
|
# @sdxc/validate
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Standard Schema validation for form submissions, request bodies and plain objects, answering a `Result` instead of throwing.
|
|
4
4
|
|
|
5
5
|
## Installation
|
|
6
6
|
|
|
7
|
-
```
|
|
7
|
+
```sh
|
|
8
8
|
npm add @sdxc/validate
|
|
9
9
|
```
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
```bash
|
|
14
|
-
npm add valibot
|
|
15
|
-
```
|
|
11
|
+
Bring a schema library that implements [Standard Schema](https://standardschema.dev), such as [`remix`](https://www.npmjs.com/package/remix)'s `remix/data-schema`, [Valibot](https://valibot.dev) or [ArkType](https://arktype.io). [`@sdxc/result`](https://www.npmjs.com/package/@sdxc/result) installs with this package and supplies `isFailure`, `success` and `failure`.
|
|
16
12
|
|
|
17
13
|
## Usage
|
|
18
14
|
|
|
@@ -32,7 +28,7 @@ let schema = s.object({
|
|
|
32
28
|
let result = await validate({ email: "ada@example.com", name: "Ada" }, schema);
|
|
33
29
|
|
|
34
30
|
if (isFailure(result)) console.error(result.error.issues);
|
|
35
|
-
else console.log(result.data.email); // typed
|
|
31
|
+
else console.log(result.data.email); // typed from the schema
|
|
36
32
|
```
|
|
37
33
|
|
|
38
34
|
### Validate a form submission
|
|
@@ -46,11 +42,12 @@ form.append("tags", "typescript");
|
|
|
46
42
|
form.append("tags", "validation");
|
|
47
43
|
|
|
48
44
|
let result = await validate(form, s.object({ email: s.string(), tags: s.array(s.string()) }));
|
|
45
|
+
// result.data: { email: "ada@example.com", tags: ["typescript", "validation"] }
|
|
49
46
|
```
|
|
50
47
|
|
|
51
48
|
### Validate a request
|
|
52
49
|
|
|
53
|
-
A `Request` is read
|
|
50
|
+
A `Request` is read by its `Content-Type`, so one call covers JSON, multipart and URL-encoded submissions:
|
|
54
51
|
|
|
55
52
|
```typescript
|
|
56
53
|
async function handler(request: Request) {
|
|
@@ -66,7 +63,7 @@ async function handler(request: Request) {
|
|
|
66
63
|
|
|
67
64
|
### Read the failure
|
|
68
65
|
|
|
69
|
-
Every issue carries the path it failed at, so a refusal
|
|
66
|
+
Every issue carries the `path` it failed at, so a refusal reports field by field:
|
|
70
67
|
|
|
71
68
|
```typescript
|
|
72
69
|
if (isFailure(result)) {
|
|
@@ -78,109 +75,166 @@ if (isFailure(result)) {
|
|
|
78
75
|
|
|
79
76
|
## API
|
|
80
77
|
|
|
81
|
-
### `validate<Schema>(input, schema): Promise<Result<InferOutput<Schema>, ValidationError>>`
|
|
82
|
-
|
|
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.
|
|
78
|
+
### `validate<Schema>(input, schema): Promise<Result<StandardSchemaV1.InferOutput<Schema>, ValidationError>>`
|
|
84
79
|
|
|
85
|
-
`input`
|
|
80
|
+
Runs `input` through any Standard Schema V1 `schema` and resolves to the schema's output type or a `ValidationError`; the success type is inferred from the schema. `input` is one of:
|
|
86
81
|
|
|
87
|
-
- `FormData` or `URLSearchParams
|
|
88
|
-
- `Request
|
|
89
|
-
- A plain object
|
|
82
|
+
- `FormData` or `URLSearchParams`: flattened to an object, a repeated name becoming an array. A schema that validates the raw source (one that reports `Expected FormData or URLSearchParams`, such as `remix/data-schema/form-data`'s `object()`) receives the original `FormData` or `URLSearchParams` instead.
|
|
83
|
+
- `Request`: the body is read as JSON for `application/json` and any `+json` media type, as `FormData` for `multipart/form-data`, and as `URLSearchParams` for `application/x-www-form-urlencoded`. A malformed JSON body refuses with `Invalid JSON in request body`, and any other content type refuses with `Unsupported content-type: …`.
|
|
84
|
+
- A plain object or any JSON value, handed to the schema as it is.
|
|
90
85
|
|
|
91
|
-
|
|
86
|
+
### `ValidationError`
|
|
92
87
|
|
|
93
|
-
`
|
|
88
|
+
The `Error` a refusal carries. `message` is `"Validation Error"` and `issues` is the schema's own `StandardSchemaV1.Issue[]`, each a `message` plus an optional `path`.
|
|
94
89
|
|
|
95
|
-
|
|
90
|
+
#### `new ValidationError(issues: readonly StandardSchemaV1.Issue[])`
|
|
96
91
|
|
|
97
|
-
|
|
92
|
+
Builds a refusal from your own issues, so a rule checked outside the schema fails the same way the schema does.
|
|
98
93
|
|
|
99
|
-
## Pattern: One Schema Behind
|
|
94
|
+
## Pattern: One Schema Behind Every Entry Point
|
|
100
95
|
|
|
101
|
-
A schema is a value, so
|
|
96
|
+
A schema is a value, so a JSON API, an HTML form and a query string can share it. Validate a query string by passing the URL's `searchParams`:
|
|
102
97
|
|
|
103
98
|
```typescript
|
|
104
|
-
|
|
99
|
+
import { isFailure } from "@sdxc/result";
|
|
100
|
+
import { validate } from "@sdxc/validate";
|
|
105
101
|
import * as s from "remix/data-schema";
|
|
106
|
-
import { email,
|
|
102
|
+
import { email, minLength } from "remix/data-schema/checks";
|
|
103
|
+
import * as coerce from "remix/data-schema/coerce";
|
|
107
104
|
|
|
108
|
-
|
|
105
|
+
let signup = s.object({
|
|
109
106
|
email: s.string().pipe(email()),
|
|
110
107
|
name: s.string().pipe(minLength(2)),
|
|
111
|
-
age: s.optional(s.number().pipe(min(18))),
|
|
112
108
|
});
|
|
109
|
+
|
|
110
|
+
let search = s.object({
|
|
111
|
+
q: s.defaulted(s.string(), ""),
|
|
112
|
+
page: s.defaulted(coerce.number(), 1),
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
async function create(request: Request) {
|
|
116
|
+
let result = await validate(request, signup); // JSON or form body
|
|
117
|
+
if (isFailure(result)) return Response.json({ errors: result.error.issues }, { status: 400 });
|
|
118
|
+
return Response.json({ user: result.data }, { status: 201 });
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function list(request: Request) {
|
|
122
|
+
let result = await validate(new URL(request.url).searchParams, search);
|
|
123
|
+
if (isFailure(result)) return Response.json({ errors: result.error.issues }, { status: 400 });
|
|
124
|
+
return Response.json({ query: result.data.q, page: result.data.page });
|
|
125
|
+
}
|
|
113
126
|
```
|
|
114
127
|
|
|
128
|
+
## Pattern: Normalizing While Validating
|
|
129
|
+
|
|
130
|
+
Everything a form submits is a string. Coerce and transform in the schema so the handler receives the shape it wants:
|
|
131
|
+
|
|
132
|
+
```typescript
|
|
133
|
+
import { validate } from "@sdxc/validate";
|
|
134
|
+
import * as s from "remix/data-schema";
|
|
135
|
+
import { email } from "remix/data-schema/checks";
|
|
136
|
+
import * as coerce from "remix/data-schema/coerce";
|
|
137
|
+
|
|
138
|
+
let schema = s.object({
|
|
139
|
+
email: s
|
|
140
|
+
.string()
|
|
141
|
+
.pipe(email())
|
|
142
|
+
.transform((value) => value.toLowerCase()),
|
|
143
|
+
age: coerce.number(),
|
|
144
|
+
newsletter: s.defaulted(coerce.boolean(), false),
|
|
145
|
+
tags: s.string().transform((value) => value.split(",")),
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
let form = new FormData();
|
|
149
|
+
form.append("email", "ADA@EXAMPLE.COM");
|
|
150
|
+
form.append("age", "25");
|
|
151
|
+
form.append("tags", "typescript,validation");
|
|
152
|
+
|
|
153
|
+
let result = await validate(form, schema);
|
|
154
|
+
// result.data: { email: "ada@example.com", age: 25, newsletter: false, tags: ["typescript", "validation"] }
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
## Pattern: Uploads With a Form-Data Schema
|
|
158
|
+
|
|
159
|
+
Schemas from `remix/data-schema/form-data` read the `FormData` itself, which is what file fields need. `validate` hands them the original submission:
|
|
160
|
+
|
|
115
161
|
```typescript
|
|
116
162
|
import { isFailure } from "@sdxc/result";
|
|
117
163
|
import { validate } from "@sdxc/validate";
|
|
164
|
+
import * as s from "remix/data-schema";
|
|
165
|
+
import * as f from "remix/data-schema/form-data";
|
|
118
166
|
|
|
119
|
-
|
|
167
|
+
let upload = f.object({
|
|
168
|
+
title: f.field(s.string()),
|
|
169
|
+
attachments: f.files(s.array(s.instanceof_(File))),
|
|
170
|
+
});
|
|
120
171
|
|
|
121
172
|
async function handler(request: Request) {
|
|
122
|
-
let result = await validate(request,
|
|
173
|
+
let result = await validate(request, upload);
|
|
123
174
|
if (isFailure(result)) return Response.json({ errors: result.error.issues }, { status: 400 });
|
|
124
|
-
|
|
175
|
+
|
|
176
|
+
for (let file of result.data.attachments) console.log(file.name, file.size);
|
|
177
|
+
return new Response(null, { status: 204 });
|
|
125
178
|
}
|
|
126
179
|
```
|
|
127
180
|
|
|
128
|
-
|
|
181
|
+
## Pattern: Refusing On a Business Rule
|
|
182
|
+
|
|
183
|
+
Some rules need a database or another service. Fail them with your own `ValidationError` so callers handle one error type:
|
|
129
184
|
|
|
130
|
-
|
|
185
|
+
```typescript
|
|
186
|
+
import type { Result } from "@sdxc/result";
|
|
187
|
+
|
|
188
|
+
import { failure, isFailure, success } from "@sdxc/result";
|
|
189
|
+
import { ValidationError, validate } from "@sdxc/validate";
|
|
131
190
|
|
|
132
|
-
|
|
191
|
+
async function register(request: Request): Promise<Result<{ email: string }, ValidationError>> {
|
|
192
|
+
let result = await validate(request, signup);
|
|
193
|
+
if (isFailure(result)) return result;
|
|
194
|
+
|
|
195
|
+
if (await emailTaken(result.data.email)) {
|
|
196
|
+
return failure(new ValidationError([{ message: "That email is taken", path: ["email"] }]));
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return success({ email: result.data.email });
|
|
200
|
+
}
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
Leave `path` out for a refusal that belongs to no single field.
|
|
204
|
+
|
|
205
|
+
## Pattern: Rendering a Refused Submission
|
|
206
|
+
|
|
207
|
+
`ValidationError.issues` matches the issue shape of the `Form` in [`@sdxc/ui`](https://www.npmjs.com/package/@sdxc/ui). Pass it straight through: each field shows its own messages by `name`, marks itself invalid, and the first invalid field takes focus. Issues without a `path` render at the top of the form.
|
|
133
208
|
|
|
134
209
|
```tsx
|
|
135
210
|
import type { Handle } from "remix/component";
|
|
136
211
|
|
|
212
|
+
import { isFailure } from "@sdxc/result";
|
|
137
213
|
import { Button, Form, TextField } from "@sdxc/ui";
|
|
214
|
+
import { validate } from "@sdxc/validate";
|
|
138
215
|
|
|
139
216
|
function SignupForm(handle: Handle<{ issues?: ReadonlyArray<Form.Issue> }>) {
|
|
140
217
|
return () => (
|
|
141
218
|
<Form method="post" issues={handle.props.issues}>
|
|
142
219
|
<TextField label="Email" name="email" type="email" required />
|
|
143
|
-
<TextField label="
|
|
220
|
+
<TextField label="Name" name="name" required />
|
|
144
221
|
<Button type="submit">Sign up</Button>
|
|
145
222
|
</Form>
|
|
146
223
|
);
|
|
147
224
|
}
|
|
148
|
-
```
|
|
149
|
-
|
|
150
|
-
```tsx
|
|
151
|
-
let result = await validate(submission, schema);
|
|
152
225
|
|
|
226
|
+
let result = await validate(request, signup);
|
|
153
227
|
if (isFailure(result)) return <SignupForm issues={result.error.issues} />;
|
|
154
228
|
```
|
|
155
229
|
|
|
156
|
-
## Pattern: Normalizing While Validating
|
|
157
|
-
|
|
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:
|
|
159
|
-
|
|
160
|
-
```typescript
|
|
161
|
-
let schema = s.object({
|
|
162
|
-
email: s
|
|
163
|
-
.string()
|
|
164
|
-
.pipe(email())
|
|
165
|
-
.transform((value) => value.toLowerCase()),
|
|
166
|
-
age: s.string().transform(Number),
|
|
167
|
-
tags: s.string().transform((value) => value.split(",")),
|
|
168
|
-
});
|
|
169
|
-
|
|
170
|
-
let form = new FormData();
|
|
171
|
-
form.append("email", "ADA@EXAMPLE.COM");
|
|
172
|
-
form.append("age", "25");
|
|
173
|
-
form.append("tags", "typescript,validation");
|
|
174
|
-
|
|
175
|
-
let result = await validate(form, schema);
|
|
176
|
-
// { email: "ada@example.com", age: 25, tags: ["typescript", "validation"] }
|
|
177
|
-
```
|
|
178
|
-
|
|
179
230
|
## Pattern: Custom Messages
|
|
180
231
|
|
|
181
|
-
|
|
232
|
+
The messages in `issues` are the schema's. With `remix/data-schema`, a check is a plain object, so spread it and override `message`; `.refine()` takes its message as the second argument:
|
|
182
233
|
|
|
183
234
|
```typescript
|
|
235
|
+
import * as s from "remix/data-schema";
|
|
236
|
+
import { email, minLength } from "remix/data-schema/checks";
|
|
237
|
+
|
|
184
238
|
let schema = s.object({
|
|
185
239
|
email: s.string().pipe({ ...email(), message: "Please enter a valid email address" }),
|
|
186
240
|
password: s
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sdxc/validate",
|
|
3
|
-
"version": "2026.10.
|
|
3
|
+
"version": "2026.10.13",
|
|
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.10.
|
|
12
|
-
"@sdxc/types": "2026.10.
|
|
11
|
+
"@sdxc/result": "2026.10.13",
|
|
12
|
+
"@sdxc/types": "2026.10.13",
|
|
13
13
|
"@standard-schema/spec": "^1.1.0"
|
|
14
14
|
},
|
|
15
|
-
"gitHead": "
|
|
15
|
+
"gitHead": "5ff758a57e35480a8db7abd24f1d9527b81bfb51",
|
|
16
16
|
"publishConfig": {
|
|
17
17
|
"access": "public"
|
|
18
18
|
},
|
|
@@ -20,5 +20,14 @@
|
|
|
20
20
|
"type": "git",
|
|
21
21
|
"url": "git+https://github.com/sergiodxa/monorepo.git",
|
|
22
22
|
"directory": "packages/validate"
|
|
23
|
+
},
|
|
24
|
+
"homepage": "https://sdxc.sergiodxa.com/api/validate",
|
|
25
|
+
"author": {
|
|
26
|
+
"name": "Sergio Xalambrí",
|
|
27
|
+
"url": "https://sergiodxa.com"
|
|
28
|
+
},
|
|
29
|
+
"funding": {
|
|
30
|
+
"type": "github",
|
|
31
|
+
"url": "https://github.com/sponsors/sergiodxa"
|
|
23
32
|
}
|
|
24
33
|
}
|