@sdxc/validate 2026.10.6 → 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.
Files changed (2) hide show
  1. package/README.md +116 -62
  2. package/package.json +4 -4
package/README.md CHANGED
@@ -1,18 +1,14 @@
1
1
  # @sdxc/validate
2
2
 
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.
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
- ```bash
7
+ ```sh
8
8
  npm add @sdxc/validate
9
9
  ```
10
10
 
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`.
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 `string` from the schema
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 according to its `Content-Type`, so one call covers every kind of submission a route accepts:
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 can be reported field by field:
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` is any of:
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` — entries flatten to an object, a repeated name becoming an array.
88
- - `Request` — the body is read by content type: `application/json` or any `+json` media type (`application/merge-patch+json`, `application/problem+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.
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
- 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.
86
+ ### `ValidationError`
92
87
 
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.
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
- ### `ValidationError`
90
+ #### `new ValidationError(issues: readonly StandardSchemaV1.Issue[])`
96
91
 
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.
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 Several Entry Points
94
+ ## Pattern: One Schema Behind Every Entry Point
100
95
 
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:
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
- // schemas.ts
99
+ import { isFailure } from "@sdxc/result";
100
+ import { validate } from "@sdxc/validate";
105
101
  import * as s from "remix/data-schema";
106
- import { email, min, minLength } from "remix/data-schema/checks";
102
+ import { email, minLength } from "remix/data-schema/checks";
103
+ import * as coerce from "remix/data-schema/coerce";
107
104
 
108
- export let signup = s.object({
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
- import { signup } from "./schemas.js";
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, signup);
173
+ let result = await validate(request, upload);
123
174
  if (isFailure(result)) return Response.json({ errors: result.error.issues }, { status: 400 });
124
- return Response.json({ user: await createUser(result.data) });
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
- There is no `.partial()` helper, so a variant where every field is optional wraps each one in `s.optional()`.
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
- ## Pattern: Rendering A Refused Submission
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
- `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:
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="Password" name="password" type="password" required />
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
- 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:
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.6",
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.6",
12
- "@sdxc/types": "2026.10.2",
11
+ "@sdxc/result": "2026.10.13",
12
+ "@sdxc/types": "2026.10.13",
13
13
  "@standard-schema/spec": "^1.1.0"
14
14
  },
15
- "gitHead": "b1067eb20fc47ec51ff8c4de47d07dc3f9c00466",
15
+ "gitHead": "5ff758a57e35480a8db7abd24f1d9527b81bfb51",
16
16
  "publishConfig": {
17
17
  "access": "public"
18
18
  },