@warlock.js/seal 5.30.0 → 5.31.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 (88) hide show
  1. package/CHANGELOG.md +38 -7
  2. package/README.md +57 -9
  3. package/cjs/index.cjs +24 -16
  4. package/cjs/index.cjs.map +1 -1
  5. package/cjs/{object--LXRt5RF.cjs → object-VTtA-k1a.cjs} +15 -13
  6. package/cjs/object-VTtA-k1a.cjs.map +1 -0
  7. package/cjs/object.cjs +2 -2
  8. package/cjs/{required-methods-DYsRO9aZ.cjs → required-methods-chT0TXQS.cjs} +107 -31
  9. package/cjs/required-methods-chT0TXQS.cjs.map +1 -0
  10. package/cjs/{string-DsqnKoSB.cjs → string-edcPIpcU.cjs} +4 -4
  11. package/cjs/string-edcPIpcU.cjs.map +1 -0
  12. package/cjs/string.cjs +2 -2
  13. package/esm/config.d.mts.map +1 -1
  14. package/esm/config.mjs.map +1 -1
  15. package/esm/factory/validators.d.mts +8 -8
  16. package/esm/factory/validators.d.mts.map +1 -1
  17. package/esm/factory/validators.mjs +7 -1
  18. package/esm/factory/validators.mjs.map +1 -1
  19. package/esm/mutators/string-mutators.mjs +2 -2
  20. package/esm/mutators/string-mutators.mjs.map +1 -1
  21. package/esm/object.d.mts +1 -3
  22. package/esm/object.d.mts.map +1 -1
  23. package/esm/object.mjs.map +1 -1
  24. package/esm/rules/common/literal.mjs.map +1 -1
  25. package/esm/rules/core/union.d.mts +8 -1
  26. package/esm/rules/core/union.d.mts.map +1 -1
  27. package/esm/rules/core/union.mjs +56 -10
  28. package/esm/rules/core/union.mjs.map +1 -1
  29. package/esm/rules/core/when.mjs.map +1 -1
  30. package/esm/standard-schema/json-schema.d.mts.map +1 -1
  31. package/esm/standard-schema/json-schema.mjs.map +1 -1
  32. package/esm/standard-schema/map-result.d.mts.map +1 -1
  33. package/esm/standard-schema/map-result.mjs.map +1 -1
  34. package/esm/standard-schema/types.d.mts.map +1 -1
  35. package/esm/types/data-transformer-types.d.mts.map +1 -1
  36. package/esm/types/inference-types.d.mts +1 -1
  37. package/esm/types/inference-types.d.mts.map +1 -1
  38. package/esm/types/rule-types.d.mts +10 -1
  39. package/esm/types/rule-types.d.mts.map +1 -1
  40. package/esm/validators/array-validator.d.mts.map +1 -1
  41. package/esm/validators/array-validator.mjs +3 -3
  42. package/esm/validators/array-validator.mjs.map +1 -1
  43. package/esm/validators/base-validator.d.mts +8 -8
  44. package/esm/validators/base-validator.d.mts.map +1 -1
  45. package/esm/validators/base-validator.mjs +50 -20
  46. package/esm/validators/base-validator.mjs.map +1 -1
  47. package/esm/validators/computed-validator.d.mts.map +1 -1
  48. package/esm/validators/computed-validator.mjs +4 -4
  49. package/esm/validators/computed-validator.mjs.map +1 -1
  50. package/esm/validators/discriminated-union-validator.mjs +1 -1
  51. package/esm/validators/discriminated-union-validator.mjs.map +1 -1
  52. package/esm/validators/lazy-validator.mjs +1 -1
  53. package/esm/validators/lazy-validator.mjs.map +1 -1
  54. package/esm/validators/object-validator.d.mts.map +1 -1
  55. package/esm/validators/object-validator.mjs +9 -7
  56. package/esm/validators/object-validator.mjs.map +1 -1
  57. package/esm/validators/record-validator.d.mts.map +1 -1
  58. package/esm/validators/record-validator.mjs +3 -3
  59. package/esm/validators/record-validator.mjs.map +1 -1
  60. package/esm/validators/scalar-validator.mjs.map +1 -1
  61. package/esm/validators/tuple-validator.d.mts.map +1 -1
  62. package/esm/validators/tuple-validator.mjs +3 -3
  63. package/esm/validators/tuple-validator.mjs.map +1 -1
  64. package/esm/validators/union-validator.d.mts +3 -1
  65. package/esm/validators/union-validator.d.mts.map +1 -1
  66. package/esm/validators/union-validator.mjs +3 -1
  67. package/esm/validators/union-validator.mjs.map +1 -1
  68. package/llms-full.txt +656 -527
  69. package/llms.txt +1 -1
  70. package/package.json +1 -1
  71. package/skills/bridge-standard-schema/SKILL.md +60 -36
  72. package/skills/compose-seal-modifiers/SKILL.md +89 -53
  73. package/skills/define-structural-shape/SKILL.md +41 -27
  74. package/skills/define-structural-shape/array-methods.md +27 -27
  75. package/skills/define-structural-shape/object-methods.md +38 -30
  76. package/skills/extend-seal-with-plugins/SKILL.md +39 -5
  77. package/skills/generate-json-schema/SKILL.md +13 -13
  78. package/skills/handle-seal-errors/SKILL.md +23 -23
  79. package/skills/index.md +4 -1
  80. package/skills/overview/SKILL.md +10 -2
  81. package/skills/pick-seal-primitive/SKILL.md +45 -45
  82. package/skills/pick-seal-primitive/date-methods.md +96 -96
  83. package/skills/pick-seal-primitive/number-methods.md +55 -55
  84. package/skills/pick-seal-primitive/string-methods.md +107 -102
  85. package/skills/seal-basics/SKILL.md +14 -14
  86. package/cjs/object--LXRt5RF.cjs.map +0 -1
  87. package/cjs/required-methods-DYsRO9aZ.cjs.map +0 -1
  88. package/cjs/string-DsqnKoSB.cjs.map +0 -1
package/llms-full.txt CHANGED
@@ -8,35 +8,36 @@
8
8
 
9
9
  ---
10
10
  name: bridge-standard-schema
11
- description: 'Diagnose `StandardSchemaV1` interop — phantom-intersection at the `v` factory return, why typed slots reject a schema, cascade `Model<TSchema>` variance. Triggers: `StandardSchemaV1`, `~standard`, `Infer`, `ObjectValidator`, `StringValidator`, `LiteralValidator`, `BaseValidator`, `Model<TSchema>`, `StandardJSONSchemaV1`, `Result<unknown>`; "StandardSchemaV1 slot rejecting my schema", "Result<unknown> error", "drop as unknown as cast", "cascade Model schema variance"; typical import `import { v, type Infer } from "@warlock.js/seal"`. Skip: foundations — the `seal-basics` topic; JSON Schema gen — the `generate-json-schema` topic; competing spec `@standard-schema/spec`.'
11
+ description: 'Diagnose `StandardSchemaV1` interop — input/output class parameters and primitive factory intersections, why typed slots reject a schema, cascade `Model<TSchema>` variance. Triggers: `StandardSchemaV1`, `~standard`, `Infer`, `ObjectValidator`, `StringValidator`, `LiteralValidator`, `BaseValidator`, `Model<TSchema>`, `StandardJSONSchemaV1`, `Result<unknown>`; "StandardSchemaV1 slot rejecting my schema", "Result<unknown> error", "drop as unknown as cast", "cascade Model schema variance"; typical import `import { v, type Infer } from "@warlock.js/seal"`. Skip: foundations — the `seal-basics` topic; JSON Schema gen — the `generate-json-schema` topic; competing spec `@standard-schema/spec`.'
12
12
  ---
13
13
 
14
14
  # The Standard Schema bridge
15
15
 
16
16
  Seal validators implement [Standard Schema V1](https://standardschema.dev) — every validator exposes a `~standard` member with `validate()`, JSON Schema metadata, and inferred types. Any consumer that accepts `StandardSchemaV1<T>` (`@warlock.js/ai` supervisor `output`, ai tool `input`, LangGraph state, OpenAI structured outputs, TanStack Form, Conform) accepts a seal schema directly.
17
17
 
18
- This skill is about the **typing** of that interop — the phantom intersection that makes `v.object({...})` satisfy `StandardSchemaV1<T>` without `as unknown as` casts.
18
+ This skill covers the input and output types exposed through `~standard`, and
19
+ compatibility with consumers such as Cascade model schemas and AI tool schemas.
19
20
 
20
21
  ## The shape of the bridge
21
22
 
22
- The `v` factory returns aren't bare validator classes. Each factory widens its return with a phantom intersection:
23
+ `ObjectValidator<TSchema>` already threads `Infer.Input` and `Infer.Output` shapes
24
+ into `BaseValidator<TInput, TOutput>`. Both `v.object(fields)` and the standalone
25
+ `object(fields)` factory return that class directly. No extra Standard Schema
26
+ intersection is needed for objects: avoiding one keeps large composed schemas
27
+ cheaper for TypeScript to instantiate.
23
28
 
24
- ```ts
25
- // Inside seal's factory:
26
- object: <T extends Schema>(schema: T) =>
27
- new ObjectValidator<T>(schema) as ObjectValidator<T>
28
- & StandardSchemaV1<Infer<ObjectValidator<T>>>;
29
-
30
- string: () =>
31
- new StringValidator() as StringValidator & StandardSchemaV1<string>;
29
+ Other factories add a Standard Schema intersection when their class has no precise
30
+ generic output declaration, for example:
32
31
 
33
- literal: <T extends readonly [...] >(...values: T) =>
34
- new LiteralValidator<T>(values) as LiteralValidator<T> & StandardSchemaV1<T[number]>;
35
-
36
- // ...one intersection per factory.
32
+ ```ts
33
+ // Inside Seal's factory:
34
+ string: () => new StringValidator() as StringValidator & StandardSchemaV1<string>;
37
35
  ```
38
36
 
39
- The intersection is **only on the factory return type** — the `BaseValidator` and `ObjectValidator` *class* shapes are unchanged. That distinction is load-bearing: putting `Infer<this>` directly on `BaseValidator['~standard']` breaks `@warlock.js/cascade`'s `Model<TSchema>` because `ObjectValidator` becomes invariant when its members vary with `TSchema`. The factory-side intersection avoids that — bare classes in `Model.schema: ObjectValidator<TSchema>` slots are still structurally compatible with the typed factory return.
37
+ Keep input and output distinct when writing consumer types. Defaults and catches
38
+ can make input fields optional while output fields are required. Avoid computing
39
+ `Infer<this>` in the base class: object shapes are already supplied as generic
40
+ parameters, and recursively walking the full class increases compiler work.
40
41
 
41
42
  ## What this means for app code
42
43
 
@@ -58,7 +59,7 @@ ai.tool({
58
59
  name: "create_user",
59
60
  description: "Create a new user",
60
61
  input: userSchema,
61
- execute: async input => createUser(input),
62
+ execute: async (input) => createUser(input),
62
63
  });
63
64
 
64
65
  // ✅ supervisor output — no cast
@@ -82,7 +83,7 @@ const schema = v.object({
82
83
  blocks: v.array(v.literal("text", "image")).optional(),
83
84
  });
84
85
 
85
- type Output = Infer<typeof schema>;
86
+ type Output = Infer.Output<typeof schema>;
86
87
  // { email: string; blocks?: ("text" | "image")[] }
87
88
  ```
88
89
 
@@ -92,17 +93,12 @@ Hand-rolled parallel types drift the moment a field changes — and seal's tight
92
93
 
93
94
  Three failure modes you'll see in real projects:
94
95
 
95
- ### 1. You annotated the schema with the bare class type
96
-
97
- ```ts
98
- // ❌ Discards the phantom intersection
99
- const schema: ObjectValidator<{ email: StringValidator }> = v.object({...});
100
-
101
- // ✅ Let inference run
102
- const schema = v.object({...});
103
- ```
96
+ ### 1. An annotation discarded schema details
104
97
 
105
- The annotation strips `& StandardSchemaV1<...>` from the type. The schema stops fitting `StandardSchemaV1<T>` slots. Fix: remove the annotation. If you need the value type, use `Infer<typeof schema>`.
98
+ Let the factory infer its concrete schema. An annotation such as bare
99
+ `ObjectValidator` loses the field types; primitive factories also lose their
100
+ Standard Schema intersection when annotated with a bare primitive class.
101
+ `ObjectValidator<typeof fields>` preserves an object's input and output types.
106
102
 
107
103
  ### 2. The schema's inferred shape doesn't match the target type
108
104
 
@@ -117,23 +113,51 @@ The supervisor's explicit `<MyOutput>` generic is a constraint the schema must s
117
113
  - **Drop the explicit generic** — let the supervisor infer output type from the schema. The hand-rolled type was probably documentation-only anyway.
118
114
  - **Align them** — if the hand-rolled type is the source of truth (e.g. a domain type from elsewhere), make the schema match.
119
115
 
120
- ### 3. The error says `Result<unknown>` even though `Infer<>` resolves correctly
116
+ ### 3. The error says `Result<unknown>`
121
117
 
122
- This is a TypeScript reporting quirk, not a bridge bug. When a `StandardSchemaV1<T>` slot rejects a schema, TS picks the simplest mismatch chain to report — often it falls through `BaseValidator['~standard'].validate`'s wider `Result<unknown>` declaration before reaching the factory-side narrower one. The intersection *is* there structurally, but the error message mentions `unknown`.
118
+ Check whether an annotation erased a factory's output type, and compare
119
+ `Infer.Input<typeof schema>` and `Infer.Output<typeof schema>` with the consumer's
120
+ expected types. Output slots should use the post-default output shape. Objects
121
+ carry that shape directly through their class generics; primitive factories add
122
+ it through their Standard Schema intersection.
123
123
 
124
- If you see this, ignore the `unknown` and ask: does `Infer<typeof schema>` match the slot's expected `T`? Probe with:
124
+ ## Union and record outputs
125
+
126
+ Use `Infer.Output<T>` or `StandardSchemaV1.InferOutput<T>` for downstream data.
127
+ Unions and records preserve their children's output types independently of input
128
+ shapes. Defaults and catches make object fields required in output. Their factory
129
+ intersections put the precise Standard Schema signature first, so direct
130
+ `~standard.validate()` calls return the declared output. `Infer.Output` reads the
131
+ output metadata rather than inferring from overloaded validation signatures. A union
132
+ returns the selected branch's validated data before its own transformers and
133
+ refinements; it does not discard branch defaults, computed values or transformations.
125
134
 
126
135
  ```ts
127
- type _Probe = Infer<typeof schema>;
128
- const _force: { __nope: 1 } = null as unknown as _Probe;
129
- // Hover the error to see the resolved shape, then compare to the slot's T.
136
+ const entry = v.object({ count: v.int().optional().default(1) });
137
+ const entries = v.record(entry);
138
+ type Entries = Infer.Output<typeof entries>;
139
+ // Record<string, { count: number }>
140
+ const maybeEntry = v.union([entry, v.string()]);
141
+ type MaybeEntry = Infer.Output<typeof maybeEntry>;
142
+ // { count: number } | string
130
143
  ```
131
144
 
132
- That's the real mismatch.
145
+ When upgrading, union output can change through member trimming, Date parsing,
146
+ defaults, computed fields and unknown-key stripping. An explicit `undefined`
147
+ member output drops its containing object field. Keep the raw input before
148
+ validation if storage, comparisons or serialization also need that value.
149
+
150
+ ## Compiler budget
151
+
152
+ From `packages/seal`, run `pnpm bench:types --check` after changes to inference or
153
+ factory types. CI runs the same deterministic composed-schema fixture when Seal
154
+ is selected. Instantiations and type counts may grow at most 5% above the checked-in
155
+ baseline. Refresh with `node scripts/bench-types.mjs --update` only after reviewing
156
+ the measurements; time and memory are diagnostic rather than gated.
133
157
 
134
158
  ## Why not `Omit<class, "~standard"> & StandardSchemaV1<T>`?
135
159
 
136
- Tempting — it would force the factory's `~standard` to fully replace the class's wider one, fixing the misleading `Result<unknown>` error message. **Don't do it.** `Omit` on a class instance type triggers the same variance trap as putting `Infer<this>` on the class itself: cascade explodes with hundreds of `ObjectValidator<{specific}>` vs `ObjectValidator<TSchema>` errors. The phantom intersection is the only shape that satisfies both ends — narrower for typed slots, structurally identical to the class for cascade's invariant generic positions.
160
+ Tempting — it would force the factory's `~standard` to fully replace the class's wider one, fixing the misleading `Result<unknown>` error message. **Don't do it.** `Omit` on a class instance type triggers the same variance trap as putting `Infer<this>` on the class itself: cascade explodes with hundreds of `ObjectValidator<{specific}>` vs `ObjectValidator<TSchema>` errors. Keep object factories as their existing generic class, and retain factory-side intersections for primitive classes that need them. Both preserve the validator's class members for generic consumers.
137
161
 
138
162
  ## Why not import `@standard-schema/spec` instead of forking the types?
139
163
 
@@ -167,31 +191,61 @@ When `validate()` runs against a value, the order is:
167
191
  3. optional/required check — decide whether to run rules
168
192
  4. requiredRule — required-condition rule (.requiredIf, .requiredWith, etc.)
169
193
  5. rules — every other rule, in declaration order
170
- 6. transformers — reshape value into data (.toISOString, .toLowerCase)
194
+ 6. children — validate container members, fill defaults and computed fields
195
+ 7. transformers — reshape value into data (.toISOString, .toLowerCase)
196
+ 8. refinements — typed custom checks against successful output
171
197
  ```
172
198
 
173
199
  If a rule fails, transformers don't run for that field.
174
200
 
175
201
  **Mutator vs transformer mental model:**
202
+
176
203
  - **Mutator** = pre-validation reshape. `v.date()` ships one that parses strings. Use when you want rules to see the reshaped value.
177
204
  - **Transformer** = post-validation reshape. Lands in `data`. Use when you only care about the output form.
178
205
 
179
206
  ```ts
180
207
  // trim BEFORE length check — use a mutator
181
- v.string().addMutator(s => s.trim()).min(3)
208
+ v.string()
209
+ .addMutator((s) => s.trim())
210
+ .min(3);
182
211
  // " Hi " → mutator trims → "Hi" → fails min(3)
183
212
 
184
213
  // trim only the OUTPUT — use a transformer (.trim() on string is a transformer)
185
- v.string().min(3).trim()
214
+ v.string().min(3).trim();
186
215
  // " Hi " → rules see " Hi " (length 6, passes min(3)) → trim → data = "Hi"
187
216
  ```
188
217
 
218
+ ## `.refine()`
219
+
220
+ Refinements receive the validator's output type, after children, defaults, computed
221
+ fields and output transformers. They skip failed validation and absent/null values,
222
+ including when `firstErrorOnly: false`. Return an error string to reject the value;
223
+ return `undefined` to accept it. Async callbacks are supported. Use `.addRule()`
224
+ when a check must run against input before children validate.
225
+
226
+ ```ts
227
+ v.object({ count: v.int().optional().default(1) }).refine((value) => {
228
+ // count is a number, and defaults have already been applied.
229
+ return value.count > 10 ? "Too many items" : undefined;
230
+ });
231
+ ```
232
+
233
+ Successful empty defaults such as `.default("")` are refined too. `.merge()`
234
+ appends the other object's refinements; `.extend()` imports only fields. Cloning
235
+ and field-selection methods preserve the receiver's refinements, so callbacks
236
+ must tolerate fields removed by `.pick()` or `.without()`.
237
+
238
+ Output transformers do not yet change declared output types: a
239
+ `v.date().toISOString()` refinement receives a string at runtime despite the
240
+ `Date` declaration. Use a Date input rule, or convert after validation; moving
241
+ `.refine()` earlier in the chain does not move it before transformers.
242
+
189
243
  ## `.required()` / `.optional()` / `.present()`
190
244
 
191
245
  ```ts
192
- v.string() // already required inside v.object
193
- v.string().optional() // type: string | undefined — { isOptional: true } brand
194
- v.string().present() // must exist, may be "" / null
246
+ v.string(); // already required inside v.object
247
+ v.string().optional(); // type: string | undefined — { isOptional: true } brand
248
+ v.string().present(); // must exist, may be "" / null
195
249
  ```
196
250
 
197
251
  **Required is the default inside `v.object`.** Most schemas read cleaner without `.required()` — `Infer<>` already shows what's required (no `?`) vs optional (`?`). Calling `.required()` explicitly is harmless and accepted, but the canonical seal style is to skip it. Keep `.optional()` explicit (it changes behavior); skip `.required()` (it doesn't).
@@ -199,9 +253,11 @@ v.string().present() // must exist, may be "" / null
199
253
  The `{ isOptional: true }` brand survives chaining (`.optional().min(3)` is still optional) and `Infer<>` reads it to make the key optional.
200
254
 
201
255
  **When `.required()` is still useful:**
202
- - It's not needed for conditional rules — `.requiredIf(field, value)` and friends *replace* the default required-condition slot, so they work standalone.
256
+
257
+ - It's not needed for conditional rules — `.requiredIf(field, value)` and friends _replace_ the default required-condition slot, so they work standalone.
203
258
 
204
259
  Conditional variants (run inside `v.object` only):
260
+
205
261
  - `.requiredIf(field, value)` / `.requiredIfSibling(field, value)`
206
262
  - `.requiredWith(field)` / `.requiredWithSibling(field)`
207
263
  - `.requiredWithout(field)` / `.requiredWithoutSibling(field)`
@@ -213,22 +269,22 @@ Conditional variants (run inside `v.object` only):
213
269
  ## `.nullable()` / `.notNullable()` / `.nullish()`
214
270
 
215
271
  ```ts
216
- v.string().nullable() // type: string | null
217
- v.string().nullish() // sugar for .optional().nullable()
272
+ v.string().nullable(); // type: string | null
273
+ v.string().nullish(); // sugar for .optional().nullable()
218
274
  ```
219
275
 
220
- Independent of optional — a field can be required *and* nullable. Defaults to non-nullable. The `{ isNullable: true }` brand widens both `Infer.Input` and `Infer.Output` with `| null`.
276
+ Independent of optional — a field can be required _and_ nullable. Defaults to non-nullable. The `{ isNullable: true }` brand widens both `Infer.Input` and `Infer.Output` with `| null`.
221
277
 
222
278
  ## `.default(value | callback)`
223
279
 
224
280
  ```ts
225
- v.string().default("guest")
226
- v.int().default(0)
227
- v.date().default(() => new Date()) // lazy — fresh each validation
228
- v.array(v.string()).default([])
281
+ v.string().default("guest");
282
+ v.int().default(0);
283
+ v.date().default(() => new Date()); // lazy — fresh each validation
284
+ v.array(v.string()).default([]);
229
285
  ```
230
286
 
231
- If input is `undefined` (or missing), the default is used and rules run against it. Pass a callback for fresh-per-run values. The default fires *before* rules — so `v.string().min(3).default("a")` fails because `"a"` is shorter than 3.
287
+ If input is `undefined` (or missing), the default is used and rules run against it. Pass a callback for fresh-per-run values. The default fires _before_ rules — so `v.string().min(3).default("a")` fails because `"a"` is shorter than 3.
232
288
 
233
289
  Date sugar: `v.date().defaultNow()` ≡ `.default(() => new Date())`.
234
290
 
@@ -236,7 +292,7 @@ The `{ hasDefault: true }` brand makes the key optional in `Infer.Input` (caller
236
292
 
237
293
  ## `.catch(fallback)`
238
294
 
239
- Rescues *failed* validation by substituting a fallback. Complement to `.default()`:
295
+ Rescues _failed_ validation by substituting a fallback. Complement to `.default()`:
240
296
 
241
297
  - `.default(x)` fires when input is **absent**
242
298
  - `.catch(y)` fires when input is **present but invalid**
@@ -271,7 +327,7 @@ const schema = v.object({
271
327
  retries: v.int().catch(3),
272
328
  });
273
329
 
274
- type In = Infer.Input<typeof schema>;
330
+ type In = Infer.Input<typeof schema>;
275
331
  // {
276
332
  // bio?: string;
277
333
  // status?: Status; ← default makes caller optional
@@ -285,10 +341,11 @@ type Out = Infer.Output<typeof schema>;
285
341
  // retries: number; ← catch guarantees a value
286
342
  // }
287
343
 
288
- type Default = Infer<typeof schema>; // alias for Infer.Input
344
+ type Default = Infer<typeof schema>; // alias for Infer.Input
289
345
  ```
290
346
 
291
347
  **When to reach for which:**
348
+
292
349
  - `Infer.Input<T>` (or bare `Infer<T>`) — for HTTP request bodies, form payloads, DTOs, anything pre-validation. **The common case in HTTP-shaped code.**
293
350
  - `Infer.Output<T>` — for Cascade `Model<>` params, validated state, anywhere downstream of `validate()`.
294
351
 
@@ -298,26 +355,26 @@ Both widen with `| null` when `.nullable()` is set.
298
355
 
299
356
  Three failure modes, three different rescue mechanisms:
300
357
 
301
- | Input state | Rescued by | What appears in `data` |
302
- | --- | --- | --- |
303
- | Field absent | `.default(x)` | `x` |
304
- | Field absent (no default) | `.optional()` | Key omitted entirely |
305
- | Field present and invalid | `.catch(y)` | `y` |
306
- | Field is `null` | `.nullable()` | `null` |
307
- | Field present, empty (`""`, `[]`, `{}`) | (none needed — empty is a valid value) | Preserved as-is |
358
+ | Input state | Rescued by | What appears in `data` |
359
+ | --------------------------------------- | -------------------------------------- | ---------------------- |
360
+ | Field absent | `.default(x)` | `x` |
361
+ | Field absent (no default) | `.optional()` | Key omitted entirely |
362
+ | Field present and invalid | `.catch(y)` | `y` |
363
+ | Field is `null` | `.nullable()` | `null` |
364
+ | Field present, empty (`""`, `[]`, `{}`) | (none needed — empty is a valid value) | Preserved as-is |
308
365
 
309
366
  Full truth table for an `.optional()` field inside `v.object(…)`:
310
367
 
311
- | Input | What appears in `data` |
312
- | --- | --- |
313
- | Field absent | Key **omitted entirely** (not `undefined`-valued) |
314
- | Field is explicit `undefined` | Key omitted (treated identically to absent) |
315
- | Field is `null` (no `.nullable()`) | Key omitted — for an **optional** field, `null` coalesces to empty and the (cleared) required rule doesn't fire. On a **required** field the same `null` triggers a validation error. |
316
- | Field is `null` with `.nullable()` | Key is `null` |
317
- | Field present and empty (`""`, `[]`, `{}`) | Preserved as-is — empty ≠ absent |
318
- | Field is `.default(x)` and absent | Key is `x` (default fires, then rules run on `x`) |
319
- | Field is `.default(x)` and present | Caller value wins; default is unused |
320
- | Field is `.catch(y)` and validation fails | Key is `y` (catch rescues) |
368
+ | Input | What appears in `data` |
369
+ | ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
370
+ | Field absent | Key **omitted entirely** (not `undefined`-valued) |
371
+ | Field is explicit `undefined` | Key omitted (treated identically to absent) |
372
+ | Field is `null` (no `.nullable()`) | Key omitted — for an **optional** field, `null` coalesces to empty and the (cleared) required rule doesn't fire. On a **required** field the same `null` triggers a validation error. |
373
+ | Field is `null` with `.nullable()` | Key is `null` |
374
+ | Field present and empty (`""`, `[]`, `{}`) | Preserved as-is — empty ≠ absent |
375
+ | Field is `.default(x)` and absent | Key is `x` (default fires, then rules run on `x`) |
376
+ | Field is `.default(x)` and present | Caller value wins; default is unused |
377
+ | Field is `.catch(y)` and validation fails | Key is `y` (catch rescues) |
321
378
 
322
379
  ```ts
323
380
  const schema = v.object({
@@ -325,11 +382,12 @@ const schema = v.object({
325
382
  embedding: v.array(v.number()).optional(),
326
383
  });
327
384
 
328
- (await validate(schema, {})).data
329
- // → {} — neither key appears
330
- // (NOT { metadata: {}, embedding: [] })
385
+ (await validate(schema, {}))
386
+ .data
387
+ // → {} — neither key appears
388
+ // (NOT { metadata: {}, embedding: [] })
331
389
 
332
- (await validate(schema, { metadata: {}, embedding: [] })).data
390
+ (await validate(schema, { metadata: {}, embedding: [] })).data;
333
391
  // → { metadata: {}, embedding: [] } — present-empty is preserved
334
392
  ```
335
393
 
@@ -342,12 +400,12 @@ Collection validators (`v.record`, `v.array`, `v.tuple`) explicitly honor this
342
400
  Available on `v.string()`, `v.number()`, `v.int()`, `v.float()`, `v.boolean()`, `v.scalar()`:
343
401
 
344
402
  ```ts
345
- v.string().in(["admin", "user", "guest"]) // value must match one
346
- v.string().oneOf(["a", "b"]) // alias for .in
347
- v.string().notIn(["banned", "blocked"]) // value must NOT match
348
- v.string().forbids(["banned"]) // alias for .notIn
349
- v.number().allowsOnly([1, 2, 3]) // stricter — explicit allowlist
350
- v.string().enum(MyTSEnum) // accepts a TS enum object via Object.values
403
+ v.string().in(["admin", "user", "guest"]); // value must match one
404
+ v.string().oneOf(["a", "b"]); // alias for .in
405
+ v.string().notIn(["banned", "blocked"]); // value must NOT match
406
+ v.string().forbids(["banned"]); // alias for .notIn
407
+ v.number().allowsOnly([1, 2, 3]); // stricter — explicit allowlist
408
+ v.string().enum(MyTSEnum); // accepts a TS enum object via Object.values
351
409
  ```
352
410
 
353
411
  For literal-typed narrowing (`"admin" | "user" | "guest"` instead of `string`), use `v.literal(...)` instead — `oneOf` keeps the broader primitive type.
@@ -359,10 +417,10 @@ v.object({
359
417
  email: v.string(),
360
418
  password: v.string(),
361
419
  passwordConfirm: v.string().sameAs("password").omit(),
362
- })
420
+ });
363
421
  ```
364
422
 
365
- `.omit()` keeps the field in *validation* but drops it from `data` and from `Infer<>`. Use for confirmation/checksum fields that exist only for cross-field rules. `.exclude()` is the same idea, used internally for managed/computed plumbing.
423
+ `.omit()` keeps the field in _validation_ but drops it from `data` and from `Infer<>`. Use for confirmation/checksum fields that exist only for cross-field rules. `.exclude()` is the same idea, used internally for managed/computed plumbing.
366
424
 
367
425
  ## `.label("Display Name")` — field display name
368
426
 
@@ -371,7 +429,7 @@ To control the `:input` placeholder in a field's own messages, call `.label()` o
371
429
  ```ts
372
430
  v.object({
373
431
  email_address: v.string().label("Email Address"),
374
- })
432
+ });
375
433
  // Error message: "The Email Address is required" (instead of "The email_address is required")
376
434
  ```
377
435
 
@@ -379,12 +437,14 @@ v.object({
379
437
 
380
438
  ### `.attributes({ ... })` is a different tool
381
439
 
382
- `.attributes()` does NOT relabel a field's own `:input`. It supplies named substitution values consumed by the translation layer and by rules that reference *other* fields (e.g. `matches`):
440
+ `.attributes()` does NOT relabel a field's own `:input`. It supplies named substitution values consumed by the translation layer and by rules that reference _other_ fields (e.g. `matches`):
383
441
 
384
442
  ```ts
385
- v.string().sameAs("confirmPassword").attributes({
386
- matches: { confirmPassword: "Confirm Password" },
387
- })
443
+ v.string()
444
+ .sameAs("confirmPassword")
445
+ .attributes({
446
+ matches: { confirmPassword: "Confirm Password" },
447
+ });
388
448
  ```
389
449
 
390
450
  For per-field display names use `.label()`; for translated messages wire the `translateRule` / `translateAttribute` hooks via `configureSeal()`.
@@ -412,7 +472,7 @@ Switch back with `.immutable`. Default is fine 99% of the time. Reach for `.muta
412
472
 
413
473
  ## Things NOT to do
414
474
 
415
- - Don't put a transformer where a mutator belongs — `.trim()` is a transformer; if you need trimming *before* `.min()`, use `.addMutator(s => s.trim())`.
475
+ - Don't put a transformer where a mutator belongs — `.trim()` is a transformer; if you need trimming _before_ `.min()`, use `.addMutator(s => s.trim())`.
416
476
  - Don't combine `.required()` and `.optional()` on the same chain — last wins, but the intent is unclear; pick one.
417
477
  - Don't chain `.default("a")` with `.min(3)` and expect `"a"` to pass — the default goes through rules.
418
478
  - Don't expect `.requiredIf()` to work on a standalone validator outside `v.object` — sibling resolution silently passes.
@@ -437,20 +497,20 @@ v.object({
437
497
  email: v.string().email(),
438
498
  age: v.int().min(13).optional(),
439
499
  role: v.literal("admin", "user", "guest"),
440
- })
500
+ });
441
501
  ```
442
502
 
443
503
  - **Required by default.** `.optional()` to opt out — the `{ isOptional: true }` brand makes the key optional in `Infer<>`.
444
504
  - **Unknown keys policy.** By default extra keys are silently dropped from `data`. Toggle with `.allowUnknown()` (forward as-is), `.stripUnknown()` (explicit drop), or `.allow(...keys)` (whitelist specific extras). See [`object-methods.md`](./object-methods.md).
445
505
  - **Schema composition.** `.extend(schema)`, `.merge(other)`, `.pick(...keys)`, `.without(...keys)`, `.partial(...keys)`, `.requiredFields(...keys)` — all in [`object-methods.md`](./object-methods.md).
446
- - **Cross-field rules** (`sameAs`, `requiredIf`, `requiredWith`) attach to fields *inside* a `v.object`. Without a parent, sibling resolution silently passes.
506
+ - **Cross-field rules** (`sameAs`, `requiredIf`, `requiredWith`) attach to fields _inside_ a `v.object`. Without a parent, sibling resolution silently passes.
447
507
 
448
508
  ## `v.array` — homogeneous lists
449
509
 
450
510
  ```ts
451
- v.array(v.string()) // type: string[]
452
- v.array(userSchema) // type: User[]
453
- v.array(v.array(v.int())) // type: number[][] — recursive inference
511
+ v.array(v.string()); // type: string[]
512
+ v.array(userSchema); // type: User[]
513
+ v.array(v.array(v.int())); // type: number[][] — recursive inference
454
514
  ```
455
515
 
456
516
  The inner validator runs against each element; failure on any element fails the array. Method surface (`.min`, `.max`, `.unique`, `.sorted`, `.flip`, …) in [`array-methods.md`](./array-methods.md).
@@ -458,9 +518,9 @@ The inner validator runs against each element; failure on any element fails the
458
518
  ## `v.record` — homogeneous values, dynamic keys
459
519
 
460
520
  ```ts
461
- v.record(v.int()) // type: Record<string, number>
462
- v.record(v.object({ count: v.int() })) // type: Record<string, { count: number }>
463
- v.record() // type: Record<string, any>
521
+ v.record(v.int()); // type: Record<string, number>
522
+ v.record(v.object({ count: v.int() })); // type: Record<string, { count: number }>
523
+ v.record(); // type: Record<string, any>
464
524
  ```
465
525
 
466
526
  Reach for `v.record` when keys are dynamic (user-provided, dictionary-style) but values share a schema. If keys are also constrained (e.g. only `"draft" | "published"`), use `v.object` with literal keys instead — the constraint lives in the type.
@@ -468,26 +528,40 @@ Reach for `v.record` when keys are dynamic (user-provided, dictionary-style) but
468
528
  ## `v.tuple` — positional types
469
529
 
470
530
  ```ts
471
- v.tuple([v.string(), v.int(), v.boolean()]) // type: [string, number, boolean]
472
- v.tuple([v.literal("ok"), v.string()]) // type: ["ok", string]
531
+ v.tuple([v.string(), v.int(), v.boolean()]); // type: [string, number, boolean]
532
+ v.tuple([v.literal("ok"), v.string()]); // type: ["ok", string]
473
533
  ```
474
534
 
475
535
  Each position has its own validator; the array length must match the tuple length. Pair with `v.literal` at position 0 for result-tuple patterns (`["ok", data]` vs `["error", message]`).
476
536
 
537
+ Pass the validators as a literal list so each position keeps its own type, for input and validated output alike. A list typed `BaseValidator[]` (built at runtime) has no fixed positions, so its output is an array rather than a tuple.
538
+
477
539
  ## `v.union` — one of N validators (untagged)
478
540
 
479
541
  ```ts
480
- v.union([v.string(), v.int()]) // type: string | number
542
+ v.union([v.string(), v.int()]); // type: string | number
543
+ ```
544
+
545
+ Every branch whose type matches the value (`matchesType()`) is tried, in order, and the first that passes wins; the union returns that branch's validated output. `firstErrorOnly` limits the errors reported, never the branches tried, so a union of several object shapes accepts a value matching any of them:
546
+
547
+ ```ts
548
+ const owner = v.object({ kind: v.literal("owner"), id: v.literal("__owner__") });
549
+ const member = v.object({ kind: v.literal("member"), id: v.string() });
550
+
551
+ await validate(v.union([owner, member]), { kind: "member", id: "x" });
552
+ // → valid, data: { kind: "member", id: "x" }
481
553
  ```
482
554
 
483
- The first type-matching validator wins, picked via each branch's `matchesType()`. Use for unions of **scalar** types (string vs number, etc.) where `matchesType` is enough to disambiguate. For object-vs-object unions, reach for `v.discriminatedUnion` instead — `matchesType` can't distinguish two object branches and you'll get errors from the wrong branch.
555
+ When no branch passes, the union reports the **closest** failed branch, picked by: fewest mismatched `v.literal(...)` fields, then fewest errors, then the deepest error path, then declaration order. Its errors keep their own paths (`actor.id`, not `actor`) and have the type `"union"` — the first one by default, all of them with `firstErrorOnly: false`. A custom `errorMessage` (`v.union([...], "Unknown actor")`), or a value no branch can hold by type, gives a single error at the union's path.
556
+
557
+ For object shapes that share a tag field, `v.discriminatedUnion` is still the better fit: it routes straight to one branch instead of trying each, and narrows exactly in TypeScript.
484
558
 
485
559
  ## `v.discriminatedUnion` — tagged unions (recommended for objects)
486
560
 
487
561
  ```ts
488
562
  const email = v.object({ type: v.literal("email"), email: v.string().email() });
489
- const sms = v.object({ type: v.literal("sms"), phone: v.string() });
490
- const push = v.object({ type: v.literal("push"), deviceId: v.string() });
563
+ const sms = v.object({ type: v.literal("sms"), phone: v.string() });
564
+ const push = v.object({ type: v.literal("push"), deviceId: v.string() });
491
565
 
492
566
  const notif = v.discriminatedUnion("type", [email, sms, push]);
493
567
 
@@ -499,8 +573,8 @@ type Notif = Infer<typeof notif>;
499
573
 
500
574
  Routes payloads by reading the discriminator field (here `type`), looking it up in a key→branch map built at construction time, and delegating to the matching branch only. Benefits over plain `v.union`:
501
575
 
502
- - **Precise errors.** Failures come from the matched branch, not from every branch.
503
- - **O(1) routing** instead of trial-and-error.
576
+ - **Precise errors.** Failures come from the branch the tag names, not from the closest guess.
577
+ - **O(1) routing** instead of trying each branch.
504
578
  - **Exact TS inference** — discriminated union with narrowing inside `if (x.type === "email")` blocks.
505
579
  - **Cleaner JSON Schema** — `oneOf` with literal discriminators; OpenAI-strict accepts it.
506
580
 
@@ -538,20 +612,20 @@ Use for: trees (categories, file systems), threaded data (comment chains), mutua
538
612
 
539
613
  ## Quick map
540
614
 
541
- | Want | Reach for |
542
- | --- | --- |
543
- | Fixed-shape record | `v.object({...})` |
544
- | Dynamic keys, same value shape | `v.record(valueSchema)` |
545
- | List of items | `v.array(itemSchema)` |
546
- | Position-typed array | `v.tuple([a, b, c])` |
547
- | One of N scalar types | `v.union([...])` |
548
- | One of N object shapes with a tag field | `v.discriminatedUnion(key, [...])` |
549
- | Self-referencing or forward reference | `v.lazy(() => schema)` |
550
- | One of N constants | `v.literal(...values)` (not structural — see the `pick-seal-primitive` topic) |
615
+ | Want | Reach for |
616
+ | --------------------------------------- | ----------------------------------------------------------------------------- |
617
+ | Fixed-shape record | `v.object({...})` |
618
+ | Dynamic keys, same value shape | `v.record(valueSchema)` |
619
+ | List of items | `v.array(itemSchema)` |
620
+ | Position-typed array | `v.tuple([a, b, c])` |
621
+ | One of N scalar types | `v.union([...])` |
622
+ | One of N object shapes with a tag field | `v.discriminatedUnion(key, [...])` |
623
+ | Self-referencing or forward reference | `v.lazy(() => schema)` |
624
+ | One of N constants | `v.literal(...values)` (not structural — see the `pick-seal-primitive` topic) |
551
625
 
552
626
  ## A note on cascade Models
553
627
 
554
- `@warlock.js/cascade` Models declare `static schema: ObjectValidator<TSchema>`. Passing a `v.object({...})` works directly — the factory return widens to fit the Model's invariant generic without breaking type checking. If TS complains *"`ObjectValidator<{specific}>` is not assignable to `ObjectValidator<TSchema>`"*, that's the variance trap and the answer isn't to widen the schema — it's almost always that `Model<TSchema>` was parameterized with a hand-rolled type that drifted from the schema's inferred shape. Fix the type, not the schema. See the `bridge-standard-schema` topic.
628
+ `@warlock.js/cascade` Models declare `static schema: ObjectValidator<TSchema>`. Passing a `v.object({...})` works directly — the factory return widens to fit the Model's invariant generic without breaking type checking. If TS complains _"`ObjectValidator<{specific}>` is not assignable to `ObjectValidator<TSchema>`"_, that's the variance trap and the answer isn't to widen the schema — it's almost always that `Model<TSchema>` was parameterized with a hand-rolled type that drifted from the schema's inferred shape. Fix the type, not the schema. See the `bridge-standard-schema` topic.
555
629
 
556
630
  ## Method-surface reference
557
631
 
@@ -569,41 +643,41 @@ The inner validator runs against each element. Failure on any element fails the
569
643
 
570
644
  ## Length
571
645
 
572
- | Method | Args | JSON Schema | Example |
573
- |---|---|---|---|
574
- | `.min(n, msg?)` | n/a — see note below | — | use `.minLength(n)` |
575
- | `.minLength(n, msg?)` | inclusive lower bound | `minItems: n` | `v.array(v.string()).minLength(1)` |
576
- | `.maxLength(n, msg?)` | inclusive upper bound | `maxItems: n` | `v.array(v.string()).maxLength(10)` |
577
- | `.length(n, msg?)` | exact length | `minItems=maxItems=n` | `v.array(v.string()).length(3)` |
578
- | `.between(a, b, msg?)` | inclusive range | `minItems: a, maxItems: b` | `v.array(v.string()).between(1, 10)` |
579
- | `.lengthBetween(a, b, msg?)` | alias for `.between()` | `minItems: a, maxItems: b` | — |
646
+ | Method | Args | JSON Schema | Example |
647
+ | ---------------------------- | ---------------------- | -------------------------- | ------------------------------------ |
648
+ | `.min(n, msg?)` | n/a — see note below | — | use `.minLength(n)` |
649
+ | `.minLength(n, msg?)` | inclusive lower bound | `minItems: n` | `v.array(v.string()).minLength(1)` |
650
+ | `.maxLength(n, msg?)` | inclusive upper bound | `maxItems: n` | `v.array(v.string()).maxLength(10)` |
651
+ | `.length(n, msg?)` | exact length | `minItems=maxItems=n` | `v.array(v.string()).length(3)` |
652
+ | `.between(a, b, msg?)` | inclusive range | `minItems: a, maxItems: b` | `v.array(v.string()).between(1, 10)` |
653
+ | `.lengthBetween(a, b, msg?)` | alias for `.between()` | `minItems: a, maxItems: b` | — |
580
654
 
581
655
  **Note on `.min`/`.max`:** the array validator does not expose `.min`/`.max` directly — use `.minLength`/`.maxLength` (or `.between`/`.length`). This is intentional: `.min`/`.max` would conflict with primitive value-comparison semantics if they ever bled into here.
582
656
 
583
657
  ## Uniqueness & sort
584
658
 
585
- | Method | Args | Effect |
586
- |---|---|---|
587
- | `.unique(msg?)` | — | every element is distinct |
659
+ | Method | Args | Effect |
660
+ | --------------------------- | ----------------------------- | ----------------------------- |
661
+ | `.unique(msg?)` | — | every element is distinct |
588
662
  | `.sorted(direction?, msg?)` | `"asc"` (default) or `"desc"` | array is monotonically sorted |
589
663
 
590
664
  ## Mutators (pre-validation reshape)
591
665
 
592
- | Method | Args | Effect |
593
- |---|---|---|
594
- | `.flip()` | — | reverse the array |
595
- | `.reverse()` | — | alias for `.flip()` |
596
- | `.onlyUnique()` | — | dedupe before validation |
597
- | `.sort(direction?, key?)` | `"asc"` (default) or `"desc"`; optional sort key for object items | sort before validation |
666
+ | Method | Args | Effect |
667
+ | ------------------------- | ----------------------------------------------------------------- | ------------------------ |
668
+ | `.flip()` | — | reverse the array |
669
+ | `.reverse()` | — | alias for `.flip()` |
670
+ | `.onlyUnique()` | — | dedupe before validation |
671
+ | `.sort(direction?, key?)` | `"asc"` (default) or `"desc"`; optional sort key for object items | sort before validation |
598
672
 
599
673
  `onlyUnique()` and `unique()` differ: the mutator silently dedupes; the rule fails when duplicates are present. Pick by intent — coerce vs reject.
600
674
 
601
675
  ## Inner validator notes
602
676
 
603
677
  ```ts
604
- v.array(v.string().email()) // every element must be a valid email
605
- v.array(userSchema) // every element must satisfy userSchema
606
- v.array(v.array(v.int())) // matrix-ish — Infer is recursive
678
+ v.array(v.string().email()); // every element must be a valid email
679
+ v.array(userSchema); // every element must satisfy userSchema
680
+ v.array(v.array(v.int())); // matrix-ish — Infer is recursive
607
681
  ```
608
682
 
609
683
  Element validators run with element-level context. `path` becomes `${parent}.${index}` for error messages. Cross-field rules inside element schemas resolve to that element's fields, not the array.
@@ -621,25 +695,25 @@ Element validators run with element-level context. `path` becomes `${parent}.${i
621
695
 
622
696
  ```ts
623
697
  // Tags
624
- v.array(v.string().min(1)).unique().between(1, 10)
698
+ v.array(v.string().min(1)).unique().between(1, 10);
625
699
 
626
700
  // User list
627
- v.array(userSchema).minLength(1)
701
+ v.array(userSchema).minLength(1);
628
702
 
629
703
  // Pre-sorted, deduped
630
- v.array(v.string()).onlyUnique().sort("asc")
704
+ v.array(v.string()).onlyUnique().sort("asc");
631
705
 
632
706
  // Reverse-emit
633
- v.array(v.string()).flip()
707
+ v.array(v.string()).flip();
634
708
 
635
709
  // Matrix
636
- v.array(v.array(v.number())).minLength(1)
710
+ v.array(v.array(v.number())).minLength(1);
637
711
 
638
712
  // Sorted ids
639
- v.array(v.string().uuid()).sorted("asc").unique()
713
+ v.array(v.string().uuid()).sorted("asc").unique();
640
714
 
641
715
  // Optional list with default
642
- v.array(v.string()).default([]).optional()
716
+ v.array(v.string()).default([]).optional();
643
717
  ```
644
718
 
645
719
 
@@ -656,10 +730,12 @@ These return a new `ObjectValidator` with a transformed schema. The TypeScript i
656
730
  ### `.extend(schemaOrValidator)` — add fields, keep config
657
731
 
658
732
  ```ts
659
- const baseUser = v.object({
660
- name: v.string(),
661
- email: v.string().email(),
662
- }).allowUnknown();
733
+ const baseUser = v
734
+ .object({
735
+ name: v.string(),
736
+ email: v.string().email(),
737
+ })
738
+ .allowUnknown();
663
739
 
664
740
  const adminUser = baseUser.extend({
665
741
  role: v.string().oneOf(["admin", "superadmin"]),
@@ -673,10 +749,12 @@ Accepts a plain schema object **or** another `ObjectValidator` (only its schema
673
749
 
674
750
  ```ts
675
751
  const base = v.object({ name: v.string() }).allowUnknown();
676
- const audit = v.object({
677
- createdAt: v.date(),
678
- updatedAt: v.date(),
679
- }).stripUnknown();
752
+ const audit = v
753
+ .object({
754
+ createdAt: v.date(),
755
+ updatedAt: v.date(),
756
+ })
757
+ .stripUnknown();
680
758
 
681
759
  const merged = base.merge(audit);
682
760
  // type: { name; createdAt; updatedAt }
@@ -713,8 +791,8 @@ Returns `ObjectValidator<Omit<TSchema, K>>`. Inverse of `.pick`. Keeps original
713
791
  ### `.partial(...keys?)` — mark fields optional
714
792
 
715
793
  ```ts
716
- fullUser.partial() // every field becomes optional
717
- fullUser.partial("password") // only `password` becomes optional
794
+ fullUser.partial(); // every field becomes optional
795
+ fullUser.partial("password"); // only `password` becomes optional
718
796
  ```
719
797
 
720
798
  Walks the schema and applies `.optional()` to each named field (or all if no keys given). `Infer<>` makes those keys optional.
@@ -722,7 +800,7 @@ Walks the schema and applies `.optional()` to each named field (or all if no key
722
800
  ### `.requiredFields(...keys?)` — mark fields required
723
801
 
724
802
  ```ts
725
- const partialUser = fullUser.partial(); // all optional
803
+ const partialUser = fullUser.partial(); // all optional
726
804
  const updateUser = partialUser.requiredFields("id", "email");
727
805
  // id and email are required again, others stay optional
728
806
  ```
@@ -733,29 +811,31 @@ Inverse of `.partial`.
733
811
 
734
812
  By default, extra keys in input are silently dropped from `data` (no error, no forward).
735
813
 
736
- | Method | Effect |
737
- |---|---|
738
- | `.allowUnknown(allow = true)` | extra **direct-child** keys forward as-is |
739
- | `.stripUnknown()` | explicit drop (mutator-based — affects `data` shape) |
740
- | `.allow(...keys)` | whitelist specific extras to forward without validation |
814
+ | Method | Effect |
815
+ | ----------------------------- | ------------------------------------------------------- |
816
+ | `.allowUnknown(allow = true)` | extra **direct-child** keys forward as-is |
817
+ | `.stripUnknown()` | explicit drop (mutator-based — affects `data` shape) |
818
+ | `.allow(...keys)` | whitelist specific extras to forward without validation |
741
819
 
742
820
  `.allowUnknown()` only affects direct children — nested objects keep their own policies. For a fully permissive object including nested children, set `.allowUnknown()` on each level.
743
821
 
744
822
  ### Output DTOs — stripping extras instead of rejecting them
745
823
 
746
- The default is to **reject** unknown keys, which is what you want for inbound payloads. For an *outbound* DTO — where the record legitimately carries internal fields you simply don't want to expose — use `.stripUnknown()`, which drops them and returns the clean object:
824
+ The default is to **reject** unknown keys, which is what you want for inbound payloads. For an _outbound_ DTO — where the record legitimately carries internal fields you simply don't want to expose — use `.stripUnknown()`, which drops them and returns the clean object:
747
825
 
748
826
  ```ts
749
- const publicArticle = v.object({
750
- id: v.string(),
751
- title: v.string(),
752
- }).stripUnknown();
827
+ const publicArticle = v
828
+ .object({
829
+ id: v.string(),
830
+ title: v.string(),
831
+ })
832
+ .stripUnknown();
753
833
 
754
834
  const { isValid, data } = await v.validate(publicArticle, record);
755
835
  // record had version / authorId / status; data has only id + title
756
836
  ```
757
837
 
758
- Without `.stripUnknown()` the same record fails with *"The schema contains unknown keys: …"* rather than being cleaned.
838
+ Without `.stripUnknown()` the same record fails with _"The schema contains unknown keys: …"_ rather than being cleaned.
759
839
 
760
840
  :::note[`data` is `undefined` on failure — since 4.9.2]
761
841
  A failed validation returns `data: undefined`, never the input it rejected. Before 4.9.2 an `object` returned the raw input on failure (while `discriminatedUnion` returned `undefined`), so a caller who read `data` without branching on `isValid` forwarded the exact fields the schema existed to exclude. Always branch on `isValid`.
@@ -763,8 +843,8 @@ A failed validation returns `data: undefined`, never the input it rejected. Befo
763
843
 
764
844
  ## Object-level mutators
765
845
 
766
- | Method | Args | Effect |
767
- |---|---|---|
846
+ | Method | Args | Effect |
847
+ | ------------------- | -------------- | ------------------------------------------------ |
768
848
  | `.trim(recursive?)` | default `true` | recursively trim string values across the object |
769
849
 
770
850
  For per-field mutators, attach on the field validator instead.
@@ -777,7 +857,7 @@ The cross-field methods (`.sameAs`, `.requiredIf`, `.requiredWith`, etc.) are **
777
857
  v.object({
778
858
  password: v.string(),
779
859
  passwordConfirm: v.string().sameAs("password"),
780
- })
860
+ });
781
861
  ```
782
862
 
783
863
  If you call cross-field rules outside a `v.object`, sibling resolution silently passes — there's nobody to compare against.
@@ -785,7 +865,7 @@ If you call cross-field rules outside a `v.object`, sibling resolution silently
785
865
  ## `Infer<>` semantics
786
866
 
787
867
  ```ts
788
- Infer<typeof userSchema>
868
+ Infer<typeof userSchema>;
789
869
  // { reqField: T; optField?: T }
790
870
  ```
791
871
 
@@ -818,10 +898,12 @@ const timestamps = v.object({
818
898
  const userWithAudit = baseUser.extend(timestamps);
819
899
 
820
900
  // Permissive container that forwards extras
821
- const eventEnvelope = v.object({
822
- type: v.literal("user.created", "user.updated"),
823
- payload: v.object({}).allowUnknown(),
824
- }).allowUnknown();
901
+ const eventEnvelope = v
902
+ .object({
903
+ type: v.literal("user.created", "user.updated"),
904
+ payload: v.object({}).allowUnknown(),
905
+ })
906
+ .allowUnknown();
825
907
 
826
908
  // Confirmation pattern with omit
827
909
  const signupSchema = v.object({
@@ -858,7 +940,7 @@ Seal exposes a plugin system so you can add validator methods without forking th
858
940
  import type { SealPlugin } from "@warlock.js/seal";
859
941
 
860
942
  type SealPlugin = {
861
- name: string; // unique identifier — duplicates warn and skip install
943
+ name: string; // unique identifier — duplicates warn and skip install
862
944
  version?: string;
863
945
  description?: string;
864
946
  install: (context: { name: string; version?: string }) => void | Promise<void>;
@@ -896,8 +978,8 @@ import { registerPlugin, unregisterPlugin, hasPlugin, getInstalledPlugins } from
896
978
  await registerPlugin(slugPlugin);
897
979
  // console.warn if "slug" is already installed; otherwise install() runs and the plugin is tracked.
898
980
 
899
- hasPlugin("slug"); // → true
900
- getInstalledPlugins(); // → [slugPlugin]
981
+ hasPlugin("slug"); // → true
982
+ getInstalledPlugins(); // → [slugPlugin]
901
983
 
902
984
  await unregisterPlugin("slug");
903
985
  // runs slugPlugin.uninstall?.(); removes from registry.
@@ -1000,8 +1082,8 @@ For a method that creates a **new validator** (not chained from an existing one)
1000
1082
  ## Introspection — checking what's loaded
1001
1083
 
1002
1084
  ```ts
1003
- hasPlugin("slug"); // boolean
1004
- getInstalledPlugins(); // SealPlugin[]
1085
+ hasPlugin("slug"); // boolean
1086
+ getInstalledPlugins(); // SealPlugin[]
1005
1087
  ```
1006
1088
 
1007
1089
  Use these in startup diagnostics or in tests that need to assert a plugin is registered before exercising a method that depends on it.
@@ -1014,6 +1096,40 @@ Use these in startup diagnostics or in tests that need to assert a plugin is reg
1014
1096
  - Don't ship a plugin that overrides a built-in method without a clear reason. If you must, `uninstall` should restore the original — but the better path is a different method name.
1015
1097
  - Don't author one plugin per method. Group related methods (e.g. "country-specific validators") into one plugin so the install/uninstall lifecycle is coherent.
1016
1098
 
1099
+ ## Returning validated rule output
1100
+
1101
+ A custom rule's success result can contain `data` to replace the current value:
1102
+ `{ isValid: true, data: replacement }`. Later rules, transformers and refinements
1103
+ receive that replacement. Omitting `data` preserves the current value; a present
1104
+ `data: undefined` explicitly clears it. This is also how a union preserves the
1105
+ selected branch's validated defaults, catches and child transformations.
1106
+
1107
+ ## Reporting several errors from one rule
1108
+
1109
+ A failed rule result can carry `errors: [{ error, path }, ...]` to report more than
1110
+ one error, each at its own path; every entry gets the rule's `name` as its `type`.
1111
+ `v.union` uses this to relay the closest branch's errors. Return as many as fit the
1112
+ caller's `context.configurations?.firstErrorOnly` (default `true`: one).
1113
+
1114
+ ```ts
1115
+ import { invalidRule, type SchemaRule } from "@warlock.js/seal";
1116
+
1117
+ const bothDates: SchemaRule = {
1118
+ name: "bothDates",
1119
+ async validate(value: { from?: unknown; to?: unknown }, context) {
1120
+ const missing = (["from", "to"] as const).filter((key) => !value?.[key]);
1121
+ if (missing.length === 0) return { isValid: true };
1122
+
1123
+ const failed = invalidRule(this, context);
1124
+ const errors = missing.map((key) => ({
1125
+ error: `The ${key} date is required`,
1126
+ path: context.path ? `${context.path}.${key}` : key,
1127
+ }));
1128
+ return { ...failed, errors };
1129
+ },
1130
+ };
1131
+ ```
1132
+
1017
1133
 
1018
1134
  ## generate-json-schema `@warlock.js/seal/generate-json-schema/SKILL.md`
1019
1135
 
@@ -1048,19 +1164,19 @@ userSchema.toJsonSchema("draft-2020-12");
1048
1164
 
1049
1165
  ```ts
1050
1166
  type JsonSchemaTarget =
1051
- | "draft-2020-12" // default — modern JSON Schema
1052
- | "draft-07" // older tooling, Swagger 2.0
1053
- | "openapi-3.0" // uses { nullable: true } instead of type unions
1054
- | "openai-strict" // OpenAI Structured Outputs strict mode
1167
+ | "draft-2020-12" // default — modern JSON Schema
1168
+ | "draft-07" // older tooling, Swagger 2.0
1169
+ | "openapi-3.0" // uses { nullable: true } instead of type unions
1170
+ | "openai-strict"; // OpenAI Structured Outputs strict mode
1055
1171
  ```
1056
1172
 
1057
1173
  Pick by consumer:
1058
1174
 
1059
- | Consumer | Target |
1060
- | --- | --- |
1061
- | Modern tooling, no specific reason otherwise | `"draft-2020-12"` |
1062
- | Swagger 2.0 / older OpenAPI / older form builders | `"draft-07"` |
1063
- | OpenAPI 3.0 spec (uses `nullable: true`) | `"openapi-3.0"` |
1175
+ | Consumer | Target |
1176
+ | --------------------------------------------------------------- | ----------------- |
1177
+ | Modern tooling, no specific reason otherwise | `"draft-2020-12"` |
1178
+ | Swagger 2.0 / older OpenAPI / older form builders | `"draft-07"` |
1179
+ | OpenAPI 3.0 spec (uses `nullable: true`) | `"openapi-3.0"` |
1064
1180
  | OpenAI `response_format: { type: "json_schema", strict: true }` | `"openai-strict"` |
1065
1181
 
1066
1182
  ## OpenAI structured outputs (`openai-strict`)
@@ -1141,8 +1257,8 @@ Cleanly mapped:
1141
1257
 
1142
1258
  Some seal constructs have no JSON Schema representation:
1143
1259
 
1144
- - **Cross-field rules** (`sameAs`, `requiredIf`, `requiredWith`, etc.) — runtime-only. The generated schema describes the *shape*, not the inter-field invariants.
1145
- - **Transformers and mutators** — output reshaping doesn't appear in the schema; the schema reflects *post-mutator, pre-transformer* shape (since that's what rules see and what the LLM is asked to produce for `openai-strict`).
1260
+ - **Cross-field rules** (`sameAs`, `requiredIf`, `requiredWith`, etc.) — runtime-only. The generated schema describes the _shape_, not the inter-field invariants.
1261
+ - **Transformers and mutators** — output reshaping doesn't appear in the schema; the schema reflects _post-mutator, pre-transformer_ shape (since that's what rules see and what the LLM is asked to produce for `openai-strict`).
1146
1262
  - **`v.computed` / `v.managed`** — **skipped** entirely by the parent `v.object`; they never appear in `properties`. They aren't part of the data contract. (Calling `.toJsonSchema()` directly on one throws — the parent object is responsible for skipping them.)
1147
1263
  - **`v.instanceof(Ctor)`** — produces `{}`. Class identity isn't expressible. For `File`, attach `{ type: "string", format: "binary" }` manually after generation if needed for OpenAPI.
1148
1264
  - **`v.any()`** — produces `{}` deliberately (any value is valid).
@@ -1151,10 +1267,10 @@ Boolean rules `accepted` / `declined` and similar coercion-style rules are also
1151
1267
 
1152
1268
  ## When the generated schema rejects valid data
1153
1269
 
1154
- If the schema validator itself accepts data but the *generated* JSON Schema rejects the same data downstream, the cause is usually one of:
1270
+ If the schema validator itself accepts data but the _generated_ JSON Schema rejects the same data downstream, the cause is usually one of:
1155
1271
 
1156
1272
  - **Cross-field rule.** The generated schema doesn't enforce it, but a separate consumer might. (Or the runtime check fired at a different stage.)
1157
- - **Transformer running on the wrong side.** The schema describes the input shape (or strict-mode normalized form). If your transformer reshapes `Date` to ISO string for `data`, the *input* still needs to be a Date-parseable thing.
1273
+ - **Transformer running on the wrong side.** The schema describes the input shape (or strict-mode normalized form). If your transformer reshapes `Date` to ISO string for `data`, the _input_ still needs to be a Date-parseable thing.
1158
1274
  - **`openai-strict` quirk.** Optional fields show as `["T", "null"]` rather than omitted — if the model omits them entirely (without sending `null`), strict mode fails. The fix is on the prompt side: tell the model to send `null` for unused fields.
1159
1275
 
1160
1276
  ## Cost note
@@ -1175,13 +1291,13 @@ description: 'Read `ValidationResult` — `isValid`, `errors[]`, `data`. Branch
1175
1291
 
1176
1292
  ```ts
1177
1293
  type TypedValidationResult<TData> =
1178
- | { isValid: true; data: TData; errors: ValidationError[] } // TData = Infer.Output<typeof schema>
1294
+ | { isValid: true; data: TData; errors: ValidationError[] } // TData = Infer.Output<typeof schema>
1179
1295
  | { isValid: false; data: undefined; errors: ValidationError[] };
1180
1296
 
1181
1297
  type ValidationError = {
1182
- type: string; // rule type that failed — "required", "email", "min", "string", ...
1183
- error: string; // resolved message (translated, with attribute substitution)
1184
- input: string; // input field name — "email" or "address.city"
1298
+ type: string; // rule type that failed — "required", "email", "min", "string", ...
1299
+ error: string; // resolved message (translated, with attribute substitution)
1300
+ input: string; // input field name — "email" or "address.city"
1185
1301
  };
1186
1302
  ```
1187
1303
 
@@ -1200,7 +1316,7 @@ const schema = v.object({
1200
1316
  const result = await validate(schema, input);
1201
1317
 
1202
1318
  if (result.isValid) {
1203
- return result.data; // typed as the inferred shape (post-transformer)
1319
+ return result.data; // typed as the inferred shape (post-transformer)
1204
1320
  }
1205
1321
 
1206
1322
  return {
@@ -1215,15 +1331,13 @@ return {
1215
1331
  const result = await validate(schema, input);
1216
1332
 
1217
1333
  if (!result.isValid) {
1218
- const emailMissing = result.errors.find(
1219
- e => e.input === "email" && e.type === "required",
1220
- );
1334
+ const emailMissing = result.errors.find((e) => e.input === "email" && e.type === "required");
1221
1335
  if (emailMissing) {
1222
1336
  return { redirect: "/signup", reason: "no email" };
1223
1337
  }
1224
1338
 
1225
1339
  const ageInvalid = result.errors.find(
1226
- e => e.input === "age" && (e.type === "int" || e.type === "min"),
1340
+ (e) => e.input === "age" && (e.type === "int" || e.type === "min"),
1227
1341
  );
1228
1342
  if (ageInvalid) {
1229
1343
  return { error: "Age must be 13 or older" };
@@ -1239,18 +1353,20 @@ These are the strings you'll see in `error.type`:
1239
1353
 
1240
1354
  The `type` is the rule's own `name` field — not the method you called. Several methods map to one shared rule name (e.g. `.min()` / `.minLength()` on a string both surface as `minLength`; `.sameAs()` surfaces as `equalsField`). The table below lists the actual `type` strings:
1241
1355
 
1242
- | Type | Produced by |
1243
- | --- | --- |
1244
- | `required`, `present` | `.required()`, `.present()` (note: `.optional()` has no error type — it clears the required rule) |
1245
- | `requiredIf`, `requiredWith`, `requiredWithout`, `requiredUnless` | conditional required methods |
1246
- | `string`, `number`, `int`, `float`, `boolean`, `scalar`, `object`, `array`, `date` | type guards from `matchesType` |
1247
- | `minLength`, `maxLength`, `betweenLength`, `length` | string / array length rules (`.min`/`.max` on a string alias to `minLength`/`maxLength`) |
1248
- | `min`, `max`, `betweenNumbers` | number range rules (`.min`/`.max`/`.between` on a number) |
1249
- | `email`, `url`, `uuid`, `pattern`, `matches` | string format rules (`.pattern()` → `pattern`) |
1250
- | `literal`, `enum`, `in`, `allowedValues`, `notAllowedValues` | value-membership rules (`.oneOf` aliases `in`; `.notIn`/`.forbids` → `notAllowedValues`; `.allowsOnly` → `allowedValues`) |
1251
- | `instanceof` | `v.instanceof(Ctor)` |
1252
- | `equalsField`, `notEqualsField` | cross-field equality (`.sameAs` → `equalsField`; `.differentFrom` → `notEqualsField`) |
1253
- | `minDate`, `maxDate`, `beforeField`, `afterField`, `today`, `past`, `future`, `weekDay`, `weekend`, `businessDay`, `birthday` | date rules (`.min`/`.max` → `minDate`/`maxDate`; `.before`/`.after` → `beforeField`/`afterField`) |
1356
+ | Type | Produced by |
1357
+ | ----------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
1358
+ | `required`, `present` | `.required()`, `.present()` (note: `.optional()` has no error type — it clears the required rule) |
1359
+ | `requiredIf`, `requiredWith`, `requiredWithout`, `requiredUnless` | conditional required methods |
1360
+ | `string`, `number`, `int`, `float`, `boolean`, `scalar`, `object`, `array`, `date` | type guards from `matchesType` |
1361
+ | `minLength`, `maxLength`, `betweenLength`, `length` | string / array length rules (`.min`/`.max` on a string alias to `minLength`/`maxLength`) |
1362
+ | `min`, `max`, `betweenNumbers` | number range rules (`.min`/`.max`/`.between` on a number) |
1363
+ | `email`, `url`, `uuid`, `pattern`, `matches` | string format rules (`.pattern()` → `pattern`) |
1364
+ | `literal`, `enum`, `in`, `allowedValues`, `notAllowedValues` | value-membership rules (`.oneOf` aliases `in`; `.notIn`/`.forbids` → `notAllowedValues`; `.allowsOnly` → `allowedValues`) |
1365
+ | `instanceof` | `v.instanceof(Ctor)` |
1366
+ | `equalsField`, `notEqualsField` | cross-field equality (`.sameAs` → `equalsField`; `.differentFrom` → `notEqualsField`) |
1367
+ | `minDate`, `maxDate`, `beforeField`, `afterField`, `today`, `past`, `future`, `weekDay`, `weekend`, `businessDay`, `birthday` | date rules (`.min`/`.max` → `minDate`/`maxDate`; `.before`/`.after` → `beforeField`/`afterField`) |
1368
+
1369
+ `v.union(...)` reports `union`: when no branch passes, it relays the closest branch's errors (fewest mismatched literals, then fewest errors, then deepest path) at their own paths, all typed `union`. A custom rule can do the same by returning `errors: [{ error, path }, ...]` with its failed result.
1254
1370
 
1255
1371
  If you write custom rules, the `type` name you set on the rule object is what shows up here. Pick stable, kebab-or-camel-case names — they become a public API.
1256
1372
 
@@ -1282,7 +1398,7 @@ For attribute display names ("email" → "Email Address"), use `.attributes({ em
1282
1398
 
1283
1399
  ## When to throw
1284
1400
 
1285
- Don't wrap `validate()` in try/catch for *validation* failures — those land in `result.errors`. The only thing that *throws* is a programming bug:
1401
+ Don't wrap `validate()` in try/catch for _validation_ failures — those land in `result.errors`. The only thing that _throws_ is a programming bug:
1286
1402
 
1287
1403
  - A rule's callback threw (e.g. you wrote `async validate() { throw new Error(...) }`).
1288
1404
  - A transformer threw on output.
@@ -1298,7 +1414,7 @@ If you're surfacing seal errors through HTTP / RPC, the typical shape is:
1298
1414
  if (!result.isValid) {
1299
1415
  return reply.code(422).send({
1300
1416
  error: "validation_failed",
1301
- fields: result.errors.map(e => ({
1417
+ fields: result.errors.map((e) => ({
1302
1418
  field: e.input,
1303
1419
  type: e.type,
1304
1420
  message: e.error,
@@ -1321,7 +1437,7 @@ description: "@warlock.js/seal overview for orientation and choosing the focused
1321
1437
 
1322
1438
  # `@warlock.js/seal` — overview
1323
1439
 
1324
- A type-safe, framework-agnostic validation library. You build a schema with the `v` factory, run it with `validate(schema, data)`, and pull the static type out with `Infer<typeof schema>`. One schema is the runtime check *and* the TypeScript type *and* (optionally) a JSON Schema.
1440
+ A type-safe, framework-agnostic validation library. You build a schema with the `v` factory, run it with `validate(schema, data)`, and pull the static type out with `Infer<typeof schema>`. One schema is the runtime check _and_ the TypeScript type _and_ (optionally) a JSON Schema.
1325
1441
 
1326
1442
  Standalone — no framework required. Ships built-in with `@warlock.js/core`, which adds framework-specific validators (file uploads, database existence/uniqueness rules) on top.
1327
1443
 
@@ -1344,31 +1460,39 @@ Eight task skills. Most schemas only need `seal-basics` + `pick-seal-primitive`
1344
1460
  ### Foundations
1345
1461
 
1346
1462
  #### `seal-basics`
1463
+
1347
1464
  Start here. The `v` factory, `validate(schema, data)`, and `Infer<typeof schema>`.
1348
1465
 
1349
1466
  ### Building schemas
1350
1467
 
1351
1468
  #### `pick-seal-primitive`
1469
+
1352
1470
  Choose the right primitive — `string` / `int` / `literal` / `date` / `enum` / `computed` / `managed` / `instanceof` / `any`. Covers the close calls (`string` vs `scalar`, `int` vs `number`, `literal` vs `enum`).
1353
1471
 
1354
1472
  #### `define-structural-shape`
1473
+
1355
1474
  Compose `v.object` / `v.array` / `v.record` / `v.tuple` / `v.union` / `v.discriminatedUnion` / `v.lazy` — object schemas, dynamic-keyed records, tagged unions, recursive shapes.
1356
1475
 
1357
1476
  #### `compose-seal-modifiers`
1477
+
1358
1478
  Cross-cutting modifiers — `.optional` / `.nullable` / `.default` / `.catch` / `.omit` / membership rules — plus the mutator-vs-transformer pipeline and `Infer.Input` vs `Infer.Output`.
1359
1479
 
1360
1480
  ### Output + interop
1361
1481
 
1362
1482
  #### `handle-seal-errors`
1483
+
1363
1484
  Read a `ValidationResult` — `isValid`, `errors[]`, `data`. Branch on `error.type`, customize messages, hook translation.
1364
1485
 
1365
1486
  #### `generate-json-schema`
1487
+
1366
1488
  `schema.toJsonSchema(target)` — draft-2020-12 / draft-07 / openapi-3.0 / openai-strict. For OpenAI structured outputs, OpenAPI specs, any JSON-Schema consumer.
1367
1489
 
1368
1490
  #### `bridge-standard-schema`
1369
- Standard Schema interop — why a `StandardSchemaV1<T>` slot might reject a schema, the phantom-intersection at the `v` factory return, Cascade `Model<TSchema>` variance. For migrating off `as unknown as` casts.
1491
+
1492
+ Standard Schema interop — why a `StandardSchemaV1<T>` slot might reject a schema, object input/output class parameters and primitive factory intersections, Cascade `Model<TSchema>` variance. For migrating off `as unknown as` casts.
1370
1493
 
1371
1494
  #### `extend-seal-with-plugins`
1495
+
1372
1496
  Author a plugin to add validator methods (`.slug`, `.postalCode`, …) — the `SealPlugin` shape, `registerPlugin` lifecycle, TS prototype augmentation.
1373
1497
 
1374
1498
  ## Configuration
@@ -1397,25 +1521,25 @@ description: 'Pick the right `v` factory primitive — string / int / literal /
1397
1521
 
1398
1522
  # Picking the right primitive
1399
1523
 
1400
- This is the orientation skill — *which* primitive for *which* job. For the chainable methods on each (`.email()`, `.min()`, `.between()`, etc.), load the matching `*-methods.md` reference file in this skill folder.
1524
+ This is the orientation skill — _which_ primitive for _which_ job. For the chainable methods on each (`.email()`, `.min()`, `.between()`, etc.), load the matching `*-methods.md` reference file in this skill folder.
1401
1525
 
1402
1526
  ## Strings
1403
1527
 
1404
1528
  ```ts
1405
- v.string() // type: string — full surface in string-methods.md
1406
- v.email() // shorthand for v.string().email()
1407
- v.enum(["a", "b"]) // type: "a" | "b" — runs as v.string().oneOf, but the factory overload preserves the literal union
1529
+ v.string(); // type: string — full surface in string-methods.md
1530
+ v.email(); // shorthand for v.string().email()
1531
+ v.enum(["a", "b"]); // type: "a" | "b" — runs as v.string().oneOf, but the factory overload preserves the literal union
1408
1532
  ```
1409
1533
 
1410
1534
  Reach for `v.string()` for any text input. `v.email()` is just sugar — switch back to `v.string().email().min(...)` when you need extra rules.
1411
1535
 
1412
1536
  ## Numbers — pick by what you accept
1413
1537
 
1414
- | Validator | Accepts | When |
1415
- |---|---|---|
1416
- | `v.number()` | any finite number | accepts both integers and floats |
1417
- | `v.int()` | integers only | rejects `1.5` |
1418
- | `v.float()` | finite, non-integer | rejects `1` |
1538
+ | Validator | Accepts | When |
1539
+ | ------------- | ------------------------- | ----------------------------------------------------------- |
1540
+ | `v.number()` | any finite number | accepts both integers and floats |
1541
+ | `v.int()` | integers only | rejects `1.5` |
1542
+ | `v.float()` | finite, non-integer | rejects `1` |
1419
1543
  | `v.numeric()` | numeric strings + numbers | form/query inputs that arrive as `"42"` — coerces to number |
1420
1544
 
1421
1545
  All four share the same chain surface (see [`number-methods.md`](./number-methods.md)). Picking is about input acceptance, not chain power.
@@ -1423,8 +1547,8 @@ All four share the same chain surface (see [`number-methods.md`](./number-method
1423
1547
  ## Booleans & scalars
1424
1548
 
1425
1549
  ```ts
1426
- v.boolean() // type: boolean — adds .accepted() / .declined() for form-style inputs
1427
- v.scalar() // type: string | number | boolean — usually a smell pointing at a missing discriminator
1550
+ v.boolean(); // type: boolean — adds .accepted() / .declined() for form-style inputs
1551
+ v.scalar(); // type: string | number | boolean — usually a smell pointing at a missing discriminator
1428
1552
  ```
1429
1553
 
1430
1554
  Use `v.scalar()` only when the field truly accepts any of the three primitives. If it's "one of N specific values across types", `v.literal(...)` is cleaner.
@@ -1432,8 +1556,8 @@ Use `v.scalar()` only when the field truly accepts any of the three primitives.
1432
1556
  ## Dates
1433
1557
 
1434
1558
  ```ts
1435
- v.date() // type: Date — normalizes strings/timestamps to Date, rich rule surface
1436
- v.instanceof(Date) // type: Date — raw instanceof, no normalization, no rules
1559
+ v.date(); // type: Date — normalizes strings/timestamps to Date, rich rule surface
1560
+ v.instanceof(Date); // type: Date — raw instanceof, no normalization, no rules
1437
1561
  ```
1438
1562
 
1439
1563
  `v.date()` is the right tool 99% of the time — it ships `.min/.max/.before/.after/.weekDay/.minAge/...` and a built-in mutator that parses strings. `v.instanceof(Date)` is the escape hatch when you specifically need strict instance identity with zero coercion.
@@ -1441,14 +1565,14 @@ v.instanceof(Date) // type: Date — raw instanceof, no normalization, no
1441
1565
  ## Literals & instances
1442
1566
 
1443
1567
  ```ts
1444
- v.literal("items") // type: "items"
1445
- v.literal("draft", "published", "archived") // type: "draft" | "published" | "archived"
1446
- v.literal(1, 2, 3) // type: 1 | 2 | 3
1447
- v.literal(true) // type: true
1568
+ v.literal("items"); // type: "items"
1569
+ v.literal("draft", "published", "archived"); // type: "draft" | "published" | "archived"
1570
+ v.literal(1, 2, 3); // type: 1 | 2 | 3
1571
+ v.literal(true); // type: true
1448
1572
 
1449
- v.instanceof(File) // type: File
1450
- v.instanceof(Buffer) // type: Buffer
1451
- v.instanceof(MyClass) // type: MyClass
1573
+ v.instanceof(File); // type: File
1574
+ v.instanceof(Buffer); // type: Buffer
1575
+ v.instanceof(MyClass); // type: MyClass
1452
1576
  ```
1453
1577
 
1454
1578
  **`v.literal` vs `v.string().oneOf([...])` vs `v.enum([...])`:**
@@ -1457,20 +1581,20 @@ v.instanceof(MyClass) // type: MyClass
1457
1581
  - `v.string().oneOf(["a", "b"])` infers as `string` (loses literal types). Use when broad type is fine.
1458
1582
  - `v.enum(["a", "b"])` runs the same `oneOf` rule at runtime (it builds a `StringValidator().oneOf(...)`), but the `v.enum` factory overload **preserves the literal union** — it infers `"a" | "b"`, not `string`. Pass a TS enum object (`v.enum(Direction)`) and it uses `Object.values`, inferring `Direction[keyof Direction]`.
1459
1583
 
1460
- `**`v.literal("")` — the empty string is a value, not an absence.** `""` is accepted when it is one of the configured literals, and the field is still required to be *present*:
1584
+ `**`v.literal("")`— the empty string is a value, not an absence.**`""` is accepted when it is one of the configured literals, and the field is still required to be _present_:
1461
1585
 
1462
1586
  ```ts
1463
1587
  const decorative = v.object({ decorative: v.literal(true), alt: v.literal("") });
1464
1588
 
1465
- await v.validate(decorative, { decorative: true, alt: "" }); // valid
1466
- await v.validate(decorative, { decorative: true, alt: "text" }); // invalid — not the literal
1467
- await v.validate(decorative, { decorative: true }); // invalid — key missing
1589
+ await v.validate(decorative, { decorative: true, alt: "" }); // valid
1590
+ await v.validate(decorative, { decorative: true, alt: "text" }); // invalid — not the literal
1591
+ await v.validate(decorative, { decorative: true }); // invalid — key missing
1468
1592
  ```
1469
1593
 
1470
1594
  That distinction matters wherever "empty" and "absent" mean different things — `alt=""` marks an image as decorative to a screen reader, while a missing `alt` is an authoring mistake. Add `.optional()` only when a missing key really is acceptable; a present value must still match the literal.
1471
1595
 
1472
1596
  :::note[Fixed in 4.9.2]
1473
- Before 4.9.2 `v.literal("")` always failed with *"is required"*, and `.optional()` looked like a workaround while actually disabling the literal check — accepting `""`, `null`, and a missing key alike. `v.literal(0)` and `v.literal(false)` were never affected.
1597
+ Before 4.9.2 `v.literal("")` always failed with _"is required"_, and `.optional()` looked like a workaround while actually disabling the literal check — accepting `""`, `null`, and a missing key alike. `v.literal(0)` and `v.literal(false)` were never affected.
1474
1598
  :::
1475
1599
 
1476
1600
  :::note[Fixed again in 5.30]
@@ -1482,7 +1606,7 @@ Before 4.9.2 `v.literal("")` always failed with *"is required"*, and `.optional(
1482
1606
  ## `v.any` — escape hatch
1483
1607
 
1484
1608
  ```ts
1485
- v.any() // type: any — skips validation entirely
1609
+ v.any(); // type: any — skips validation entirely
1486
1610
  ```
1487
1611
 
1488
1612
  Reach for it when you genuinely don't care about the shape. Usually a smell — search PRs for it and ask whether a real schema would catch a class of bugs.
@@ -1504,26 +1628,26 @@ v.object({
1504
1628
  - **`v.computed`** runs after sibling validation; callback signature is `(data, context)` — `data` is the validated sibling object. Use for derived values (full name, hash of fields, computed totals). An optional second arg validates the result: `v.computed<string>(cb, v.string().min(3))`.
1505
1629
  - **`v.managed`** runs from `SchemaContext` only — callback signature is `(context)`. Caller-supplied extras passed to `validate(schema, data, { context })` land on `context.context`. Use for framework-injected values — timestamps, current user, request id. The callback is optional (`v.managed()`) for values the framework injects without a generator.
1506
1630
 
1507
- Both are **skipped** when their parent `v.object` generates JSON Schema — they never appear in `properties`, since they're runtime-only and not part of the JSON contract an LLM or external API consumer reads. Calling `.toJsonSchema()` *directly* on a `v.computed` / `v.managed` validator **throws** (it's a programming error — let the parent object skip them).
1631
+ Both are **skipped** when their parent `v.object` generates JSON Schema — they never appear in `properties`, since they're runtime-only and not part of the JSON contract an LLM or external API consumer reads. Calling `.toJsonSchema()` _directly_ on a `v.computed` / `v.managed` validator **throws** (it's a programming error — let the parent object skip them).
1508
1632
 
1509
1633
  ## Quick map — "I need to validate…"
1510
1634
 
1511
- | Need | Reach for |
1512
- |---|---|
1513
- | Email | `v.string().email()` or `v.email()` |
1514
- | URL | `v.string().url()` |
1515
- | UUID | `v.string().uuid()` (see [`string-methods.md`](./string-methods.md)) |
1516
- | Number 0–100 | `v.number().between(0, 100)` |
1517
- | Positive integer | `v.int().positive()` |
1518
- | One of N constants | `v.literal(...values)` |
1519
- | One of TS enum values | `v.enum(MyEnum)` |
1520
- | Date in the past | `v.date().past()` |
1521
- | File upload | `v.instanceof(File)` |
1522
- | Class instance (not Date) | `v.instanceof(Ctor)` |
1523
- | Discriminated union | see the `define-structural-shape` topic |
1524
- | Derived value (computed from siblings) | `v.computed<T>(callback)` |
1525
- | Framework-injected value | `v.managed<T>(callback)` |
1526
- | Free-form / pass-through | `v.any()` (only when you've thought about it) |
1635
+ | Need | Reach for |
1636
+ | -------------------------------------- | -------------------------------------------------------------------- |
1637
+ | Email | `v.string().email()` or `v.email()` |
1638
+ | URL | `v.string().url()` |
1639
+ | UUID | `v.string().uuid()` (see [`string-methods.md`](./string-methods.md)) |
1640
+ | Number 0–100 | `v.number().between(0, 100)` |
1641
+ | Positive integer | `v.int().positive()` |
1642
+ | One of N constants | `v.literal(...values)` |
1643
+ | One of TS enum values | `v.enum(MyEnum)` |
1644
+ | Date in the past | `v.date().past()` |
1645
+ | File upload | `v.instanceof(File)` |
1646
+ | Class instance (not Date) | `v.instanceof(Ctor)` |
1647
+ | Discriminated union | see the `define-structural-shape` topic |
1648
+ | Derived value (computed from siblings) | `v.computed<T>(callback)` |
1649
+ | Framework-injected value | `v.managed<T>(callback)` |
1650
+ | Free-form / pass-through | `v.any()` (only when you've thought about it) |
1527
1651
 
1528
1652
  ## Method-surface reference
1529
1653
 
@@ -1542,7 +1666,7 @@ For cross-cutting modifiers (`.optional`/`.nullable`/`.default`/`.catch`/`.in`/`
1542
1666
  - Don't pick `v.scalar` because "it's flexible". Flexibility at this layer usually means a missing discriminator — try `v.literal` or `v.union` first.
1543
1667
  - Don't reach for `v.instanceof(Date)` when `v.date()` works. The latter is purpose-built.
1544
1668
  - Don't use `v.string().oneOf(["a", "b"])` for discriminator fields where you need the literal type — use `v.literal("a", "b")`.
1545
- - Don't expect `v.computed` / `v.managed` to validate input — they ignore input shape entirely. Reach for them only when *producing* a value.
1669
+ - Don't expect `v.computed` / `v.managed` to validate input — they ignore input shape entirely. Reach for them only when _producing_ a value.
1546
1670
 
1547
1671
 
1548
1672
  ### pick-seal-primitive/boolean-methods.md `@warlock.js/seal/pick-seal-primitive/boolean-methods.md`
@@ -1661,139 +1785,139 @@ v.object({
1661
1785
 
1662
1786
  ## Range — global value comparison
1663
1787
 
1664
- | Method | Args | Example |
1665
- |---|---|---|
1666
- | `.min(dateOrField, msg?)` | inclusive `>=` | `v.date().min("2024-01-01")` or `v.date().min(new Date())` |
1667
- | `.max(dateOrField, msg?)` | inclusive `<=` | `v.date().max(new Date())` |
1668
- | `.before(dateOrField, msg?)` | strict `<` | `v.date().before(new Date())` |
1669
- | `.after(dateOrField, msg?)` | strict `>` | `v.date().after(new Date())` |
1670
- | `.between(start, end, msg?)` | inclusive range | `v.date().between(start, end)` |
1788
+ | Method | Args | Example |
1789
+ | ---------------------------- | --------------- | ---------------------------------------------------------- |
1790
+ | `.min(dateOrField, msg?)` | inclusive `>=` | `v.date().min("2024-01-01")` or `v.date().min(new Date())` |
1791
+ | `.max(dateOrField, msg?)` | inclusive `<=` | `v.date().max(new Date())` |
1792
+ | `.before(dateOrField, msg?)` | strict `<` | `v.date().before(new Date())` |
1793
+ | `.after(dateOrField, msg?)` | strict `>` | `v.date().after(new Date())` |
1794
+ | `.between(start, end, msg?)` | inclusive range | `v.date().between(start, end)` |
1671
1795
 
1672
1796
  Smart detection: a string with `-` or `/` is a date string; a plain string is a sibling field name.
1673
1797
 
1674
1798
  ## Range — explicit sibling scope
1675
1799
 
1676
- | Method | Effect |
1677
- |---|---|
1678
- | `.minSibling(field, msg?)` | `>=` sibling field |
1679
- | `.maxSibling(field, msg?)` | `<=` sibling field |
1680
- | `.beforeSibling(field, msg?)` | `<` sibling field |
1681
- | `.afterSibling(field, msg?)` | `>` sibling field |
1682
- | `.sameAsField(field, msg?)` | `===` sibling field |
1800
+ | Method | Effect |
1801
+ | ---------------------------------- | ------------------------------------ |
1802
+ | `.minSibling(field, msg?)` | `>=` sibling field |
1803
+ | `.maxSibling(field, msg?)` | `<=` sibling field |
1804
+ | `.beforeSibling(field, msg?)` | `<` sibling field |
1805
+ | `.afterSibling(field, msg?)` | `>` sibling field |
1806
+ | `.sameAsField(field, msg?)` | `===` sibling field |
1683
1807
  | `.sameAsFieldSibling(field, msg?)` | `===` sibling field (explicit scope) |
1684
1808
 
1685
1809
  Only run inside `v.object`. Not representable in JSON Schema.
1686
1810
 
1687
1811
  ## Today / past / future
1688
1812
 
1689
- | Method | Effect |
1690
- |---|---|
1691
- | `.today(msg?)` | exactly today |
1692
- | `.fromToday(msg?)` | today or future |
1813
+ | Method | Effect |
1814
+ | -------------------- | --------------------- |
1815
+ | `.today(msg?)` | exactly today |
1816
+ | `.fromToday(msg?)` | today or future |
1693
1817
  | `.beforeToday(msg?)` | strictly before today |
1694
- | `.afterToday(msg?)` | strictly after today |
1695
- | `.past(msg?)` | any past date |
1696
- | `.future(msg?)` | any future date |
1818
+ | `.afterToday(msg?)` | strictly after today |
1819
+ | `.past(msg?)` | any past date |
1820
+ | `.future(msg?)` | any future date |
1697
1821
 
1698
1822
  ## Relative window
1699
1823
 
1700
- | Method | Args | Effect |
1701
- |---|---|---|
1702
- | `.withinDays(n, msg?)` | within N days past or future | — |
1703
- | `.withinPastDays(n, msg?)` | within N days in the past | — |
1704
- | `.withinFutureDays(n, msg?)` | within N days in the future | — |
1824
+ | Method | Args | Effect |
1825
+ | ---------------------------- | ---------------------------- | ------ |
1826
+ | `.withinDays(n, msg?)` | within N days past or future | — |
1827
+ | `.withinPastDays(n, msg?)` | within N days in the past | — |
1828
+ | `.withinFutureDays(n, msg?)` | within N days in the future | — |
1705
1829
 
1706
1830
  ## Age
1707
1831
 
1708
- | Method | Args | Effect |
1709
- |---|---|---|
1710
- | `.age(years, msg?)` | exactly N years old |
1711
- | `.minAge(years, msg?)` | at least N years old |
1712
- | `.maxAge(years, msg?)` | at most N years old |
1713
- | `.betweenAge(min, max, msg?)` | between min/max years |
1832
+ | Method | Args | Effect |
1833
+ | ----------------------------------- | --------------------------------- | ------ |
1834
+ | `.age(years, msg?)` | exactly N years old |
1835
+ | `.minAge(years, msg?)` | at least N years old |
1836
+ | `.maxAge(years, msg?)` | at most N years old |
1837
+ | `.betweenAge(min, max, msg?)` | between min/max years |
1714
1838
  | `.birthday(minAge?, maxAge?, msg?)` | not in future, optional age range |
1715
1839
 
1716
1840
  ## Weekday / weekend / business day
1717
1841
 
1718
- | Method | Args | Effect |
1719
- |---|---|---|
1720
- | `.weekDay(day, msg?)` | day = `"monday"` … `"sunday"` |
1721
- | `.weekdays(days, msg?)` | array of weekdays |
1722
- | `.weekend(msg?)` | Saturday or Sunday |
1723
- | `.businessDay(msg?)` | Monday – Friday |
1842
+ | Method | Args | Effect |
1843
+ | ----------------------- | ----------------------------- | ------ |
1844
+ | `.weekDay(day, msg?)` | day = `"monday"` … `"sunday"` |
1845
+ | `.weekdays(days, msg?)` | array of weekdays |
1846
+ | `.weekend(msg?)` | Saturday or Sunday |
1847
+ | `.businessDay(msg?)` | Monday – Friday |
1724
1848
 
1725
1849
  ## Period — month / year / quarter
1726
1850
 
1727
- | Method | Args | Effect |
1728
- |---|---|---|
1729
- | `.month(m, msg?)` | `m` = 1–12 (or `Month` enum) |
1730
- | `.year(y, msg?)` | exact year |
1731
- | `.quarter(q, msg?)` | `q` = 1–4 |
1732
- | `.leapYear(msg?)` | year is a leap year |
1733
- | `.minYear(yearOrField, msg?)` | year `>=` |
1734
- | `.maxYear(yearOrField, msg?)` | year `<=` |
1735
- | `.minMonth(mOrField, msg?)` | month `>=` |
1736
- | `.maxMonth(mOrField, msg?)` | month `<=` |
1737
- | `.minDay(dOrField, msg?)` | day-of-month `>=` |
1738
- | `.maxDay(dOrField, msg?)` | day-of-month `<=` |
1739
- | `.betweenYears(start, end, msg?)` | inclusive year range |
1740
- | `.betweenMonths(start, end, msg?)` | inclusive month range |
1741
- | `.betweenDays(start, end, msg?)` | inclusive day-of-month range |
1851
+ | Method | Args | Effect |
1852
+ | ---------------------------------- | ---------------------------- | ------ |
1853
+ | `.month(m, msg?)` | `m` = 1–12 (or `Month` enum) |
1854
+ | `.year(y, msg?)` | exact year |
1855
+ | `.quarter(q, msg?)` | `q` = 1–4 |
1856
+ | `.leapYear(msg?)` | year is a leap year |
1857
+ | `.minYear(yearOrField, msg?)` | year `>=` |
1858
+ | `.maxYear(yearOrField, msg?)` | year `<=` |
1859
+ | `.minMonth(mOrField, msg?)` | month `>=` |
1860
+ | `.maxMonth(mOrField, msg?)` | month `<=` |
1861
+ | `.minDay(dOrField, msg?)` | day-of-month `>=` |
1862
+ | `.maxDay(dOrField, msg?)` | day-of-month `<=` |
1863
+ | `.betweenYears(start, end, msg?)` | inclusive year range |
1864
+ | `.betweenMonths(start, end, msg?)` | inclusive month range |
1865
+ | `.betweenDays(start, end, msg?)` | inclusive day-of-month range |
1742
1866
 
1743
1867
  Each `min*` / `max*` / `between*` accepts a sibling field name. Sibling-explicit variants exist: `.minYearSibling`, `.maxYearSibling`, `.minMonthSibling`, `.maxMonthSibling`, `.minDaySibling`, `.maxDaySibling`, `.betweenYearsSibling`, `.betweenMonthsSibling`, `.betweenDaysSibling`.
1744
1868
 
1745
1869
  ## Time — hour / minute
1746
1870
 
1747
- | Method | Args | Effect |
1748
- |---|---|---|
1749
- | `.fromHour(h, msg?)` | h = 0–23, time `>= h:00` |
1750
- | `.beforeHour(h, msg?)` | time `< h:00` |
1751
- | `.betweenHours(start, end, msg?)` | inclusive hour range |
1752
- | `.fromMinute(m, msg?)` | m = 0–59 |
1753
- | `.beforeMinute(m, msg?)` | — |
1754
- | `.betweenMinutes(start, end, msg?)` | inclusive minute range |
1755
- | `.betweenTimes(start, end, msg?)` | "HH:MM" strings |
1871
+ | Method | Args | Effect |
1872
+ | ----------------------------------- | ------------------------ | ------ |
1873
+ | `.fromHour(h, msg?)` | h = 0–23, time `>= h:00` |
1874
+ | `.beforeHour(h, msg?)` | time `< h:00` |
1875
+ | `.betweenHours(start, end, msg?)` | inclusive hour range |
1876
+ | `.fromMinute(m, msg?)` | m = 0–59 |
1877
+ | `.beforeMinute(m, msg?)` | — |
1878
+ | `.betweenMinutes(start, end, msg?)` | inclusive minute range |
1879
+ | `.betweenTimes(start, end, msg?)` | "HH:MM" strings |
1756
1880
 
1757
1881
  ## Format
1758
1882
 
1759
- | Method | Args | Effect |
1760
- |---|---|---|
1883
+ | Method | Args | Effect |
1884
+ | -------------------- | ------------------- | --------------------------- |
1761
1885
  | `.format(fmt, msg?)` | dayjs format string | input must match the format |
1762
1886
 
1763
1887
  ## Mutators (pre-validation reshape)
1764
1888
 
1765
- | Method | Effect |
1766
- |---|---|
1767
- | `.toStartOfDay()` | 00:00:00.000 |
1768
- | `.toEndOfDay()` | 23:59:59.999 |
1769
- | `.toStartOfMonth()` | first day of month |
1770
- | `.toEndOfMonth()` | last day of month |
1771
- | `.toStartOfYear()` | January 1st |
1772
- | `.toEndOfYear()` | December 31st |
1773
- | `.addDays(n)` | shift by N days (negative = back) |
1774
- | `.addMonths(n)` | shift by N months |
1775
- | `.addYears(n)` | shift by N years |
1776
- | `.addHours(n)` | shift by N hours |
1777
- | `.toUTC()` | normalize to UTC |
1778
-
1779
- Mutators run *before* rules. `v.date().addDays(7).future()` checks whether the shifted date is in the future.
1889
+ | Method | Effect |
1890
+ | ------------------- | --------------------------------- |
1891
+ | `.toStartOfDay()` | 00:00:00.000 |
1892
+ | `.toEndOfDay()` | 23:59:59.999 |
1893
+ | `.toStartOfMonth()` | first day of month |
1894
+ | `.toEndOfMonth()` | last day of month |
1895
+ | `.toStartOfYear()` | January 1st |
1896
+ | `.toEndOfYear()` | December 31st |
1897
+ | `.addDays(n)` | shift by N days (negative = back) |
1898
+ | `.addMonths(n)` | shift by N months |
1899
+ | `.addYears(n)` | shift by N years |
1900
+ | `.addHours(n)` | shift by N hours |
1901
+ | `.toUTC()` | normalize to UTC |
1902
+
1903
+ Mutators run _before_ rules. `v.date().addDays(7).future()` checks whether the shifted date is in the future.
1780
1904
 
1781
1905
  ## Transformers (post-validation, reshape `data`)
1782
1906
 
1783
- | Method | Args | Effect |
1784
- |---|---|---|
1785
- | `.toISOString()` | — | `Date` → `"2026-01-15T00:00:00.000Z"` |
1786
- | `.toTimestamp()` | — | `Date` → number (ms since epoch) |
1787
- | `.toFormat(fmt)` | dayjs format string | `Date` → formatted string |
1788
- | `.toDateOnly()` | — | `"YYYY-MM-DD"` |
1789
- | `.toTimeOnly()` | — | `"HH:mm:ss"` |
1907
+ | Method | Args | Effect |
1908
+ | ---------------- | ------------------- | ------------------------------------- |
1909
+ | `.toISOString()` | — | `Date` → `"2026-01-15T00:00:00.000Z"` |
1910
+ | `.toTimestamp()` | — | `Date` → number (ms since epoch) |
1911
+ | `.toFormat(fmt)` | dayjs format string | `Date` → formatted string |
1912
+ | `.toDateOnly()` | — | `"YYYY-MM-DD"` |
1913
+ | `.toTimeOnly()` | — | `"HH:mm:ss"` |
1790
1914
 
1791
- Transformers shape the *output*. `Infer<>` still resolves to `Date` even if a transformer changes the runtime shape — `Infer` reads the validator type, not the transformer pipeline.
1915
+ Transformers shape the _output_. `Infer<>` still resolves to `Date` even if a transformer changes the runtime shape — `Infer` reads the validator type, not the transformer pipeline.
1792
1916
 
1793
1917
  ## Defaults
1794
1918
 
1795
- | Method | Effect |
1796
- |---|---|
1919
+ | Method | Effect |
1920
+ | --------------- | ------------------------------------------ |
1797
1921
  | `.defaultNow()` | shorthand for `.default(() => new Date())` |
1798
1922
 
1799
1923
  ## JSON Schema mapping
@@ -1807,28 +1931,28 @@ Transformers shape the *output*. `Infer<>` still resolves to `Date` even if a tr
1807
1931
 
1808
1932
  ```ts
1809
1933
  // Birthday — must be 13+ and in the past
1810
- v.date().past().minAge(13)
1934
+ v.date().past().minAge(13);
1811
1935
 
1812
1936
  // Reservation — future, business day, between hours
1813
- v.date().future().businessDay().betweenHours(9, 17)
1937
+ v.date().future().businessDay().betweenHours(9, 17);
1814
1938
 
1815
1939
  // Effective date range (cross-field)
1816
1940
  v.object({
1817
1941
  startsAt: v.date(),
1818
1942
  endsAt: v.date().afterSibling("startsAt"),
1819
- })
1943
+ });
1820
1944
 
1821
1945
  // API response — emit ISO string
1822
- v.date().toISOString()
1946
+ v.date().toISOString();
1823
1947
 
1824
1948
  // Event window — within 30 future days, normalized to UTC
1825
- v.date().toUTC().withinFutureDays(30)
1949
+ v.date().toUTC().withinFutureDays(30);
1826
1950
 
1827
1951
  // Quarterly report
1828
- v.date().quarter(1).year(2026)
1952
+ v.date().quarter(1).year(2026);
1829
1953
 
1830
1954
  // Date-only, end-of-day for inclusive comparisons
1831
- v.date().toEndOfDay().toDateOnly()
1955
+ v.date().toEndOfDay().toDateOnly();
1832
1956
  ```
1833
1957
 
1834
1958
 
@@ -1840,23 +1964,23 @@ All four share this surface. The picking guide (which factory to call) is in the
1840
1964
 
1841
1965
  ## Range — global value comparison
1842
1966
 
1843
- | Method | Args | JSON Schema | Example |
1844
- |---|---|---|---|
1845
- | `.min(n, msg?)` | inclusive lower bound | `minimum: n` | `v.int().min(0)` |
1846
- | `.max(n, msg?)` | inclusive upper bound | `maximum: n` | `v.int().max(100)` |
1847
- | `.between(a, b, msg?)` | inclusive range | `minimum: a, maximum: b` | `v.number().between(0, 1)` |
1848
- | `.greaterThan(n, msg?)` | strict `>` | `exclusiveMinimum: n` | `v.int().greaterThan(0)` |
1849
- | `.gt(n, msg?)` | alias for `.greaterThan` | `exclusiveMinimum: n` | — |
1850
- | `.lessThan(n, msg?)` | strict `<` | `exclusiveMaximum: n` | `v.int().lessThan(100)` |
1851
- | `.lt(n, msg?)` | alias for `.lessThan` | `exclusiveMaximum: n` | — |
1967
+ | Method | Args | JSON Schema | Example |
1968
+ | ----------------------- | ------------------------ | ------------------------ | -------------------------- |
1969
+ | `.min(n, msg?)` | inclusive lower bound | `minimum: n` | `v.int().min(0)` |
1970
+ | `.max(n, msg?)` | inclusive upper bound | `maximum: n` | `v.int().max(100)` |
1971
+ | `.between(a, b, msg?)` | inclusive range | `minimum: a, maximum: b` | `v.number().between(0, 1)` |
1972
+ | `.greaterThan(n, msg?)` | strict `>` | `exclusiveMinimum: n` | `v.int().greaterThan(0)` |
1973
+ | `.gt(n, msg?)` | alias for `.greaterThan` | `exclusiveMinimum: n` | — |
1974
+ | `.lessThan(n, msg?)` | strict `<` | `exclusiveMaximum: n` | `v.int().lessThan(100)` |
1975
+ | `.lt(n, msg?)` | alias for `.lessThan` | `exclusiveMaximum: n` | — |
1852
1976
 
1853
1977
  `.min` / `.max` / `.between` / `.greaterThan` / `.lessThan` accept a **string** as the value — interpreted as a sibling field name (smart detection):
1854
1978
 
1855
1979
  ```ts
1856
1980
  v.object({
1857
1981
  minPrice: v.int(),
1858
- maxPrice: v.int().min("minPrice"), // maxPrice >= minPrice
1859
- })
1982
+ maxPrice: v.int().min("minPrice"), // maxPrice >= minPrice
1983
+ });
1860
1984
  ```
1861
1985
 
1862
1986
  Sibling references are **not representable in JSON Schema** — silently omitted from generated output.
@@ -1865,68 +1989,68 @@ Sibling references are **not representable in JSON Schema** — silently omitted
1865
1989
 
1866
1990
  For when smart detection is ambiguous (e.g. a numeric string that happens to match a field name):
1867
1991
 
1868
- | Method | Effect |
1869
- |---|---|
1870
- | `.minSibling(field, msg?)` | value `>=` sibling field |
1871
- | `.maxSibling(field, msg?)` | value `<=` sibling field |
1872
- | `.greaterThanSibling(field, msg?)` | value `>` sibling field |
1873
- | `.gtSibling(field, msg?)` | alias |
1874
- | `.lessThanSibling(field, msg?)` | value `<` sibling field |
1875
- | `.ltSibling(field, msg?)` | alias |
1992
+ | Method | Effect |
1993
+ | ------------------------------------------- | -------------------------- |
1994
+ | `.minSibling(field, msg?)` | value `>=` sibling field |
1995
+ | `.maxSibling(field, msg?)` | value `<=` sibling field |
1996
+ | `.greaterThanSibling(field, msg?)` | value `>` sibling field |
1997
+ | `.gtSibling(field, msg?)` | alias |
1998
+ | `.lessThanSibling(field, msg?)` | value `<` sibling field |
1999
+ | `.ltSibling(field, msg?)` | alias |
1876
2000
  | `.betweenSibling(minField, maxField, msg?)` | between two sibling fields |
1877
2001
 
1878
2002
  These **only run inside `v.object`** — sibling resolution silently passes otherwise.
1879
2003
 
1880
2004
  ## Sign & parity
1881
2005
 
1882
- | Method | Effect | Example |
1883
- |---|---|---|
1884
- | `.positive(msg?)` | value `> 0` | `v.int().positive()` |
1885
- | `.negative(msg?)` | value `< 0` | `v.int().negative()` |
1886
- | `.odd(msg?)` | value is odd | `v.int().odd()` |
1887
- | `.even(msg?)` | value is even | `v.int().even()` |
2006
+ | Method | Effect | Example |
2007
+ | ----------------- | ------------- | -------------------- |
2008
+ | `.positive(msg?)` | value `> 0` | `v.int().positive()` |
2009
+ | `.negative(msg?)` | value `< 0` | `v.int().negative()` |
2010
+ | `.odd(msg?)` | value is odd | `v.int().odd()` |
2011
+ | `.even(msg?)` | value is even | `v.int().even()` |
1888
2012
 
1889
2013
  ## Divisibility / modulo
1890
2014
 
1891
- | Method | Effect | JSON Schema |
1892
- |---|---|---|
1893
- | `.modulo(n, msg?)` | value `% n === 0` | `multipleOf: n` |
1894
- | `.divisibleBy(n, msg?)` | alias | `multipleOf: n` |
1895
- | `.multipleOf(n, msg?)` | alias | `multipleOf: n` |
1896
- | `.modulusOf(n, msg?)` | alias | `multipleOf: n` |
2015
+ | Method | Effect | JSON Schema |
2016
+ | ----------------------- | ----------------- | --------------- |
2017
+ | `.modulo(n, msg?)` | value `% n === 0` | `multipleOf: n` |
2018
+ | `.divisibleBy(n, msg?)` | alias | `multipleOf: n` |
2019
+ | `.multipleOf(n, msg?)` | alias | `multipleOf: n` |
2020
+ | `.modulusOf(n, msg?)` | alias | `multipleOf: n` |
1897
2021
 
1898
2022
  ## String-form length (rare)
1899
2023
 
1900
2024
  When the numeric value is a fixed-format code (PINs, IDs):
1901
2025
 
1902
- | Method | Effect |
1903
- |---|---|
1904
- | `.length(n, msg?)` | string-rep length must be exactly `n` |
1905
- | `.minLength(n, msg?)` | string-rep length `>= n` |
1906
- | `.maxLength(n, msg?)` | string-rep length `<= n` |
2026
+ | Method | Effect |
2027
+ | --------------------- | ------------------------------------- |
2028
+ | `.length(n, msg?)` | string-rep length must be exactly `n` |
2029
+ | `.minLength(n, msg?)` | string-rep length `>= n` |
2030
+ | `.maxLength(n, msg?)` | string-rep length `<= n` |
1907
2031
 
1908
2032
  For most numeric-shaped IDs you'd use `v.string().length(n).numeric()` instead — let the value be a string.
1909
2033
 
1910
2034
  ## Mutators (pre-validation reshape)
1911
2035
 
1912
- | Method | Args | Effect |
1913
- |---|---|---|
1914
- | `.abs()` | — | `Math.abs(value)` |
1915
- | `.ceil()` | — | round up to integer |
1916
- | `.floor()` | — | round down to integer |
1917
- | `.round(decimals?)` | default 0 | round to N decimals |
1918
- | `.toFixed(decimals?)` | default 2 | round to N decimals — returns a **number** |
1919
- | `.coerce()` | — | a **numeric-shaped string** → `Number`; any other value passes through unchanged |
2036
+ | Method | Args | Effect |
2037
+ | --------------------- | --------- | -------------------------------------------------------------------------------- |
2038
+ | `.abs()` | — | `Math.abs(value)` |
2039
+ | `.ceil()` | — | round up to integer |
2040
+ | `.floor()` | — | round down to integer |
2041
+ | `.round(decimals?)` | default 0 | round to N decimals |
2042
+ | `.toFixed(decimals?)` | default 2 | round to N decimals — returns a **number** |
2043
+ | `.coerce()` | — | a **numeric-shaped string** → `Number`; any other value passes through unchanged |
1920
2044
 
1921
- These run *before* validation rules. If you mutate `1.6` with `.ceil()`, `v.int()` sees `2` and passes. Use mutators when the input arrives in a slightly wrong form and you want to coerce, not reject.
2045
+ These run _before_ validation rules. If you mutate `1.6` with `.ceil()`, `v.int()` sees `2` and passes. Use mutators when the input arrives in a slightly wrong form and you want to coerce, not reject.
1922
2046
 
1923
2047
  `.coerce()` (5.9+) is the opt-in for the most common case: **query-string params arrive as strings.** `v.int()` is strict by default — `?limit=2` is the string `"2"` and is rejected — so add `.coerce()` when a schema validates `request.query`:
1924
2048
 
1925
2049
  ```ts
1926
- v.object({ limit: v.int().coerce().min(1).max(100) }) // "2" → 2, passes
2050
+ v.object({ limit: v.int().coerce().min(1).max(100) }); // "2" → 2, passes
1927
2051
  ```
1928
2052
 
1929
- It only fixes the string *representation* — the type rule still runs, so `v.int().coerce()` on `"5.5"` coerces to `5.5` and correctly **fails** (still not an integer), and `"abc"` passes through and fails. Strictness is unchanged unless you chain `.coerce()`; the output type is unaffected (`v.int().coerce()` still infers `number`). Prefer this over hand-rolling `.addMutator(...)` in app schemas.
2053
+ It only fixes the string _representation_ — the type rule still runs, so `v.int().coerce()` on `"5.5"` coerces to `5.5` and correctly **fails** (still not an integer), and `"abc"` passes through and fails. Strictness is unchanged unless you chain `.coerce()`; the output type is unaffected (`v.int().coerce()` still infers `number`). Prefer this over hand-rolling `.addMutator(...)` in app schemas.
1930
2054
 
1931
2055
  That ordering is exactly why `.toFixed()` yields a number rather than a fixed-point string: the mutated value is what the validator's own type rule sees, and a `number` schema that emitted `"3.14"` would reject its own output. Format to a string at the presentation edge instead.
1932
2056
 
@@ -1946,31 +2070,31 @@ Before 4.9.2 `.toFixed()` returned a string, so `v.number().toFixed(2)` **always
1946
2070
 
1947
2071
  ```ts
1948
2072
  // Age
1949
- v.int().min(0).max(150)
2073
+ v.int().min(0).max(150);
1950
2074
 
1951
2075
  // Price (cents)
1952
- v.int().min(0)
2076
+ v.int().min(0);
1953
2077
 
1954
2078
  // Probability
1955
- v.number().between(0, 1)
2079
+ v.number().between(0, 1);
1956
2080
 
1957
2081
  // Even page index
1958
- v.int().min(0).even()
2082
+ v.int().min(0).even();
1959
2083
 
1960
2084
  // Quantity divisible by box size
1961
- v.int().min(1).multipleOf(12)
2085
+ v.int().min(1).multipleOf(12);
1962
2086
 
1963
2087
  // Coerced from form input
1964
- v.numeric().min(0).max(100)
2088
+ v.numeric().min(0).max(100);
1965
2089
 
1966
2090
  // Cross-field range
1967
2091
  v.object({
1968
2092
  startYear: v.int(),
1969
2093
  endYear: v.int().minSibling("startYear"),
1970
- })
2094
+ });
1971
2095
 
1972
2096
  // Optional with default
1973
- v.int().min(0).default(0).optional()
2097
+ v.int().min(0).default(0).optional();
1974
2098
  ```
1975
2099
 
1976
2100
 
@@ -1984,168 +2108,173 @@ For the picking guide (`v.string` vs `v.scalar` vs `v.literal`), see the `pick-s
1984
2108
 
1985
2109
  ## Length
1986
2110
 
1987
- | Method | Args | JSON Schema | Example |
1988
- |---|---|---|---|
1989
- | `.min(n, msg?)` | min length, inclusive | `minLength: n` | `v.string().min(3)` |
1990
- | `.minLength(n, msg?)` | alias for `.min()` | `minLength: n` | `v.string().minLength(3)` |
1991
- | `.max(n, msg?)` | max length, inclusive | `maxLength: n` | `v.string().max(120)` |
1992
- | `.maxLength(n, msg?)` | alias for `.max()` | `maxLength: n` | `v.string().maxLength(120)` |
1993
- | `.length(n, msg?)` | exact length | `minLength=maxLength=n` | `v.string().length(10)` |
1994
- | `.lengthBetween(a, b, msg?)` | min and max length | `minLength: a, maxLength: b` | `v.string().lengthBetween(5, 30)` |
2111
+ | Method | Args | JSON Schema | Example |
2112
+ | ---------------------------- | --------------------- | ---------------------------- | --------------------------------- |
2113
+ | `.min(n, msg?)` | min length, inclusive | `minLength: n` | `v.string().min(3)` |
2114
+ | `.minLength(n, msg?)` | alias for `.min()` | `minLength: n` | `v.string().minLength(3)` |
2115
+ | `.max(n, msg?)` | max length, inclusive | `maxLength: n` | `v.string().max(120)` |
2116
+ | `.maxLength(n, msg?)` | alias for `.max()` | `maxLength: n` | `v.string().maxLength(120)` |
2117
+ | `.length(n, msg?)` | exact length | `minLength=maxLength=n` | `v.string().length(10)` |
2118
+ | `.lengthBetween(a, b, msg?)` | min and max length | `minLength: a, maxLength: b` | `v.string().lengthBetween(5, 30)` |
1995
2119
 
1996
2120
  ## Format
1997
2121
 
1998
- | Method | Pattern | JSON Schema | Example |
1999
- |---|---|---|---|
2000
- | `.email(msg?)` | RFC-flavored regex | `format: "email"` | `v.string().email()` |
2001
- | `.url(msg?)` | http/https URL | `format: "uri"` | `v.string().url()` |
2002
- | `.pattern(re, msg?)` | custom regex | `pattern: re.source` | `v.string().pattern(/^[A-Z]/)` |
2003
- | `.alpha(msg?)` | letters only | — | `v.string().alpha()` |
2004
- | `.alphanumeric(msg?)` | letters + digits | — | `v.string().alphanumeric()` |
2005
- | `.numeric(msg?)` | digits only (string) | — | `v.string().numeric()` |
2006
- | `.withoutWhitespace(msg?)` | no spaces/tabs/newlines | — | `v.string().withoutWhitespace()` |
2007
- | `.creditCard(msg?)` | credit-card-shaped | — | `v.string().creditCard()` |
2008
- | `.ip(msg?)` | IPv4 or IPv6 | `format: "ipv4"` | `v.string().ip()` |
2009
- | `.ip4(msg?)` | IPv4 only | `format: "ipv4"` | `v.string().ip4()` |
2010
- | `.ip6(msg?)` | IPv6 only | `format: "ipv6"` | `v.string().ip6()` |
2011
- | `.strongPassword(minLen?, msg?)` | 8+ chars, upper+lower+digit+symbol | — | `v.string().strongPassword(12)` |
2122
+ | Method | Pattern | JSON Schema | Example |
2123
+ | -------------------------------- | ---------------------------------- | -------------------- | -------------------------------- |
2124
+ | `.email(msg?)` | RFC-flavored regex | `format: "email"` | `v.string().email()` |
2125
+ | `.url(msg?)` | http/https URL | `format: "uri"` | `v.string().url()` |
2126
+ | `.pattern(re, msg?)` | custom regex | `pattern: re.source` | `v.string().pattern(/^[A-Z]/)` |
2127
+ | `.alpha(msg?)` | letters only | — | `v.string().alpha()` |
2128
+ | `.alphanumeric(msg?)` | letters + digits | — | `v.string().alphanumeric()` |
2129
+ | `.numeric(msg?)` | digits only (string) | — | `v.string().numeric()` |
2130
+ | `.withoutWhitespace(msg?)` | no spaces/tabs/newlines | — | `v.string().withoutWhitespace()` |
2131
+ | `.creditCard(msg?)` | credit-card-shaped | — | `v.string().creditCard()` |
2132
+ | `.ip(msg?)` | IPv4 or IPv6 | `format: "ipv4"` | `v.string().ip()` |
2133
+ | `.ip4(msg?)` | IPv4 only | `format: "ipv4"` | `v.string().ip4()` |
2134
+ | `.ip6(msg?)` | IPv6 only | `format: "ipv6"` | `v.string().ip6()` |
2135
+ | `.strongPassword(minLen?, msg?)` | 8+ chars, upper+lower+digit+symbol | — | `v.string().strongPassword(12)` |
2012
2136
 
2013
2137
  ## ID formats
2014
2138
 
2015
- | Method | Args | JSON Schema | Example |
2016
- |---|---|---|---|
2017
- | `.uuid(version?, msg?)` | UUID, any version or restrict to 1/3/4/5/6/7 | `format: "uuid"` | `v.string().uuid(4)` |
2018
- | `.cuid({ version?: 1\|2 }?)` | CUID2 default (24 chars, lowercase); v1 legacy | `pattern: …` | `v.string().cuid()` |
2019
- | `.ulid(msg?)` | 26 chars, Crockford base32 (no I/L/O/U) | `pattern: …` | `v.string().ulid()` |
2020
- | `.nanoid(length?, msg?)` | URL-safe alphabet, default length 21 | `pattern: …` | `v.string().nanoid(21)` |
2139
+ | Method | Args | JSON Schema | Example |
2140
+ | ---------------------------- | ---------------------------------------------- | ---------------- | ----------------------- |
2141
+ | `.uuid(version?, msg?)` | UUID, any version or restrict to 1/3/4/5/6/7 | `format: "uuid"` | `v.string().uuid(4)` |
2142
+ | `.cuid({ version?: 1\|2 }?)` | CUID2 default (24 chars, lowercase); v1 legacy | `pattern: …` | `v.string().cuid()` |
2143
+ | `.ulid(msg?)` | 26 chars, Crockford base32 (no I/L/O/U) | `pattern: …` | `v.string().ulid()` |
2144
+ | `.nanoid(length?, msg?)` | URL-safe alphabet, default length 21 | `pattern: …` | `v.string().nanoid(21)` |
2021
2145
 
2022
2146
  UUID validation is RFC 4122 strict — the variant nibble (8/9/a/b at position 17) is checked, so "looks-like-UUID-but-not-valid" inputs are rejected. CUID defaults to **CUID2** since CUID1 is deprecated by its original author; pass `{ version: 1 }` only for legacy data. nanoid's alphabet is fixed (`A-Za-z0-9_-`) — for custom alphabets use `.pattern()` directly.
2023
2147
 
2024
2148
  ## Word count
2025
2149
 
2026
- | Method | Args | Example |
2027
- |---|---|---|
2028
- | `.words(n, msg?)` | exact word count | `v.string().words(5)` |
2029
- | `.minWords(n, msg?)` | min words | `v.string().minWords(3)` |
2030
- | `.maxWords(n, msg?)` | max words | `v.string().maxWords(50)` |
2150
+ | Method | Args | Example |
2151
+ | -------------------- | ---------------- | ------------------------- |
2152
+ | `.words(n, msg?)` | exact word count | `v.string().words(5)` |
2153
+ | `.minWords(n, msg?)` | min words | `v.string().minWords(3)` |
2154
+ | `.maxWords(n, msg?)` | max words | `v.string().maxWords(50)` |
2031
2155
 
2032
2156
  ## Content
2033
2157
 
2034
- | Method | Args | Example |
2035
- |---|---|---|
2036
- | `.startsWith(s, msg?)` | prefix check | `v.string().startsWith("https://")` |
2037
- | `.endsWith(s, msg?)` | suffix check | `v.string().endsWith(".pdf")` |
2038
- | `.contains(s, msg?)` | substring check | `v.string().contains("@")` |
2158
+ | Method | Args | Example |
2159
+ | ----------------------- | ----------------- | --------------------------------------- |
2160
+ | `.startsWith(s, msg?)` | prefix check | `v.string().startsWith("https://")` |
2161
+ | `.endsWith(s, msg?)` | suffix check | `v.string().endsWith(".pdf")` |
2162
+ | `.contains(s, msg?)` | substring check | `v.string().contains("@")` |
2039
2163
  | `.notContains(s, msg?)` | inverse substring | `v.string().notContains("javascript:")` |
2040
2164
 
2041
2165
  ## Color
2042
2166
 
2043
- | Method | Accepts | Example |
2044
- |---|---|---|
2045
- | `.color(msg?)` | any valid CSS color | `v.string().color()` |
2046
- | `.hexColor(msg?)` | `#rgb`, `#rrggbb` | `v.string().hexColor()` |
2047
- | `.rgbColor(msg?)` | `rgb(r,g,b)` | — |
2048
- | `.rgbaColor(msg?)` | `rgba(r,g,b,a)` | — |
2049
- | `.hslColor(msg?)` | `hsl(h,s,l)` | — |
2050
- | `.lightColor(msg?)` | luminance-based | — |
2051
- | `.darkColor(msg?)` | luminance-based | — |
2167
+ | Method | Accepts | Example |
2168
+ | ------------------- | ------------------- | ----------------------- |
2169
+ | `.color(msg?)` | any valid CSS color | `v.string().color()` |
2170
+ | `.hexColor(msg?)` | `#rgb`, `#rrggbb` | `v.string().hexColor()` |
2171
+ | `.rgbColor(msg?)` | `rgb(r,g,b)` | — |
2172
+ | `.rgbaColor(msg?)` | `rgba(r,g,b,a)` | — |
2173
+ | `.hslColor(msg?)` | `hsl(h,s,l)` | — |
2174
+ | `.lightColor(msg?)` | luminance-based | — |
2175
+ | `.darkColor(msg?)` | luminance-based | — |
2052
2176
 
2053
2177
  JSON Schema: `format: "color"` for `.hexColor()`; the others map to `format: "color"` only via `.hexColor()` — for OpenAPI consumers expecting strict format, prefer `.hexColor()`.
2054
2178
 
2055
2179
  ## Case mutators (pre-validation)
2056
2180
 
2057
- | Method | Effect | Example |
2058
- |---|---|---|
2059
- | `.uppercase()` | "Hello" → "HELLO" | `v.string().uppercase()` |
2060
- | `.lowercase()` | "Hello" → "hello" | — |
2061
- | `.capitalize()` | "hello world" → "Hello world" | — |
2062
- | `.titleCase()` | "hello world" → "Hello World" | — |
2063
- | `.camelCase()` | "hello world" → "helloWorld" | — |
2064
- | `.pascalCase()` | "hello world" → "HelloWorld" | — |
2065
- | `.snakeCase()` | "hello world" → "hello_world" | — |
2066
- | `.kebabCase()` | "hello world" → "hello-world" | — |
2181
+ | Method | Effect | Example |
2182
+ | --------------- | ----------------------------- | ------------------------ |
2183
+ | `.uppercase()` | "Hello" → "HELLO" | `v.string().uppercase()` |
2184
+ | `.lowercase()` | "Hello" → "hello" | — |
2185
+ | `.capitalize()` | "hello world" → "Hello world" | — |
2186
+ | `.titleCase()` | "hello world" → "Hello World" | — |
2187
+ | `.camelCase()` | "hello world" → "helloWorld" | — |
2188
+ | `.pascalCase()` | "hello world" → "HelloWorld" | — |
2189
+ | `.snakeCase()` | "hello world" → "hello_world" | — |
2190
+ | `.kebabCase()` | "hello world" → "hello-world" | — |
2067
2191
 
2068
2192
  ## Trim & whitespace mutators
2069
2193
 
2070
- | Method | Args | Effect |
2071
- |---|---|---|
2072
- | `.trim(needle?)` | default = space | trim both ends |
2073
- | `.ltrim(needle?)` | — | trim left only |
2074
- | `.rtrim(needle?)` | — | trim right only |
2075
- | `.trimMultipleWhitespace()` | — | "a b" → "a b" |
2076
- | `.padStart(len, char?)` | char default = " " | left-pad to length |
2077
- | `.padEnd(len, char?)` | char default = " " | right-pad to length |
2194
+ | Method | Args | Effect |
2195
+ | --------------------------- | ------------------ | ------------------- |
2196
+ | `.trim(needle?)` | default = space | trim both ends |
2197
+ | `.ltrim(needle?)` | — | trim left only |
2198
+ | `.rtrim(needle?)` | — | trim right only |
2199
+ | `.trimMultipleWhitespace()` | — | "a b" → "a b" |
2200
+ | `.padStart(len, char?)` | char default = " " | left-pad to length |
2201
+ | `.padEnd(len, char?)` | char default = " " | right-pad to length |
2078
2202
 
2079
2203
  ## Replace, append, modify mutators
2080
2204
 
2081
- | Method | Args | Example |
2082
- |---|---|---|
2083
- | `.replace(search, replace)` | string or RegExp + string | — |
2084
- | `.replaceAll(search, replace)` | string or RegExp + string | — |
2085
- | `.append(suffix)` | string | "foo" → "foobar" via `.append("bar")` |
2086
- | `.prepend(prefix)` | string | "foo" → "barfoo" via `.prepend("bar")` |
2087
- | `.reverse()` | — | "abc" → "cba" |
2088
- | `.repeat(count)` | number | "ab" → "ababab" via `.repeat(3)` |
2089
- | `.truncate(maxLen, suffix?)` | suffix default = "…" | "long text" → "long…" |
2090
- | `.mask(start, end?, char?)` | char default = "*" | "1234567890" → "12******90" |
2205
+ | Method | Args | Example |
2206
+ | ------------------------------ | ------------------------- | -------------------------------------- |
2207
+ | `.replace(search, replace)` | string or RegExp + string | — |
2208
+ | `.replaceAll(search, replace)` | string or RegExp + string | — |
2209
+ | `.append(suffix)` | string | "foo" → "foobar" via `.append("bar")` |
2210
+ | `.prepend(prefix)` | string | "foo" → "barfoo" via `.prepend("bar")` |
2211
+ | `.reverse()` | — | "abc" → "cba" |
2212
+ | `.repeat(count)` | number | "ab" → "ababab" via `.repeat(3)` |
2213
+ | `.truncate(maxLen, suffix?)` | suffix default = "…" | "long text" → "long…" |
2214
+ | `.mask(start, end?, char?)` | char default = "*" | "1234567890" → "12******90" |
2091
2215
 
2092
2216
  ## Filter mutators
2093
2217
 
2094
- | Method | Effect |
2095
- |---|---|
2096
- | `.toAlpha()` | strip non-letters |
2097
- | `.toAlphanumeric()` | strip non-alphanumerics |
2098
- | `.removeSpecialCharacters()` | keep alphanumerics + whitespace |
2099
- | `.removeNumbers()` | strip digits |
2100
- | `.stripTags()` | strip HTML tags via a naive `<...>` regex — **not** an XSS sanitizer; `.safeHtml()` is a deprecated alias |
2101
- | `.htmlEscape()` | `<` → `&lt;` etc |
2102
- | `.unescapeHtml()` | reverse `htmlEscape()` |
2218
+ | Method | Effect |
2219
+ | ---------------------------- | --------------------------------------------------------------------------------------------------------- |
2220
+ | `.toAlpha()` | strip non-letters |
2221
+ | `.toAlphanumeric()` | strip non-alphanumerics |
2222
+ | `.removeSpecialCharacters()` | keep alphanumerics + whitespace |
2223
+ | `.removeNumbers()` | strip digits |
2224
+ | `.stripTags()` | strip HTML tags via a naive `<...>` regex — **not** an XSS sanitizer; `.safeHtml()` is a deprecated alias |
2225
+ | `.htmlEscape()` | `<` → `&lt;` etc |
2226
+ | `.unescapeHtml()` | reverse `htmlEscape()` |
2103
2227
 
2104
2228
  ## Encoding mutators
2105
2229
 
2106
- | Method | Effect |
2107
- |---|---|
2108
- | `.base64Encode()` | utf8 → base64 |
2109
- | `.base64Decode()` | base64 → utf8 |
2110
- | `.urlEncode()` | percent-encode |
2111
- | `.urlDecode()` | percent-decode |
2112
- | `.slug()` | "Hello World!" → "hello-world" |
2113
- | `.toString()` | coerce non-string input to string |
2230
+ | Method | Effect |
2231
+ | ----------------- | --------------------------------- |
2232
+ | `.base64Encode()` | utf8 → base64 |
2233
+ | `.base64Decode()` | base64 → utf8 |
2234
+ | `.urlEncode()` | percent-encode |
2235
+ | `.urlDecode()` | percent-decode |
2236
+ | `.slug()` | "Hello World!" → "hello-world" |
2237
+ | `.toString()` | coerce non-string input to string |
2114
2238
 
2115
2239
  ## Mutator vs transformer
2116
2240
 
2117
- The methods above are **all mutators** — they reshape the value *before* validation rules run. If you want post-validation reshaping, attach via `.addTransformer(fn)` (see the `compose-seal-modifiers` topic).
2241
+ The methods above are **all mutators** — they reshape the value _before_ validation rules run. If you want post-validation reshaping, attach via `.addTransformer(fn)` (see the `compose-seal-modifiers` topic).
2118
2242
 
2119
- Practical implication: `v.string().min(3).trim()` runs `min(3)` against the *un-trimmed* input. To check trimmed length, mutate first: `v.string().trim().min(3)`.
2243
+ Practical implication: `v.string().min(3).trim()` runs `min(3)` against the _un-trimmed_ input. To check trimmed length, mutate first: `v.string().trim().min(3)`.
2120
2244
 
2121
2245
  ## Common chains
2122
2246
 
2123
2247
  ```ts
2124
2248
  // Email field
2125
- v.string().email()
2249
+ v.string().email();
2126
2250
 
2127
2251
  // Username
2128
- v.string().min(3).max(30).alphanumeric().lowercase()
2252
+ v.string().min(3).max(30).alphanumeric().lowercase();
2129
2253
 
2130
2254
  // Slug from title
2131
- v.string().slug()
2255
+ v.string().slug();
2132
2256
 
2133
2257
  // Strong password
2134
- v.string().strongPassword(12)
2258
+ v.string().strongPassword(12);
2135
2259
 
2136
2260
  // URL with strict format
2137
- v.string().url().startsWith("https://")
2261
+ v.string().url().startsWith("https://");
2138
2262
 
2139
2263
  // Tag-stripped HTML body (not XSS-safe — sanitize with DOMPurify/sanitize-html before rendering untrusted rich text)
2140
- v.string().stripTags().min(1)
2264
+ v.string().stripTags().min(1);
2141
2265
 
2142
2266
  // Masked phone for response
2143
- v.string().pattern(/^\+\d{8,}$/).mask(3, -2)
2267
+ v.string()
2268
+ .pattern(/^\+\d{8,}$/)
2269
+ .mask(3, -2);
2144
2270
 
2145
2271
  // Optional with default
2146
- v.string().email().default("guest@example.com").optional()
2272
+ v.string().email().default("guest@example.com").optional();
2147
2273
  ```
2148
2274
 
2275
+ The standalone `replaceMutator` and `replaceAllMutator` preserve the stringified
2276
+ value when called without a context or replacement options.
2277
+
2149
2278
 
2150
2279
  ## seal-basics `@warlock.js/seal/seal-basics/SKILL.md`
2151
2280
 
@@ -2156,7 +2285,7 @@ description: "Start here for @warlock.js/seal schemas, validation, parsing, and
2156
2285
 
2157
2286
  # Validate with seal
2158
2287
 
2159
- Schema-first validation. Single entry point: the `v` factory. Every validator chains, composes, and infers. Schemas double as runtime validators *and* type-level shapes via `Infer<typeof schema>`, and ship `~standard` so any Standard-Schema-aware consumer accepts them directly.
2288
+ Schema-first validation. Single entry point: the `v` factory. Every validator chains, composes, and infers. Schemas double as runtime validators _and_ type-level shapes via `Infer<typeof schema>`, and ship `~standard` so any Standard-Schema-aware consumer accepts them directly.
2160
2289
 
2161
2290
  > This skill is the seal **map** — read it first, then load the specific skill for the task.
2162
2291
 
@@ -2184,9 +2313,9 @@ type User = Infer<typeof userSchema>;
2184
2313
 
2185
2314
  const result = await validate(userSchema, input);
2186
2315
  if (result.isValid) {
2187
- result.data; // typed, post-transformer
2316
+ result.data; // typed, post-transformer
2188
2317
  } else {
2189
- result.errors; // [{ type, error, input }, ...]
2318
+ result.errors; // [{ type, error, input }, ...]
2190
2319
  }
2191
2320
  ```
2192
2321
 
@@ -2242,13 +2371,13 @@ const schema = v.object({
2242
2371
  status: v.enum(Status).optional().default(Status.ACTIVE),
2243
2372
  });
2244
2373
 
2245
- type In = Infer.Input<typeof schema>;
2374
+ type In = Infer.Input<typeof schema>;
2246
2375
  // { email?: string; status?: Status } ← caller may omit both
2247
2376
 
2248
2377
  type Out = Infer.Output<typeof schema>;
2249
2378
  // { email?: string; status: Status } ← default fired for status
2250
2379
 
2251
- type Default = Infer<typeof schema>; // alias for Infer.Input
2380
+ type Default = Infer<typeof schema>; // alias for Infer.Input
2252
2381
  ```
2253
2382
 
2254
2383
  Both flavours widen with `| null` when `.nullable()` is set.
@@ -2260,15 +2389,15 @@ Both flavours widen with `| null` when `.nullable()` is set.
2260
2389
 
2261
2390
  ## Pick a skill
2262
2391
 
2263
- | If the task is about… | Load |
2264
- | --- | --- |
2265
- | Picking the right primitive (`v.string` vs `v.scalar`, `v.literal` vs `v.enum`, `v.date` vs `v.instanceof(Date)`) | The `pick-seal-primitive` topic |
2266
- | Building object / array / record / tuple / union schemas, discriminated unions, recursive schemas | The `define-structural-shape` topic |
2267
- | Modifiers — `.required` / `.optional` / `.nullable` / `.default` / `.catch` / `.omit`, transformer vs mutator pipelines, membership rules | The `compose-seal-modifiers` topic |
2268
- | Reading `ValidationResult`, branching on `error.type`, error message customization, translation | The `handle-seal-errors` topic |
2269
- | Generating JSON Schema for OpenAI strict / OpenAPI / draft-07 | The `generate-json-schema` topic |
2270
- | Why a `StandardSchemaV1<T>` slot accepts/rejects a schema, the phantom-intersection design, `Result<unknown>` errors | The `bridge-standard-schema` topic |
2271
- | Authoring custom seal plugins to add validator methods | The `extend-seal-with-plugins` topic |
2392
+ | If the task is about… | Load |
2393
+ | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------ |
2394
+ | Picking the right primitive (`v.string` vs `v.scalar`, `v.literal` vs `v.enum`, `v.date` vs `v.instanceof(Date)`) | The `pick-seal-primitive` topic |
2395
+ | Building object / array / record / tuple / union schemas, discriminated unions, recursive schemas | The `define-structural-shape` topic |
2396
+ | Modifiers — `.required` / `.optional` / `.nullable` / `.default` / `.catch` / `.omit`, transformer vs mutator pipelines, membership rules | The `compose-seal-modifiers` topic |
2397
+ | Reading `ValidationResult`, branching on `error.type`, error message customization, translation | The `handle-seal-errors` topic |
2398
+ | Generating JSON Schema for OpenAI strict / OpenAPI / draft-07 | The `generate-json-schema` topic |
2399
+ | Why a `StandardSchemaV1<T>` slot accepts/rejects a schema, object input/output class parameters and primitive factory intersections, `Result<unknown>` errors | The `bridge-standard-schema` topic |
2400
+ | Authoring custom seal plugins to add validator methods | The `extend-seal-with-plugins` topic |
2272
2401
 
2273
2402
  ## Things NOT to do
2274
2403