@zerotal/arch 1.7.0

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 (160) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/LICENSE +21 -0
  3. package/README.md +67 -0
  4. package/api-surface.md +283 -0
  5. package/docs/about.md +676 -0
  6. package/docs/admin/actions.md +183 -0
  7. package/docs/admin/auth.md +89 -0
  8. package/docs/admin/dashboard.md +155 -0
  9. package/docs/admin/extending-ui.md +229 -0
  10. package/docs/admin/extending.md +205 -0
  11. package/docs/admin/forms.md +155 -0
  12. package/docs/admin/index.md +144 -0
  13. package/docs/admin/operations.md +224 -0
  14. package/docs/admin/references.md +174 -0
  15. package/docs/admin/resources.md +132 -0
  16. package/docs/admin/structure.md +158 -0
  17. package/docs/admin/tables.md +185 -0
  18. package/docs/admin/testing.md +138 -0
  19. package/docs/ai.md +373 -0
  20. package/docs/application.md +435 -0
  21. package/docs/arch.md +263 -0
  22. package/docs/assets.md +755 -0
  23. package/docs/audit.md +367 -0
  24. package/docs/authentication.md +1000 -0
  25. package/docs/authorization.md +529 -0
  26. package/docs/broadcasting/channels.md +170 -0
  27. package/docs/broadcasting/client.md +108 -0
  28. package/docs/broadcasting/events.md +190 -0
  29. package/docs/broadcasting/index.md +163 -0
  30. package/docs/broadcasting/references.md +84 -0
  31. package/docs/broadcasting/testing.md +132 -0
  32. package/docs/cache.md +387 -0
  33. package/docs/carbon.md +830 -0
  34. package/docs/changelog.md +465 -0
  35. package/docs/client/auth.md +113 -0
  36. package/docs/client/errors.md +139 -0
  37. package/docs/client/files.md +118 -0
  38. package/docs/client/index.md +128 -0
  39. package/docs/client/references.md +58 -0
  40. package/docs/client/requests.md +131 -0
  41. package/docs/client/resilience.md +141 -0
  42. package/docs/client/testing.md +146 -0
  43. package/docs/commands.md +450 -0
  44. package/docs/components.md +2427 -0
  45. package/docs/config-system.md +258 -0
  46. package/docs/container.md +596 -0
  47. package/docs/context.md +579 -0
  48. package/docs/contributing.md +164 -0
  49. package/docs/controllers.md +354 -0
  50. package/docs/conventions.md +466 -0
  51. package/docs/cookies.md +101 -0
  52. package/docs/csrf.md +206 -0
  53. package/docs/database.md +476 -0
  54. package/docs/deployment.md +434 -0
  55. package/docs/devtools.md +1013 -0
  56. package/docs/email-verification.md +320 -0
  57. package/docs/encryption.md +380 -0
  58. package/docs/errors.md +317 -0
  59. package/docs/events.md +420 -0
  60. package/docs/flow/components.md +846 -0
  61. package/docs/flow/decorators.md +458 -0
  62. package/docs/flow/events.md +420 -0
  63. package/docs/flow/forms.md +551 -0
  64. package/docs/flow/index.md +568 -0
  65. package/docs/flow/layouts.md +793 -0
  66. package/docs/flow/lifecycle.md +336 -0
  67. package/docs/flow/pagination.md +402 -0
  68. package/docs/flow/performance.md +262 -0
  69. package/docs/flow/references.md +317 -0
  70. package/docs/flow/routing.md +550 -0
  71. package/docs/flow/testing.md +447 -0
  72. package/docs/getting-started.md +286 -0
  73. package/docs/health.md +269 -0
  74. package/docs/helpers.md +423 -0
  75. package/docs/i18n.md +414 -0
  76. package/docs/index.md +65 -0
  77. package/docs/inertia/build.md +95 -0
  78. package/docs/inertia/devtools.md +197 -0
  79. package/docs/inertia/index.md +267 -0
  80. package/docs/inertia/middleware.md +81 -0
  81. package/docs/inertia/props.md +507 -0
  82. package/docs/inertia/references.md +144 -0
  83. package/docs/inertia/rendering.md +189 -0
  84. package/docs/inertia/ssr.md +102 -0
  85. package/docs/inspirations.md +257 -0
  86. package/docs/lifecycle.md +331 -0
  87. package/docs/lock.md +454 -0
  88. package/docs/logger.md +517 -0
  89. package/docs/media.md +582 -0
  90. package/docs/middleware.md +509 -0
  91. package/docs/migrations.md +529 -0
  92. package/docs/monitor.md +377 -0
  93. package/docs/notifications.md +848 -0
  94. package/docs/orm/casts.md +365 -0
  95. package/docs/orm/factories.md +231 -0
  96. package/docs/orm/index.md +663 -0
  97. package/docs/orm/lifecycle.md +421 -0
  98. package/docs/orm/queries.md +692 -0
  99. package/docs/orm/relationships.md +440 -0
  100. package/docs/orm/serialization.md +251 -0
  101. package/docs/package-development.md +536 -0
  102. package/docs/pagination.md +340 -0
  103. package/docs/password-reset.md +335 -0
  104. package/docs/providers.md +447 -0
  105. package/docs/query-builder.md +465 -0
  106. package/docs/queue.md +549 -0
  107. package/docs/rate-limiting.md +253 -0
  108. package/docs/responses.md +336 -0
  109. package/docs/roles-and-2fa.md +418 -0
  110. package/docs/routing.md +1008 -0
  111. package/docs/scaffolding.md +335 -0
  112. package/docs/scheduler.md +533 -0
  113. package/docs/seeding.md +229 -0
  114. package/docs/session.md +451 -0
  115. package/docs/social.md +524 -0
  116. package/docs/storage.md +622 -0
  117. package/docs/structure.md +230 -0
  118. package/docs/support-policy.md +114 -0
  119. package/docs/telemetry.md +505 -0
  120. package/docs/tenancy.md +608 -0
  121. package/docs/testing/browser.md +153 -0
  122. package/docs/testing/console.md +130 -0
  123. package/docs/testing/database.md +238 -0
  124. package/docs/testing/flow-browser.md +216 -0
  125. package/docs/testing/http.md +441 -0
  126. package/docs/testing/index.md +240 -0
  127. package/docs/testing/mocking.md +410 -0
  128. package/docs/upgrade.md +192 -0
  129. package/docs/validator.md +549 -0
  130. package/docs/view.md +453 -0
  131. package/package.json +60 -0
  132. package/src/bin/mcp.ts +73 -0
  133. package/src/config.ts +63 -0
  134. package/src/errors.ts +27 -0
  135. package/src/index.ts +56 -0
  136. package/src/install/ArchInstallCommand.ts +189 -0
  137. package/src/install/detect.ts +98 -0
  138. package/src/install/guidelines.ts +290 -0
  139. package/src/install/markers.ts +77 -0
  140. package/src/install/mcpConfig.ts +81 -0
  141. package/src/mcp/index.ts +27 -0
  142. package/src/mcp/jsonrpc.ts +133 -0
  143. package/src/mcp/server.ts +307 -0
  144. package/src/mcp/stdio.ts +111 -0
  145. package/src/mcp/types.ts +146 -0
  146. package/src/probe/ArchProbeCommand.ts +48 -0
  147. package/src/probe/sentinel.ts +13 -0
  148. package/src/probe/topics.ts +291 -0
  149. package/src/provider/ArchProvider.ts +101 -0
  150. package/src/tools/_probe.ts +164 -0
  151. package/src/tools/apiSurface.ts +241 -0
  152. package/src/tools/appInfo.ts +88 -0
  153. package/src/tools/baselines.ts +210 -0
  154. package/src/tools/context.ts +37 -0
  155. package/src/tools/doctor.ts +93 -0
  156. package/src/tools/index.ts +50 -0
  157. package/src/tools/logs.ts +265 -0
  158. package/src/tools/routes.ts +133 -0
  159. package/src/tools/schema.ts +119 -0
  160. package/src/tools/searchDocs.ts +345 -0
@@ -0,0 +1,549 @@
1
+ ---
2
+ title: Validation
3
+ description: Validate request input against typed rule chains and turn failures into the right HTTP response automatically.
4
+ ---
5
+
6
+ # Validation
7
+
8
+ The validator checks incoming request data against rule chains and produces
9
+ fully-typed output — no manual type assertions needed. It offers two entry
10
+ points: `FormRequest` (class-based, recommended) and the standalone `validate()`
11
+ function (for one-off use).
12
+
13
+ ## Getting Started
14
+
15
+ ```bash
16
+ # in your project root
17
+ bun add @zerotal/validator
18
+ ```
19
+
20
+ The validator has no provider to register — import its classes and functions
21
+ directly. Database-aware rules (`unique()`, `exists()`) do require
22
+ [`DatabaseProvider`](/docs/database) to be registered; see
23
+ [Database rules](#database-rules).
24
+
25
+ ## Configuration
26
+
27
+ Create `config/validator.ts` with the `ValidatorConfig()` helper so every field
28
+ stays type-checked while defaults are filled in:
29
+
30
+ ```typescript
31
+ // config/validator.ts
32
+ import { ValidatorConfig } from "@zerotal/validator";
33
+
34
+ export default ValidatorConfig({
35
+ stopOnFirstFailure: true, // stop after the first failure per field
36
+ locale: "en", // default locale for error messages
37
+ });
38
+ ```
39
+
40
+ | Field | Required | Default | Description |
41
+ | -------------------- | -------- | ------- | -------------------------------------------------- |
42
+ | `stopOnFirstFailure` | no | `true` | Stop validation after the first failure per field. |
43
+ | `locale` | no | `"en"` | Default locale for error messages. |
44
+
45
+ ## Which entry point should I use?
46
+
47
+ - **`FormRequest`** — the default for HTTP handlers. Encapsulates rules and
48
+ authorization in a reusable, testable class, reads the request from the active
49
+ context, and throws the right error on failure.
50
+ - **`validate(ctx, factory)`** — for one-off HTTP validation where a dedicated
51
+ class would be overkill. Same failure behavior as `FormRequest`.
52
+ - **`Validator.check(data, factory)`** — for non-HTTP code (CLI commands,
53
+ services, background jobs). Returns a result object and never throws or
54
+ redirects. See [Non-HTTP validation](#non-http-validation).
55
+
56
+ ## FormRequest
57
+
58
+ ### Defining a request
59
+
60
+ ```typescript
61
+ // app/requests/posts/StorePostRequest.ts
62
+ import { FormRequest, RuleBuilder } from "@zerotal/validator";
63
+
64
+ export class StorePostRequest extends FormRequest {
65
+ // Return true to allow, false to deny with a 403 ForbiddenError.
66
+ // Access the current request via this.context.
67
+ authorize(): boolean {
68
+ return !!this.context.user;
69
+ }
70
+
71
+ // Do NOT annotate the return type — TypeScript needs to infer the narrow shape
72
+ // for validate() to produce a typed result.
73
+ rules(r: RuleBuilder) {
74
+ return {
75
+ title: r.string().min(3).max(255),
76
+ body: r.string().min(10),
77
+ tags: r.array(r.string()).optional(),
78
+ };
79
+ }
80
+ }
81
+ ```
82
+
83
+ > **Warning** — Do not add an explicit return type to `rules()`. Annotating it
84
+ > (e.g. as `Record<string, FieldRule>`) widens the inferred type and makes
85
+ > `validate()` return `Record<string, unknown>` instead of your typed shape.
86
+
87
+ ### Calling validate
88
+
89
+ Call `validate()` as a static method on the class. It reads `HttpContext` from
90
+ `AsyncLocalStorage` — no arguments needed.
91
+
92
+ ```typescript
93
+ // in a controller
94
+ const data = await StorePostRequest.validate();
95
+
96
+ data.title; // string — fully typed, no cast needed
97
+ data.body; // string
98
+ data.tags; // string[] | undefined
99
+ ```
100
+
101
+ On failure, `validate()` stores `errors` and `old` (previous input) in the
102
+ session, then branches on the request type:
103
+
104
+ | Request type | Detected by | Error thrown | HTTP result |
105
+ | ------------ | ------------------------------------------------------ | ------------------------- | ----------------------------------------------- |
106
+ | JSON / API | `Accept: application/json` (and no `X-Inertia` header) | `ValidationJsonError` | 422 JSON with field errors |
107
+ | Inertia | `X-Inertia: true` header | `ValidationRedirectError` | Redirect back; errors surfaced via shared props |
108
+ | HTML form | neither header | `ValidationRedirectError` | Redirect back; errors in session |
109
+
110
+ Both `ValidationJsonError` and `ValidationRedirectError` are exported from
111
+ `@zerotal/validator`. The exception handler renders them appropriately — you
112
+ never need to catch them yourself.
113
+
114
+ ### Authorization
115
+
116
+ `authorize()` runs **before** validation. Returning `false` throws a
117
+ `ForbiddenError` (403) without touching the request body.
118
+
119
+ ```typescript
120
+ // in a FormRequest subclass
121
+
122
+ // Check the current user's role:
123
+ authorize(): boolean {
124
+ return (this.context.user as { role?: string })?.role === "admin";
125
+ }
126
+
127
+ // Use Gate.allows() for policy-based auth:
128
+ authorize(): boolean {
129
+ return Gate.via(PostPolicy).allows("create", new Post());
130
+ }
131
+
132
+ // Async authorize is also supported:
133
+ async authorize(): Promise<boolean> {
134
+ const post = await Post.findOrFail(Number(this.context.params["id"]));
135
+ return Gate.via(PostPolicy).allows("update", post);
136
+ }
137
+ ```
138
+
139
+ ### Accessing context inside rules
140
+
141
+ Because `rules()` is an instance method, you have full access to `this.context`
142
+ for rules that depend on the current user, route params, or session state:
143
+
144
+ ```typescript
145
+ // in a FormRequest subclass
146
+ rules(r: RuleBuilder) {
147
+ const userId = this.context.user?.id;
148
+ return {
149
+ email: r.string().email()
150
+ .unique("users", "email", userId), // ignore current user on update
151
+ name: r.string().min(2).max(100),
152
+ };
153
+ }
154
+ ```
155
+
156
+ ## Standalone validate
157
+
158
+ For simple one-off validation without a dedicated class:
159
+
160
+ ```typescript
161
+ // in a controller
162
+ import { validate } from "@zerotal/validator";
163
+
164
+ // validate(ctx, factory) reads the request body from the HttpContext and
165
+ // returns the validated, typed data directly. On failure it throws —
166
+ // ValidationJsonError (→ 422 JSON) or ValidationRedirectError (→ 303 back) —
167
+ // which the global exception handler turns into the right response.
168
+ const data = await validate(ctx, (r) => ({
169
+ title: r.string().min(3),
170
+ count: r.number().integer().min(1),
171
+ }));
172
+
173
+ data.title; // string — fully typed, no cast needed
174
+ data.count; // number
175
+ ```
176
+
177
+ ## Non-HTTP validation
178
+
179
+ For CLI commands, services, or background jobs — where there is no request to
180
+ redirect and no response to throw — use the `Validator` facade. It returns a
181
+ `{ success, data, errors }` outcome and never throws.
182
+
183
+ ```typescript
184
+ // in a command or service
185
+ import { Validator } from "@zerotal/validator";
186
+
187
+ const result = Validator.check(payload, (r) => ({
188
+ email: r.string().email(),
189
+ age: r.number().min(0),
190
+ }));
191
+
192
+ if (!result.success) {
193
+ console.error(result.errors);
194
+ } else {
195
+ result.data.email; // string — fully typed
196
+ }
197
+ ```
198
+
199
+ ## RuleBuilder types
200
+
201
+ Start every rule chain with a type method on `RuleBuilder`:
202
+
203
+ ```typescript
204
+ // in a rules() method or factory
205
+ const r = new RuleBuilder(); // or the `r` param in rules()
206
+
207
+ r.string(); // StringRule
208
+ r.number(); // NumberRule
209
+ r.boolean(); // BooleanRule
210
+ r.date(); // DateRule
211
+ r.file(); // FileRule — multipart/form-data uploads
212
+ r.password(); // PasswordRule — strength validation
213
+
214
+ // Convenience shorthands on RuleBuilder:
215
+ r.required(); // r.string().required() — required string, the common case
216
+ r.email(); // r.string().email()
217
+ r.url(); // r.string().url()
218
+ r.uuid(); // r.string().uuid()
219
+
220
+ r.array(r.string()); // ArrayRule<StringRule> — array of strings
221
+ r.object({/* … */}); // ObjectRule — nested object
222
+ ```
223
+
224
+ ## Common modifiers
225
+
226
+ These are available on **every** rule type:
227
+
228
+ | Method | Description |
229
+ | --------------------------------- | ---------------------------------------------------------------------------- |
230
+ | `.required(msg?)` | Field must be present and non-empty |
231
+ | `.optional()` | Field may be absent; result type becomes `T \| undefined` |
232
+ | `.nullable()` | Field may be `null` |
233
+ | `.default(val)` | Use `val` when field is absent; implies `.optional()` |
234
+ | `.sometimes()` | Only validate when field is present in input (PATCH-friendly) |
235
+ | `.bail()` | Stop after the first failing rule on this field |
236
+ | `.custom(fn)` | Run a custom sync/async function; return `true`, `false`, or an error string |
237
+ | `.requiredIf(field, value)` | Required when another field equals `value` |
238
+ | `.requiredUnless(field, value)` | Required unless another field equals `value` |
239
+ | `.requiredWith(fields[])` | Required when ANY listed field is present |
240
+ | `.requiredWithout(fields[])` | Required when ANY listed field is absent |
241
+ | `.requiredWithAll(fields[])` | Required when ALL listed fields are present |
242
+ | `.requiredWithoutAll(fields[])` | Required when ALL listed fields are absent |
243
+ | `.prohibitedIf(field, value)` | Must be absent when another field equals `value` |
244
+ | `.prohibitedUnless(field, value)` | Must be absent unless another field equals `value` |
245
+ | `.accepted()` | Truthy: `true`, `1`, `'1'`, `'yes'`, `'on'`, `'true'` |
246
+ | `.declined()` | Falsy: `false`, `0`, `'0'`, `'no'`, `'off'`, `'false'` |
247
+
248
+ ### Custom validator
249
+
250
+ ```typescript
251
+ // in a rules() method or factory
252
+ username: r.string().custom(async (value) => {
253
+ const taken = await User.findBy("username", value as string);
254
+ return taken ? "This username is already taken." : true;
255
+ });
256
+ ```
257
+
258
+ ## String rules
259
+
260
+ ```typescript
261
+ // in a rules() method or factory
262
+ r.string()
263
+ .min(3) // minimum character length
264
+ .max(255) // maximum character length
265
+ .size(10) // exact character length
266
+ .email() // valid email address
267
+ .url() // valid URL
268
+ .uuid() // valid UUID v4
269
+ .ip() // valid IPv4 or IPv6
270
+ .json() // parseable JSON string
271
+ .regex(/^[A-Z]+$/) // matches regex
272
+ .matches(/^[A-Z]+$/, "msg") // alias for regex()
273
+ .in(["a", "b", "c"]) // value in list
274
+ .notIn(["x", "y"]) // value not in list
275
+ .alpha() // letters only (a-z, A-Z)
276
+ .alphaNum() // letters and digits
277
+ .alphaDash() // letters, digits, hyphens, underscores
278
+ .numeric() // digits only (optional leading minus)
279
+ .digits(6) // exactly 6 digits
280
+ .digitsBetween(4, 8) // between 4 and 8 digits
281
+ .startsWith("https://") // must start with prefix
282
+ .endsWith(".pdf") // must end with suffix
283
+ .trim() // strip leading/trailing whitespace (transform)
284
+ .lowercase() // convert to lowercase (transform)
285
+ .uppercase() // convert to uppercase (transform)
286
+ .confirmed() // value must equal `{field}_confirmation` in input
287
+ .sameAs("password") // value must equal another field
288
+ .present() // key must exist in input (may be empty)
289
+ .prohibited() // key must be absent from input
290
+ .password(); // semantic alias — chain constraints after this
291
+ ```
292
+
293
+ ## Number rules
294
+
295
+ ```typescript
296
+ // in a rules() method or factory
297
+ r.number()
298
+ .min(0) // minimum value (inclusive)
299
+ .max(100) // maximum value (inclusive)
300
+ .between(1, 10) // min and max in one call
301
+ .integer() // must be a whole number
302
+ .positive() // shorthand for .min(0)
303
+ .notIn([0, -1]); // value not in list
304
+ ```
305
+
306
+ ## Boolean rules
307
+
308
+ ```typescript
309
+ // in a rules() method or factory
310
+ r.boolean()
311
+ .accepted() // truthy — for "agree to terms" checkboxes
312
+ .declined(); // falsy
313
+ ```
314
+
315
+ ## Date rules
316
+
317
+ ```typescript
318
+ // in a rules() method or factory
319
+ r.date()
320
+ .after("2026-01-01") // strictly after date
321
+ .before(new Date()) // strictly before date
322
+ .afterOrEqual("2026-01-01") // on or after
323
+ .beforeOrEqual("2026-12-31"); // on or before
324
+ ```
325
+
326
+ ## Array rules
327
+
328
+ ```typescript
329
+ // in a rules() method or factory
330
+ r.array(r.string()) // array of strings
331
+ .min(1) // minimum item count
332
+ .max(10) // maximum item count
333
+ .size(3); // exact item count
334
+
335
+ r.array(r.number().integer()); // array of integers
336
+
337
+ // Nested objects in an array:
338
+ r.array(r.object({ name: r.string(), age: r.number() }));
339
+ ```
340
+
341
+ ## Object rules
342
+
343
+ ```typescript
344
+ // in a rules() method or factory
345
+ r.object({
346
+ street: r.string(),
347
+ city: r.string(),
348
+ country: r.string().in(["US", "CA", "GB"]),
349
+ zip: r
350
+ .string()
351
+ .regex(/^\d{5}$/)
352
+ .optional(),
353
+ });
354
+ ```
355
+
356
+ ## File rules
357
+
358
+ Validates `File` objects from `multipart/form-data` uploads:
359
+
360
+ ```typescript
361
+ // in a rules() method or factory
362
+ avatar: r.file()
363
+ .mimes(["jpg", "jpeg", "png", "webp"]) // allowed extensions
364
+ .max(2048) // maximum KB
365
+ .min(1) // minimum KB
366
+ .optional();
367
+ ```
368
+
369
+ ## Password rules
370
+
371
+ ```typescript
372
+ // in a rules() method or factory
373
+ r.password()
374
+ .min(8) // minimum length (default: 8)
375
+ .mixedCase() // requires upper + lower case letters
376
+ .numbers() // requires at least one digit
377
+ .symbols() // requires at least one symbol
378
+ .uncompromised() // must not contain spaces
379
+ .confirmed(); // must equal password_confirmation field
380
+ ```
381
+
382
+ ## Database rules
383
+
384
+ These rules require [`DatabaseProvider`](/docs/database) to be registered; they
385
+ throw at runtime otherwise.
386
+
387
+ ### unique — value must not already exist in the DB
388
+
389
+ The third argument ignores a record on update — pass the current record's ID, or
390
+ a `UniqueOptions` object (`{ ignoreId }`) for clarity. A fourth argument
391
+ overrides the error message.
392
+
393
+ ```typescript
394
+ // in a rules() method or factory
395
+ email: r.string().email().unique("users", "email");
396
+
397
+ // Ignore the current record when updating (pass the record's ID):
398
+ email: r.string().email().unique("users", "email", this.context.user?.id);
399
+
400
+ // Equivalent, using the options object:
401
+ email: r.string().email().unique("users", "email", { ignoreId: userId });
402
+ ```
403
+
404
+ ### exists — value must exist in the DB
405
+
406
+ ```typescript
407
+ // in a rules() method or factory
408
+ userId: r.number().exists("users", "id");
409
+ roleSlug: r.string().exists("roles", "slug");
410
+ ```
411
+
412
+ > **Note** — These rules stay decoupled from the ORM: `DatabaseProvider`
413
+ > registers the query executor via `registerDbRuleRunner()` during its boot, so
414
+ > `@zerotal/validator` never depends directly on `@zerotal/orm`.
415
+
416
+ ## Precognition
417
+
418
+ When a request carries a `Precognition: true` header, `FormRequest.validate()`
419
+ runs the rules without executing the controller body, then short-circuits with a
420
+ `204` (no errors) or `422` (with errors). A `Precognition-Validate-Only` header
421
+ (comma-separated field names) narrows the validated set to just those fields —
422
+ useful for live, field-by-field client validation.
423
+
424
+ ## Error handling
425
+
426
+ ### JSON / API requests
427
+
428
+ When `Accept: application/json` is present (and no `X-Inertia` header),
429
+ validation throws `ValidationJsonError`:
430
+
431
+ ```json
432
+ {
433
+ "message": "The given data was invalid.",
434
+ "errors": {
435
+ "title": ["The title must be at least 3 characters."],
436
+ "email": ["The email has already been taken."]
437
+ }
438
+ }
439
+ ```
440
+
441
+ The response is sent as `422 Unprocessable Entity`. `ExceptionHandler` catches
442
+ the error and renders this automatically.
443
+
444
+ ### Inertia / HTML form requests
445
+
446
+ When the request is an Inertia request or has no JSON `Accept` header, validation
447
+ throws `ValidationRedirectError`. `ExceptionHandler` converts this to a redirect
448
+ back to the previous page.
449
+
450
+ Errors and old input are stored in the [session](/docs/session) under the keys
451
+ `'errors'` and `'old'`. Read them on the next request:
452
+
453
+ ```typescript
454
+ // in a controller
455
+ const errors = ctx.flashed<Record<string, string[]>>("errors");
456
+ const old = ctx.flashed<Record<string, unknown>>("old");
457
+ ```
458
+
459
+ In [Inertia](/docs/inertia), errors are passed automatically as the `errors`
460
+ prop via shared props — no manual session reading required.
461
+
462
+ ## Testing
463
+
464
+ Set your suite up once as described in [Testing](/docs/testing). Validation is
465
+ worth testing at the rule level, because that is where the mistakes are and it
466
+ needs no request.
467
+
468
+ **`Validator.check()` never throws**, so a rule set tests as a pure function —
469
+ assert on the outcome rather than on a caught error:
470
+
471
+ ```typescript
472
+ // tests/validation/RegisterRules.test.ts
473
+ import { test, expect } from "bun:test";
474
+ import { Validator } from "@zerotal/validator";
475
+
476
+ const rules = (r) => ({
477
+ email: r.string().email(),
478
+ age: r.number().min(18),
479
+ });
480
+
481
+ test("rejects an underage applicant", () => {
482
+ const result = Validator.check({ email: "jane@example.com", age: 17 }, rules);
483
+
484
+ expect(result.success).toBe(false);
485
+ expect(result.errors.age).toBeDefined();
486
+ });
487
+
488
+ test("returns typed data on success", () => {
489
+ const result = Validator.check({ email: "jane@example.com", age: 30 }, rules);
490
+
491
+ expect(result.success).toBe(true);
492
+ if (result.success) expect(result.data.email).toBe("jane@example.com");
493
+ });
494
+ ```
495
+
496
+ **Test the values that sit either side of a boundary**, not a comfortable middle.
497
+ `age: 18` and `age: 17` prove `min(18)`; `age: 30` proves nothing that `age: 19`
498
+ would not.
499
+
500
+ **A `FormRequest` fails the HTTP request**, so test it through the route it
501
+ guards. The status tells you which failure mode you got:
502
+
503
+ ```typescript
504
+ // tests/http/register.test.ts
505
+ const res = await app.post("/register", { email: "not-an-email" });
506
+
507
+ res.assertUnprocessable(); // 422 for a JSON request
508
+ ```
509
+
510
+ > **Note** — The same failure redirects with flashed errors for a form request
511
+ > and returns `422` for a JSON one. Assert the shape your route actually serves;
512
+ > a test posting JSON to a form endpoint will pass for the wrong reason.
513
+
514
+ ## References
515
+
516
+ ### RuleBuilder
517
+
518
+ | Method | Signature | Description |
519
+ | ---------- | --------------------------------------------------- | ----------------------------------------- |
520
+ | `required` | `required(message?: string): StringRule` | Required string — the common entry point. |
521
+ | `string` | `string(): StringRule` | Begin a string rule chain. |
522
+ | `number` | `number(): NumberRule` | Begin a number rule chain. |
523
+ | `boolean` | `boolean(): BooleanRule` | Begin a boolean rule chain. |
524
+ | `date` | `date(): DateRule` | Begin a date rule chain. |
525
+ | `file` | `file(): FileRule` | Validate a multipart/form-data upload. |
526
+ | `password` | `password(): PasswordRule` | Validate password strength. |
527
+ | `email` | `email(message?): StringRule` | Shorthand for `string().email()`. |
528
+ | `url` | `url(message?): StringRule` | Shorthand for `string().url()`. |
529
+ | `uuid` | `uuid(message?): StringRule` | Shorthand for `string().uuid()`. |
530
+ | `array` | `array<T extends FieldRule>(item: T): ArrayRule<T>` | Array whose items match `item`. |
531
+ | `object` | `object<S>(shape): ObjectRule<S>` | Nested object matching `shape`. |
532
+
533
+ ### Entry points
534
+
535
+ | Member | Signature | Description |
536
+ | ----------------- | -------------------------------------------- | ---------------------------------------------------- |
537
+ | `FormRequest` | `static validate(): Promise<Infer<…>>` | Validate the active request against the class rules. |
538
+ | `FormRequest` | `authorize(): boolean \| Promise<boolean>` | Allow/deny the request before validation. |
539
+ | `FormRequest` | `rules(r: RuleBuilder)` | Return the field-to-rule schema (no return type). |
540
+ | `validate` | `validate(ctx, factory): Promise<Infer<…>>` | One-off HTTP validation; throws on failure. |
541
+ | `Validator.check` | `check(data, factory): ValidationOutcome<…>` | Non-HTTP validation; returns a result, never throws. |
542
+
543
+ ## Next steps
544
+
545
+ - [Requests Context](/docs/context#reading-input) — read the input that `FormRequest` validates.
546
+ - [Errors](/docs/errors) — how `ExceptionHandler` renders validation failures.
547
+ - [Authorization](/docs/authorization) — back `authorize()` with policies.
548
+ - [Database](/docs/database) — register `DatabaseProvider` for `unique()` and `exists()`.
549
+ - [Inertia](/docs/inertia) — where the `errors` shared prop comes from.