@warlock.js/seal 5.22.1 → 5.23.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,271 +1,269 @@
1
- ---
2
- name: compose-seal-modifiers
3
- description: 'Apply cross-cutting modifiers — `.optional` / `.nullable` / `.default` / `.catch` / `.omit` / membership rules — plus the mutator-vs-transformer pipeline and `Infer.Input` vs `Infer.Output`. Triggers: `.optional`, `.required`, `.nullable`, `.default`, `.catch`, `.present`, `.requiredIf`, `.requiredWith`, `.in`, `.oneOf`, `.notIn`, `.omit`, `.attribute`, `.mutable`, `.addMutator`, `Infer.Input`, `Infer.Output`; "mark field optional in seal", "default value in schema", "mutator vs transformer", "when does .catch fire"; typical import `import { v, type Infer } from "@warlock.js/seal"`. Skip: primitives — `@warlock.js/seal/pick-seal-primitive/SKILL.md`; containers — `@warlock.js/seal/define-structural-shape/SKILL.md`; errors — `@warlock.js/seal/handle-seal-errors/SKILL.md`; competing `zod` `.optional`/`.default`.'
4
- ---
5
-
6
- # Cross-cutting modifiers + the pipeline
7
-
8
- This skill covers methods that work on **every** validator. Type-specific methods (`.email()`, `.min()` on strings, `.between()` on numbers, `.weekDay()` on dates, etc.) live in the per-type method references — see [`@warlock.js/seal/pick-seal-primitive/SKILL.md`](@warlock.js/seal/pick-seal-primitive/SKILL.md) and [`@warlock.js/seal/define-structural-shape/SKILL.md`](@warlock.js/seal/define-structural-shape/SKILL.md).
9
-
10
- ## The pipeline
11
-
12
- When `validate()` runs against a value, the order is:
13
-
14
- ```
15
- 1. default — fills if input is undefined
16
- 2. mutators — reshape value (string → Date, .trim(), .toUTC())
17
- 3. optional/required check — decide whether to run rules
18
- 4. requiredRule — required-condition rule (.requiredIf, .requiredWith, etc.)
19
- 5. rules — every other rule, in declaration order
20
- 6. transformers — reshape value into data (.toISOString, .toLowerCase)
21
- ```
22
-
23
- If a rule fails, transformers don't run for that field.
24
-
25
- **Mutator vs transformer mental model:**
26
- - **Mutator** = pre-validation reshape. `v.date()` ships one that parses strings. Use when you want rules to see the reshaped value.
27
- - **Transformer** = post-validation reshape. Lands in `data`. Use when you only care about the output form.
28
-
29
- ```ts
30
- // trim BEFORE length check — use a mutator
31
- v.string().addMutator(s => s.trim()).min(3)
32
- // " Hi " → mutator trims → "Hi" → fails min(3)
33
-
34
- // trim only the OUTPUT — use a transformer (.trim() on string is a transformer)
35
- v.string().min(3).trim()
36
- // " Hi " → rules see " Hi " (length 6, passes min(3)) → trim → data = "Hi"
37
- ```
38
-
39
- ## `.required()` / `.optional()` / `.present()`
40
-
41
- ```ts
42
- v.string() // already required inside v.object — no need to call .required()
43
- v.string().optional() // type: string | undefined — { isOptional: true } brand
44
- v.string().present() // must exist, may be "" / null
45
- v.string().required() // explicit form — same behavior, redundant
46
- ```
47
-
48
- **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).
49
-
50
- The `{ isOptional: true }` brand survives chaining (`.optional().min(3)` is still optional) and `Infer<>` reads it to make the key optional.
51
-
52
- **When `.required()` is still useful:**
53
- - Visual contrast next to `.optional()` siblings when you want the asymmetry to be loud — style call.
54
- - It's not needed for conditional rules — `.requiredIf(field, value)` and friends *replace* the default required-condition slot, so they work standalone.
55
-
56
- Conditional variants (run inside `v.object` only):
57
- - `.requiredIf(field, value)` / `.requiredIfSibling(field, value)`
58
- - `.requiredWith(field)` / `.requiredWithSibling(field)`
59
- - `.requiredWithout(field)` / `.requiredWithoutSibling(field)`
60
- - `.requiredUnless(field, value)`
61
- - `.requiredWhen(callback)` — predicate
62
- - `.present()` / `.presentIf(field, value)` / `.presentUnless(field, value)`
63
- - `.forbidden()` / `.forbiddenIf(field, value)` — opposite of present
64
-
65
- ## `.nullable()` / `.notNullable()` / `.nullish()`
66
-
67
- ```ts
68
- v.string().nullable() // type: string | null
69
- v.string().nullish() // sugar for .optional().nullable()
70
- ```
71
-
72
- 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`.
73
-
74
- ## `.default(value | callback)`
75
-
76
- ```ts
77
- v.string().default("guest")
78
- v.int().default(0)
79
- v.date().default(() => new Date()) // lazy — fresh each validation
80
- v.array(v.string()).default([])
81
- ```
82
-
83
- 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.
84
-
85
- Date sugar: `v.date().defaultNow()` ≡ `.default(() => new Date())`.
86
-
87
- The `{ hasDefault: true }` brand makes the key optional in `Infer.Input` (caller doesn't have to send it) and required in `Infer.Output` (data always has it).
88
-
89
- ## `.catch(fallback)`
90
-
91
- Rescues *failed* validation by substituting a fallback. Complement to `.default()`:
92
-
93
- - `.default(x)` fires when input is **absent**
94
- - `.catch(y)` fires when input is **present but invalid**
95
-
96
- ```ts
97
- const config = v.object({
98
- retries: v.int().min(0).catch(3),
99
- region: v.string().in(["us", "eu"]).catch("us"),
100
- features: v.array(v.string()).catch([]),
101
- });
102
-
103
- await validate(config, { retries: "five", region: null, features: "x" });
104
- // { isValid: true, data: { retries: 3, region: "us", features: [] } }
105
- ```
106
-
107
- The fallback can be a value or a callback `(errors, originalInput) => fallback` — the callback variant is the only side-channel for the swallowed errors. Use it to log/alert before substituting.
108
-
109
- **Scope (v1).** Catch is honoured for **leaf validators** (string, number, boolean, date, …) and for fields inside containers. It is a **no-op on container validators themselves** (`v.object`, `v.array`, `v.record`, `v.tuple`, `v.discriminatedUnion`) — those use their own iteration logic that bypasses the catch hook. To rescue a whole-container failure, wrap the call site in your own try/catch instead.
110
-
111
- **Best used for:** LLM output parsing, third-party API responses, config files, any data where the cost of failure is higher than the cost of a wrong value. Overuse masks real bugs — reach for it deliberately.
112
-
113
- The `{ hasCatch: true }` brand has the same effect on `Infer.Input`/`Infer.Output` as `{ hasDefault: true }`.
114
-
115
- ## `Infer.Input` vs `Infer.Output`
116
-
117
- The two inference helpers describe the two halves of the pipeline:
118
-
119
- ```ts
120
- const schema = v.object({
121
- bio: v.string().optional(),
122
- status: v.enum(Status).optional().default(Status.ACTIVE),
123
- retries: v.int().catch(3),
124
- });
125
-
126
- type In = Infer.Input<typeof schema>;
127
- // {
128
- // bio?: string;
129
- // status?: Status; ← default makes caller optional
130
- // retries?: number; ← catch makes caller optional
131
- // }
132
-
133
- type Out = Infer.Output<typeof schema>;
134
- // {
135
- // bio?: string;
136
- // status: Status; ← default guarantees a value
137
- // retries: number; ← catch guarantees a value
138
- // }
139
-
140
- type Default = Infer<typeof schema>; // alias for Infer.Input
141
- ```
142
-
143
- **When to reach for which:**
144
- - `Infer.Input<T>` (or bare `Infer<T>`) — for HTTP request bodies, form payloads, DTOs, anything pre-validation. **The common case in HTTP-shaped code.**
145
- - `Infer.Output<T>` — for Cascade `Model<>` params, validated state, anywhere downstream of `validate()`.
146
-
147
- Both widen with `| null` when `.nullable()` is set.
148
-
149
- ## Absent vs empty vs invalid — what `data` actually contains
150
-
151
- Three failure modes, three different rescue mechanisms:
152
-
153
- | Input state | Rescued by | What appears in `data` |
154
- | --- | --- | --- |
155
- | Field absent | `.default(x)` | `x` |
156
- | Field absent (no default) | `.optional()` | Key omitted entirely |
157
- | Field present and invalid | `.catch(y)` | `y` |
158
- | Field is `null` | `.nullable()` | `null` |
159
- | Field present, empty (`""`, `[]`, `{}`) | (none needed — empty is a valid value) | Preserved as-is |
160
-
161
- Full truth table for an `.optional()` field inside `v.object(…)`:
162
-
163
- | Input | What appears in `data` |
164
- | --- | --- |
165
- | Field absent | Key **omitted entirely** (not `undefined`-valued) |
166
- | Field is explicit `undefined` | Key omitted (treated identically to absent) |
167
- | 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. |
168
- | Field is `null` with `.nullable()` | Key is `null` |
169
- | Field present and empty (`""`, `[]`, `{}`) | Preserved as-is — empty ≠ absent |
170
- | Field is `.default(x)` and absent | Key is `x` (default fires, then rules run on `x`) |
171
- | Field is `.default(x)` and present | Caller value wins; default is unused |
172
- | Field is `.catch(y)` and validation fails | Key is `y` (catch rescues) |
173
-
174
- ```ts
175
- const schema = v.object({
176
- metadata: v.record(v.string()).optional(),
177
- embedding: v.array(v.number()).optional(),
178
- });
179
-
180
- (await validate(schema, {})).data
181
- // → {} — neither key appears
182
- // (NOT { metadata: {}, embedding: [] })
183
-
184
- (await validate(schema, { metadata: {}, embedding: [] })).data
185
- // → { metadata: {}, embedding: [] } — present-empty is preserved
186
- ```
187
-
188
- **Why the distinction matters.** Persistence layers see `key in data` as "user touched this column". Synthesizing `{}` / `[]` for absent input would write empty values to the DB, defeat `$exists` filters, and confuse "I cleared this" vs "I never set this" downstream. Cascade models, Standard-Schema consumers, and JSON serializers all depend on this contract.
189
-
190
- Collection validators (`v.record`, `v.array`, `v.tuple`) explicitly honor this — they used to coerce absent input to empty containers (a long-standing bug), but now propagate `undefined` so the parent `v.object` correctly omits the key.
191
-
192
- ## Membership rules (inherited by every primitive)
193
-
194
- Available on `v.string()`, `v.number()`, `v.int()`, `v.float()`, `v.boolean()`, `v.scalar()`:
195
-
196
- ```ts
197
- v.string().in(["admin", "user", "guest"]) // value must match one
198
- v.string().oneOf(["a", "b"]) // alias for .in
199
- v.string().notIn(["banned", "blocked"]) // value must NOT match
200
- v.string().forbids(["banned"]) // alias for .notIn
201
- v.number().allowsOnly([1, 2, 3]) // stricter — explicit allowlist
202
- v.string().enum(MyTSEnum) // accepts a TS enum object via Object.values
203
- ```
204
-
205
- For literal-typed narrowing (`"admin" | "user" | "guest"` instead of `string`), use `v.literal(...)` instead — `oneOf` keeps the broader primitive type.
206
-
207
- ## `.omit()` / `.exclude()`
208
-
209
- ```ts
210
- v.object({
211
- email: v.string(),
212
- password: v.string(),
213
- passwordConfirm: v.string().sameAs("password").omit(),
214
- })
215
- ```
216
-
217
- `.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.
218
-
219
- ## `.label("Display Name")` — field display name
220
-
221
- To control the `:input` placeholder in a field's own messages, call `.label()` on the field validator:
222
-
223
- ```ts
224
- v.object({
225
- email_address: v.string().label("Email Address"),
226
- })
227
- // Error message: "The Email Address is required" (instead of "The email_address is required")
228
- ```
229
-
230
- `.label(x)` sets the field's `:input` attribute, so every rule message for that field renders the friendly name.
231
-
232
- ### `.attributes({ ... })` is a different tool
233
-
234
- `.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`):
235
-
236
- ```ts
237
- v.string().sameAs("confirmPassword").attributes({
238
- matches: { confirmPassword: "Confirm Password" },
239
- })
240
- ```
241
-
242
- For per-field display names use `.label()`; for translated messages wire the `translateRule` / `translateAttribute` hooks via `configureSeal()`.
243
-
244
- ## Mutability — `.mutable` / `.immutable`
245
-
246
- Validators are **immutable by default**. Every chain method returns a clone:
247
-
248
- ```ts
249
- const baseString = v.string();
250
- const required = baseString.required();
251
- // baseString is unchanged
252
- ```
253
-
254
- This matters because schemas are often shared (`Model.schema = v.object({...})`). If chaining mutated, every reuse would carry forward the previous chain's state.
255
-
256
- Toggle in-place with the `.mutable` getter (rare):
257
-
258
- ```ts
259
- const schema = v.string().mutable.required().min(3);
260
- // Same instance throughout — useful when building dynamically
261
- ```
262
-
263
- Switch back with `.immutable`. Default is fine 99% of the time. Reach for `.mutable` only when you've thought about who else holds a reference.
264
-
265
- ## Things NOT to do
266
-
267
- - Don't put a transformer where a mutator belongs — `.trim()` is a transformer; if you need trimming *before* `.min()`, use `.addMutator(s => s.trim())`.
268
- - Don't combine `.required()` and `.optional()` on the same chain — last wins, but the intent is unclear; pick one.
269
- - Don't chain `.default("a")` with `.min(3)` and expect `"a"` to pass — the default goes through rules.
270
- - Don't expect `.requiredIf()` to work on a standalone validator outside `v.object` — sibling resolution silently passes.
271
- - Don't mutate a schema you handed to a Model. Default immutability protects you; opting into `.mutable` on shared schemas is asking for confusion.
1
+ ---
2
+ name: compose-seal-modifiers
3
+ description: 'Apply cross-cutting modifiers — `.optional` / `.nullable` / `.default` / `.catch` / `.omit` / membership rules — plus the mutator-vs-transformer pipeline and `Infer.Input` vs `Infer.Output`. Triggers: `.optional`, `.required`, `.nullable`, `.default`, `.catch`, `.present`, `.requiredIf`, `.requiredWith`, `.in`, `.oneOf`, `.notIn`, `.omit`, `.attribute`, `.mutable`, `.addMutator`, `Infer.Input`, `Infer.Output`; "mark field optional in seal", "default value in schema", "mutator vs transformer", "when does .catch fire"; typical import `import { v, type Infer } from "@warlock.js/seal"`. Skip: primitives — `@warlock.js/seal/pick-seal-primitive/SKILL.md`; containers — `@warlock.js/seal/define-structural-shape/SKILL.md`; errors — `@warlock.js/seal/handle-seal-errors/SKILL.md`; competing `zod` `.optional`/`.default`.'
4
+ ---
5
+
6
+ # Cross-cutting modifiers + the pipeline
7
+
8
+ This skill covers methods that work on **every** validator. Type-specific methods (`.email()`, `.min()` on strings, `.between()` on numbers, `.weekDay()` on dates, etc.) live in the per-type method references — see [`@warlock.js/seal/pick-seal-primitive/SKILL.md`](@warlock.js/seal/pick-seal-primitive/SKILL.md) and [`@warlock.js/seal/define-structural-shape/SKILL.md`](@warlock.js/seal/define-structural-shape/SKILL.md).
9
+
10
+ ## The pipeline
11
+
12
+ When `validate()` runs against a value, the order is:
13
+
14
+ ```
15
+ 1. default — fills if input is undefined
16
+ 2. mutators — reshape value (string → Date, .trim(), .toUTC())
17
+ 3. optional/required check — decide whether to run rules
18
+ 4. requiredRule — required-condition rule (.requiredIf, .requiredWith, etc.)
19
+ 5. rules — every other rule, in declaration order
20
+ 6. transformers — reshape value into data (.toISOString, .toLowerCase)
21
+ ```
22
+
23
+ If a rule fails, transformers don't run for that field.
24
+
25
+ **Mutator vs transformer mental model:**
26
+ - **Mutator** = pre-validation reshape. `v.date()` ships one that parses strings. Use when you want rules to see the reshaped value.
27
+ - **Transformer** = post-validation reshape. Lands in `data`. Use when you only care about the output form.
28
+
29
+ ```ts
30
+ // trim BEFORE length check — use a mutator
31
+ v.string().addMutator(s => s.trim()).min(3)
32
+ // " Hi " → mutator trims → "Hi" → fails min(3)
33
+
34
+ // trim only the OUTPUT — use a transformer (.trim() on string is a transformer)
35
+ v.string().min(3).trim()
36
+ // " Hi " → rules see " Hi " (length 6, passes min(3)) → trim → data = "Hi"
37
+ ```
38
+
39
+ ## `.required()` / `.optional()` / `.present()`
40
+
41
+ ```ts
42
+ v.string() // already required inside v.object
43
+ v.string().optional() // type: string | undefined — { isOptional: true } brand
44
+ v.string().present() // must exist, may be "" / null
45
+ ```
46
+
47
+ **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).
48
+
49
+ The `{ isOptional: true }` brand survives chaining (`.optional().min(3)` is still optional) and `Infer<>` reads it to make the key optional.
50
+
51
+ **When `.required()` is still useful:**
52
+ - It's not needed for conditional rules — `.requiredIf(field, value)` and friends *replace* the default required-condition slot, so they work standalone.
53
+
54
+ Conditional variants (run inside `v.object` only):
55
+ - `.requiredIf(field, value)` / `.requiredIfSibling(field, value)`
56
+ - `.requiredWith(field)` / `.requiredWithSibling(field)`
57
+ - `.requiredWithout(field)` / `.requiredWithoutSibling(field)`
58
+ - `.requiredUnless(field, value)`
59
+ - `.requiredWhen(callback)` — predicate
60
+ - `.present()` / `.presentIf(field, value)` / `.presentUnless(field, value)`
61
+ - `.forbidden()` / `.forbiddenIf(field, value)` — opposite of present
62
+
63
+ ## `.nullable()` / `.notNullable()` / `.nullish()`
64
+
65
+ ```ts
66
+ v.string().nullable() // type: string | null
67
+ v.string().nullish() // sugar for .optional().nullable()
68
+ ```
69
+
70
+ 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`.
71
+
72
+ ## `.default(value | callback)`
73
+
74
+ ```ts
75
+ v.string().default("guest")
76
+ v.int().default(0)
77
+ v.date().default(() => new Date()) // lazy — fresh each validation
78
+ v.array(v.string()).default([])
79
+ ```
80
+
81
+ 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.
82
+
83
+ Date sugar: `v.date().defaultNow()` ≡ `.default(() => new Date())`.
84
+
85
+ The `{ hasDefault: true }` brand makes the key optional in `Infer.Input` (caller doesn't have to send it) and required in `Infer.Output` (data always has it).
86
+
87
+ ## `.catch(fallback)`
88
+
89
+ Rescues *failed* validation by substituting a fallback. Complement to `.default()`:
90
+
91
+ - `.default(x)` fires when input is **absent**
92
+ - `.catch(y)` fires when input is **present but invalid**
93
+
94
+ ```ts
95
+ const config = v.object({
96
+ retries: v.int().min(0).catch(3),
97
+ region: v.string().in(["us", "eu"]).catch("us"),
98
+ features: v.array(v.string()).catch([]),
99
+ });
100
+
101
+ await validate(config, { retries: "five", region: null, features: "x" });
102
+ // { isValid: true, data: { retries: 3, region: "us", features: [] } }
103
+ ```
104
+
105
+ The fallback can be a value or a callback `(errors, originalInput) => fallback` — the callback variant is the only side-channel for the swallowed errors. Use it to log/alert before substituting.
106
+
107
+ **Scope (v1).** Catch is honoured for **leaf validators** (string, number, boolean, date, …) and for fields inside containers. It is a **no-op on container validators themselves** (`v.object`, `v.array`, `v.record`, `v.tuple`, `v.discriminatedUnion`) — those use their own iteration logic that bypasses the catch hook. To rescue a whole-container failure, wrap the call site in your own try/catch instead.
108
+
109
+ **Best used for:** LLM output parsing, third-party API responses, config files, any data where the cost of failure is higher than the cost of a wrong value. Overuse masks real bugs — reach for it deliberately.
110
+
111
+ The `{ hasCatch: true }` brand has the same effect on `Infer.Input`/`Infer.Output` as `{ hasDefault: true }`.
112
+
113
+ ## `Infer.Input` vs `Infer.Output`
114
+
115
+ The two inference helpers describe the two halves of the pipeline:
116
+
117
+ ```ts
118
+ const schema = v.object({
119
+ bio: v.string().optional(),
120
+ status: v.enum(Status).optional().default(Status.ACTIVE),
121
+ retries: v.int().catch(3),
122
+ });
123
+
124
+ type In = Infer.Input<typeof schema>;
125
+ // {
126
+ // bio?: string;
127
+ // status?: Status; ← default makes caller optional
128
+ // retries?: number; ← catch makes caller optional
129
+ // }
130
+
131
+ type Out = Infer.Output<typeof schema>;
132
+ // {
133
+ // bio?: string;
134
+ // status: Status; ← default guarantees a value
135
+ // retries: number; ← catch guarantees a value
136
+ // }
137
+
138
+ type Default = Infer<typeof schema>; // alias for Infer.Input
139
+ ```
140
+
141
+ **When to reach for which:**
142
+ - `Infer.Input<T>` (or bare `Infer<T>`) — for HTTP request bodies, form payloads, DTOs, anything pre-validation. **The common case in HTTP-shaped code.**
143
+ - `Infer.Output<T>` — for Cascade `Model<>` params, validated state, anywhere downstream of `validate()`.
144
+
145
+ Both widen with `| null` when `.nullable()` is set.
146
+
147
+ ## Absent vs empty vs invalid — what `data` actually contains
148
+
149
+ Three failure modes, three different rescue mechanisms:
150
+
151
+ | Input state | Rescued by | What appears in `data` |
152
+ | --- | --- | --- |
153
+ | Field absent | `.default(x)` | `x` |
154
+ | Field absent (no default) | `.optional()` | Key omitted entirely |
155
+ | Field present and invalid | `.catch(y)` | `y` |
156
+ | Field is `null` | `.nullable()` | `null` |
157
+ | Field present, empty (`""`, `[]`, `{}`) | (none needed — empty is a valid value) | Preserved as-is |
158
+
159
+ Full truth table for an `.optional()` field inside `v.object(…)`:
160
+
161
+ | Input | What appears in `data` |
162
+ | --- | --- |
163
+ | Field absent | Key **omitted entirely** (not `undefined`-valued) |
164
+ | Field is explicit `undefined` | Key omitted (treated identically to absent) |
165
+ | 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. |
166
+ | Field is `null` with `.nullable()` | Key is `null` |
167
+ | Field present and empty (`""`, `[]`, `{}`) | Preserved as-is — empty ≠ absent |
168
+ | Field is `.default(x)` and absent | Key is `x` (default fires, then rules run on `x`) |
169
+ | Field is `.default(x)` and present | Caller value wins; default is unused |
170
+ | Field is `.catch(y)` and validation fails | Key is `y` (catch rescues) |
171
+
172
+ ```ts
173
+ const schema = v.object({
174
+ metadata: v.record(v.string()).optional(),
175
+ embedding: v.array(v.number()).optional(),
176
+ });
177
+
178
+ (await validate(schema, {})).data
179
+ // → {} — neither key appears
180
+ // (NOT { metadata: {}, embedding: [] })
181
+
182
+ (await validate(schema, { metadata: {}, embedding: [] })).data
183
+ // → { metadata: {}, embedding: [] } — present-empty is preserved
184
+ ```
185
+
186
+ **Why the distinction matters.** Persistence layers see `key in data` as "user touched this column". Synthesizing `{}` / `[]` for absent input would write empty values to the DB, defeat `$exists` filters, and confuse "I cleared this" vs "I never set this" downstream. Cascade models, Standard-Schema consumers, and JSON serializers all depend on this contract.
187
+
188
+ Collection validators (`v.record`, `v.array`, `v.tuple`) explicitly honor this — they used to coerce absent input to empty containers (a long-standing bug), but now propagate `undefined` so the parent `v.object` correctly omits the key.
189
+
190
+ ## Membership rules (inherited by every primitive)
191
+
192
+ Available on `v.string()`, `v.number()`, `v.int()`, `v.float()`, `v.boolean()`, `v.scalar()`:
193
+
194
+ ```ts
195
+ v.string().in(["admin", "user", "guest"]) // value must match one
196
+ v.string().oneOf(["a", "b"]) // alias for .in
197
+ v.string().notIn(["banned", "blocked"]) // value must NOT match
198
+ v.string().forbids(["banned"]) // alias for .notIn
199
+ v.number().allowsOnly([1, 2, 3]) // stricter — explicit allowlist
200
+ v.string().enum(MyTSEnum) // accepts a TS enum object via Object.values
201
+ ```
202
+
203
+ For literal-typed narrowing (`"admin" | "user" | "guest"` instead of `string`), use `v.literal(...)` instead — `oneOf` keeps the broader primitive type.
204
+
205
+ ## `.omit()` / `.exclude()`
206
+
207
+ ```ts
208
+ v.object({
209
+ email: v.string(),
210
+ password: v.string(),
211
+ passwordConfirm: v.string().sameAs("password").omit(),
212
+ })
213
+ ```
214
+
215
+ `.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.
216
+
217
+ ## `.label("Display Name")` — field display name
218
+
219
+ To control the `:input` placeholder in a field's own messages, call `.label()` on the field validator:
220
+
221
+ ```ts
222
+ v.object({
223
+ email_address: v.string().label("Email Address"),
224
+ })
225
+ // Error message: "The Email Address is required" (instead of "The email_address is required")
226
+ ```
227
+
228
+ `.label(x)` sets the field's `:input` attribute, so every rule message for that field renders the friendly name.
229
+
230
+ ### `.attributes({ ... })` is a different tool
231
+
232
+ `.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`):
233
+
234
+ ```ts
235
+ v.string().sameAs("confirmPassword").attributes({
236
+ matches: { confirmPassword: "Confirm Password" },
237
+ })
238
+ ```
239
+
240
+ For per-field display names use `.label()`; for translated messages wire the `translateRule` / `translateAttribute` hooks via `configureSeal()`.
241
+
242
+ ## Mutability — `.mutable` / `.immutable`
243
+
244
+ Validators are **immutable by default**. Every chain method returns a clone:
245
+
246
+ ```ts
247
+ const baseString = v.string();
248
+ const short = baseString.min(3);
249
+ // baseString is unchanged
250
+ ```
251
+
252
+ This matters because schemas are often shared (`Model.schema = v.object({...})`). If chaining mutated, every reuse would carry forward the previous chain's state.
253
+
254
+ Toggle in-place with the `.mutable` getter (rare):
255
+
256
+ ```ts
257
+ const schema = v.string().mutable.min(3);
258
+ // Same instance throughout — useful when building dynamically
259
+ ```
260
+
261
+ Switch back with `.immutable`. Default is fine 99% of the time. Reach for `.mutable` only when you've thought about who else holds a reference.
262
+
263
+ ## Things NOT to do
264
+
265
+ - Don't put a transformer where a mutator belongs — `.trim()` is a transformer; if you need trimming *before* `.min()`, use `.addMutator(s => s.trim())`.
266
+ - Don't combine `.required()` and `.optional()` on the same chain — last wins, but the intent is unclear; pick one.
267
+ - Don't chain `.default("a")` with `.min(3)` and expect `"a"` to pass — the default goes through rules.
268
+ - Don't expect `.requiredIf()` to work on a standalone validator outside `v.object` — sibling resolution silently passes.
269
+ - Don't mutate a schema you handed to a Model. Default immutability protects you; opting into `.mutable` on shared schemas is asking for confusion.
@@ -100,8 +100,8 @@ The default is to **reject** unknown keys, which is what you want for inbound pa
100
100
 
101
101
  ```ts
102
102
  const publicArticle = v.object({
103
- id: v.string().required(),
104
- title: v.string().required(),
103
+ id: v.string(),
104
+ title: v.string(),
105
105
  }).stripUnknown();
106
106
 
107
107
  const { isValid, data } = await v.validate(publicArticle, record);
@@ -49,7 +49,7 @@ import { object } from "@warlock.js/seal/object";
49
49
  import { string } from "@warlock.js/seal/string";
50
50
 
51
51
  const commentSchema = object({
52
- comment: string().trim().required().minLength(3),
52
+ comment: string().trim().minLength(3),
53
53
  });
54
54
 
55
55
  const result = await commentSchema["~standard"].validate({