@warlock.js/seal 5.24.0 → 5.26.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 (255) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/LICENSE +21 -21
  3. package/cjs/index.cjs +10 -5
  4. package/cjs/index.cjs.map +1 -1
  5. package/cjs/{object-DPPttDAF.cjs → object-BuKApaax.cjs} +3 -3
  6. package/cjs/object-BuKApaax.cjs.map +1 -0
  7. package/cjs/object.cjs +2 -2
  8. package/cjs/{required-methods-DJTm7yNP.cjs → required-methods-BOVNRtci.cjs} +12 -2
  9. package/cjs/required-methods-BOVNRtci.cjs.map +1 -0
  10. package/cjs/{string-C_pAtZPC.cjs → string-DpDpgefH.cjs} +2 -2
  11. package/cjs/string-DpDpgefH.cjs.map +1 -0
  12. package/cjs/string.cjs +2 -2
  13. package/esm/config.d.mts +0 -1
  14. package/esm/config.d.mts.map +1 -1
  15. package/esm/config.mjs.map +1 -1
  16. package/esm/factory/validate.mjs.map +1 -1
  17. package/esm/factory/validators.d.mts.map +1 -1
  18. package/esm/factory/validators.mjs +1 -1
  19. package/esm/factory/validators.mjs.map +1 -1
  20. package/esm/helpers/date-helpers.mjs.map +1 -1
  21. package/esm/helpers/get-field-value.d.mts +0 -1
  22. package/esm/helpers/get-field-value.d.mts.map +1 -1
  23. package/esm/helpers/get-field-value.mjs.map +1 -1
  24. package/esm/helpers/index.d.mts +5 -0
  25. package/esm/helpers/path-helpers.mjs.map +1 -1
  26. package/esm/helpers/validation-helpers.d.mts +0 -1
  27. package/esm/helpers/validation-helpers.d.mts.map +1 -1
  28. package/esm/mutators/array-mutators.d.mts +0 -1
  29. package/esm/mutators/array-mutators.d.mts.map +1 -1
  30. package/esm/mutators/array-mutators.mjs.map +1 -1
  31. package/esm/mutators/boolean-mutators.d.mts +0 -1
  32. package/esm/mutators/boolean-mutators.d.mts.map +1 -1
  33. package/esm/mutators/date-mutators.d.mts +0 -1
  34. package/esm/mutators/date-mutators.d.mts.map +1 -1
  35. package/esm/mutators/date-mutators.mjs.map +1 -1
  36. package/esm/mutators/index.d.mts +7 -0
  37. package/esm/mutators/number-mutators.d.mts +0 -1
  38. package/esm/mutators/number-mutators.d.mts.map +1 -1
  39. package/esm/mutators/number-mutators.mjs.map +1 -1
  40. package/esm/mutators/object-mutators.d.mts +0 -1
  41. package/esm/mutators/object-mutators.d.mts.map +1 -1
  42. package/esm/mutators/object-mutators.mjs.map +1 -1
  43. package/esm/mutators/scalar-mutators.d.mts +0 -1
  44. package/esm/mutators/scalar-mutators.d.mts.map +1 -1
  45. package/esm/mutators/scalar-mutators.mjs.map +1 -1
  46. package/esm/mutators/string-mutators.d.mts +0 -1
  47. package/esm/mutators/string-mutators.d.mts.map +1 -1
  48. package/esm/mutators/string-mutators.mjs.map +1 -1
  49. package/esm/plugins/plugin-system.mjs.map +1 -1
  50. package/esm/rules/array/array-rules.d.mts +0 -1
  51. package/esm/rules/array/array-rules.d.mts.map +1 -1
  52. package/esm/rules/array/array-rules.mjs.map +1 -1
  53. package/esm/rules/array/index.d.mts +1 -0
  54. package/esm/rules/color/color-rules.d.mts +0 -1
  55. package/esm/rules/color/color-rules.d.mts.map +1 -1
  56. package/esm/rules/color/color-rules.mjs.map +1 -1
  57. package/esm/rules/color/index.d.mts +1 -0
  58. package/esm/rules/common/enum.d.mts +0 -1
  59. package/esm/rules/common/enum.d.mts.map +1 -1
  60. package/esm/rules/common/enum.mjs.map +1 -1
  61. package/esm/rules/common/equals-field-rules.d.mts +0 -1
  62. package/esm/rules/common/equals-field-rules.d.mts.map +1 -1
  63. package/esm/rules/common/equals-field-rules.mjs.map +1 -1
  64. package/esm/rules/common/index.d.mts +6 -0
  65. package/esm/rules/common/instanceof.d.mts +0 -1
  66. package/esm/rules/common/instanceof.d.mts.map +1 -1
  67. package/esm/rules/common/literal.d.mts +0 -1
  68. package/esm/rules/common/literal.d.mts.map +1 -1
  69. package/esm/rules/common/type-rules.d.mts +0 -1
  70. package/esm/rules/common/type-rules.d.mts.map +1 -1
  71. package/esm/rules/common/type-rules.mjs.map +1 -1
  72. package/esm/rules/common/unknown-key.d.mts +0 -1
  73. package/esm/rules/common/unknown-key.d.mts.map +1 -1
  74. package/esm/rules/conditional/forbidden-if-rules.d.mts +0 -1
  75. package/esm/rules/conditional/forbidden-if-rules.d.mts.map +1 -1
  76. package/esm/rules/conditional/forbidden-if-rules.mjs.map +1 -1
  77. package/esm/rules/conditional/index.d.mts +10 -0
  78. package/esm/rules/conditional/present-if-rules.d.mts +0 -1
  79. package/esm/rules/conditional/present-if-rules.d.mts.map +1 -1
  80. package/esm/rules/conditional/present-if-rules.mjs.map +1 -1
  81. package/esm/rules/conditional/present-unless-rules.d.mts +0 -1
  82. package/esm/rules/conditional/present-unless-rules.d.mts.map +1 -1
  83. package/esm/rules/conditional/present-unless-rules.mjs.map +1 -1
  84. package/esm/rules/conditional/present-with-rules.d.mts +0 -1
  85. package/esm/rules/conditional/present-with-rules.d.mts.map +1 -1
  86. package/esm/rules/conditional/present-with-rules.mjs.map +1 -1
  87. package/esm/rules/conditional/present-without-rules.d.mts +0 -1
  88. package/esm/rules/conditional/present-without-rules.d.mts.map +1 -1
  89. package/esm/rules/conditional/present-without-rules.mjs.map +1 -1
  90. package/esm/rules/conditional/required-if-rules.d.mts +0 -1
  91. package/esm/rules/conditional/required-if-rules.d.mts.map +1 -1
  92. package/esm/rules/conditional/required-if-rules.mjs.map +1 -1
  93. package/esm/rules/conditional/required-unless-rules.d.mts +0 -1
  94. package/esm/rules/conditional/required-unless-rules.d.mts.map +1 -1
  95. package/esm/rules/conditional/required-unless-rules.mjs.map +1 -1
  96. package/esm/rules/conditional/required-when-rule.d.mts +0 -1
  97. package/esm/rules/conditional/required-when-rule.d.mts.map +1 -1
  98. package/esm/rules/conditional/required-with-rules.d.mts +0 -1
  99. package/esm/rules/conditional/required-with-rules.d.mts.map +1 -1
  100. package/esm/rules/conditional/required-with-rules.mjs.map +1 -1
  101. package/esm/rules/conditional/required-without-rules.d.mts +0 -1
  102. package/esm/rules/conditional/required-without-rules.d.mts.map +1 -1
  103. package/esm/rules/conditional/required-without-rules.mjs.map +1 -1
  104. package/esm/rules/core/equal.d.mts +0 -1
  105. package/esm/rules/core/equal.d.mts.map +1 -1
  106. package/esm/rules/core/equal.mjs.map +1 -1
  107. package/esm/rules/core/forbidden.d.mts +0 -1
  108. package/esm/rules/core/forbidden.d.mts.map +1 -1
  109. package/esm/rules/core/forbidden.mjs.map +1 -1
  110. package/esm/rules/core/index.d.mts +5 -0
  111. package/esm/rules/core/required.d.mts +0 -1
  112. package/esm/rules/core/required.d.mts.map +1 -1
  113. package/esm/rules/core/required.mjs.map +1 -1
  114. package/esm/rules/core/union.d.mts.map +1 -1
  115. package/esm/rules/core/union.mjs.map +1 -1
  116. package/esm/rules/core/when.d.mts +0 -1
  117. package/esm/rules/core/when.d.mts.map +1 -1
  118. package/esm/rules/core/when.mjs.map +1 -1
  119. package/esm/rules/date/date-comparison-rules.d.mts +0 -1
  120. package/esm/rules/date/date-comparison-rules.d.mts.map +1 -1
  121. package/esm/rules/date/date-comparison-rules.mjs.map +1 -1
  122. package/esm/rules/date/date-day-rules.d.mts +0 -1
  123. package/esm/rules/date/date-day-rules.d.mts.map +1 -1
  124. package/esm/rules/date/date-day-rules.mjs.map +1 -1
  125. package/esm/rules/date/date-field-comparison-rules.d.mts +0 -1
  126. package/esm/rules/date/date-field-comparison-rules.d.mts.map +1 -1
  127. package/esm/rules/date/date-field-comparison-rules.mjs.map +1 -1
  128. package/esm/rules/date/date-period-rules.d.mts +0 -1
  129. package/esm/rules/date/date-period-rules.d.mts.map +1 -1
  130. package/esm/rules/date/date-period-rules.mjs.map +1 -1
  131. package/esm/rules/date/date-relative-rules.d.mts +0 -1
  132. package/esm/rules/date/date-relative-rules.d.mts.map +1 -1
  133. package/esm/rules/date/date-relative-rules.mjs.map +1 -1
  134. package/esm/rules/date/date-special-rules.d.mts +0 -1
  135. package/esm/rules/date/date-special-rules.d.mts.map +1 -1
  136. package/esm/rules/date/date-special-rules.mjs.map +1 -1
  137. package/esm/rules/date/date.d.mts +0 -1
  138. package/esm/rules/date/date.d.mts.map +1 -1
  139. package/esm/rules/date/date.mjs.map +1 -1
  140. package/esm/rules/date/index.d.mts +7 -0
  141. package/esm/rules/file/dimensions.d.mts +0 -1
  142. package/esm/rules/file/dimensions.d.mts.map +1 -1
  143. package/esm/rules/file/dimensions.mjs.map +1 -1
  144. package/esm/rules/file/file-size.d.mts +0 -1
  145. package/esm/rules/file/file-size.d.mts.map +1 -1
  146. package/esm/rules/file/file-size.mjs.map +1 -1
  147. package/esm/rules/file/index.d.mts +2 -0
  148. package/esm/rules/index.d.mts +45 -0
  149. package/esm/rules/length/index.d.mts +1 -0
  150. package/esm/rules/length/length-rules.d.mts +0 -1
  151. package/esm/rules/length/length-rules.d.mts.map +1 -1
  152. package/esm/rules/length/length-rules.mjs.map +1 -1
  153. package/esm/rules/number/index.d.mts +1 -0
  154. package/esm/rules/number/number-rules.d.mts +0 -1
  155. package/esm/rules/number/number-rules.d.mts.map +1 -1
  156. package/esm/rules/number/number-rules.mjs.map +1 -1
  157. package/esm/rules/scalar/accepted-rule.mjs.map +1 -1
  158. package/esm/rules/scalar/declined-rule.mjs.map +1 -1
  159. package/esm/rules/string/alpha.d.mts +0 -1
  160. package/esm/rules/string/alpha.d.mts.map +1 -1
  161. package/esm/rules/string/alpha.mjs.map +1 -1
  162. package/esm/rules/string/credit-card.d.mts +0 -1
  163. package/esm/rules/string/credit-card.d.mts.map +1 -1
  164. package/esm/rules/string/credit-card.mjs.map +1 -1
  165. package/esm/rules/string/email.d.mts +0 -1
  166. package/esm/rules/string/email.d.mts.map +1 -1
  167. package/esm/rules/string/email.mjs.map +1 -1
  168. package/esm/rules/string/id-formats.d.mts +0 -1
  169. package/esm/rules/string/id-formats.d.mts.map +1 -1
  170. package/esm/rules/string/index.d.mts +11 -0
  171. package/esm/rules/string/ip.d.mts +0 -1
  172. package/esm/rules/string/ip.d.mts.map +1 -1
  173. package/esm/rules/string/matches.d.mts +0 -1
  174. package/esm/rules/string/matches.d.mts.map +1 -1
  175. package/esm/rules/string/matches.mjs.map +1 -1
  176. package/esm/rules/string/pattern.d.mts +0 -1
  177. package/esm/rules/string/pattern.d.mts.map +1 -1
  178. package/esm/rules/string/pattern.mjs.map +1 -1
  179. package/esm/rules/string/string-comparison.d.mts +0 -1
  180. package/esm/rules/string/string-comparison.d.mts.map +1 -1
  181. package/esm/rules/string/string-comparison.mjs.map +1 -1
  182. package/esm/rules/string/strong-password-rule.d.mts +0 -1
  183. package/esm/rules/string/strong-password-rule.d.mts.map +1 -1
  184. package/esm/rules/string/strong-password-rule.mjs.map +1 -1
  185. package/esm/rules/string/url.d.mts +0 -1
  186. package/esm/rules/string/url.d.mts.map +1 -1
  187. package/esm/rules/string/url.mjs.map +1 -1
  188. package/esm/rules/string/without-whitespace.d.mts +0 -1
  189. package/esm/rules/string/without-whitespace.d.mts.map +1 -1
  190. package/esm/rules/string/without-whitespace.mjs.map +1 -1
  191. package/esm/standard-schema/index.d.mts +3 -0
  192. package/esm/standard-schema/map-result.d.mts +0 -1
  193. package/esm/standard-schema/map-result.d.mts.map +1 -1
  194. package/esm/types/date-types.mjs.map +1 -1
  195. package/esm/types/index.d.mts +10 -0
  196. package/esm/validators/any-validator.mjs.map +1 -1
  197. package/esm/validators/array-validator.d.mts.map +1 -1
  198. package/esm/validators/array-validator.mjs +1 -0
  199. package/esm/validators/array-validator.mjs.map +1 -1
  200. package/esm/validators/base-validator.d.mts +2 -1
  201. package/esm/validators/base-validator.d.mts.map +1 -1
  202. package/esm/validators/base-validator.mjs +11 -1
  203. package/esm/validators/base-validator.mjs.map +1 -1
  204. package/esm/validators/boolean-validator.d.mts.map +1 -1
  205. package/esm/validators/boolean-validator.mjs +1 -0
  206. package/esm/validators/boolean-validator.mjs.map +1 -1
  207. package/esm/validators/computed-validator.d.mts.map +1 -1
  208. package/esm/validators/date-validator.d.mts +0 -1
  209. package/esm/validators/date-validator.d.mts.map +1 -1
  210. package/esm/validators/date-validator.mjs +1 -0
  211. package/esm/validators/date-validator.mjs.map +1 -1
  212. package/esm/validators/discriminated-union-validator.d.mts.map +1 -1
  213. package/esm/validators/float-validator.mjs.map +1 -1
  214. package/esm/validators/int-validator.mjs.map +1 -1
  215. package/esm/validators/lazy-validator.d.mts.map +1 -1
  216. package/esm/validators/literal-validator.d.mts.map +1 -1
  217. package/esm/validators/literal-validator.mjs +1 -3
  218. package/esm/validators/literal-validator.mjs.map +1 -1
  219. package/esm/validators/managed-validator.d.mts.map +1 -1
  220. package/esm/validators/methods/equality-conditional-methods.d.mts +0 -1
  221. package/esm/validators/methods/equality-conditional-methods.d.mts.map +1 -1
  222. package/esm/validators/methods/equality-conditional-methods.mjs.map +1 -1
  223. package/esm/validators/methods/forbidden-methods.mjs.map +1 -1
  224. package/esm/validators/methods/present-methods.mjs.map +1 -1
  225. package/esm/validators/methods/required-methods.d.mts +0 -1
  226. package/esm/validators/methods/required-methods.d.mts.map +1 -1
  227. package/esm/validators/methods/required-methods.mjs.map +1 -1
  228. package/esm/validators/number-validator.d.mts.map +1 -1
  229. package/esm/validators/number-validator.mjs +1 -0
  230. package/esm/validators/number-validator.mjs.map +1 -1
  231. package/esm/validators/numeric-validator.mjs.map +1 -1
  232. package/esm/validators/object-validator.d.mts.map +1 -1
  233. package/esm/validators/object-validator.mjs +1 -1
  234. package/esm/validators/object-validator.mjs.map +1 -1
  235. package/esm/validators/record-validator.d.mts.map +1 -1
  236. package/esm/validators/record-validator.mjs.map +1 -1
  237. package/esm/validators/scalar-validator.d.mts.map +1 -1
  238. package/esm/validators/scalar-validator.mjs +1 -0
  239. package/esm/validators/scalar-validator.mjs.map +1 -1
  240. package/esm/validators/string-validator.mjs.map +1 -1
  241. package/esm/validators/tuple-validator.d.mts.map +1 -1
  242. package/esm/validators/tuple-validator.mjs.map +1 -1
  243. package/esm/validators/union-validator.mjs.map +1 -1
  244. package/llms-full.txt +1166 -1168
  245. package/llms.txt +1 -1
  246. package/package.json +1 -1
  247. package/skills/bridge-standard-schema/SKILL.md +138 -138
  248. package/skills/define-structural-shape/SKILL.md +138 -138
  249. package/skills/extend-seal-with-plugins/SKILL.md +178 -178
  250. package/skills/generate-json-schema/SKILL.md +143 -143
  251. package/skills/handle-seal-errors/SKILL.md +148 -148
  252. package/skills/pick-seal-primitive/SKILL.md +149 -149
  253. package/cjs/object-DPPttDAF.cjs.map +0 -1
  254. package/cjs/required-methods-DJTm7yNP.cjs.map +0 -1
  255. package/cjs/string-C_pAtZPC.cjs.map +0 -1
package/llms-full.txt CHANGED
@@ -6,561 +6,559 @@
6
6
 
7
7
  ## bridge-standard-schema `@warlock.js/seal/bridge-standard-schema/SKILL.md`
8
8
 
9
- ---
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 — `@warlock.js/seal/seal-basics/SKILL.md`; JSON Schema gen — `@warlock.js/seal/generate-json-schema/SKILL.md`; competing spec `@standard-schema/spec`.'
12
- ---
13
-
14
- # The Standard Schema bridge
15
-
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
-
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.
19
-
20
- ## The shape of the bridge
21
-
22
- The `v` factory returns aren't bare validator classes. Each factory widens its return with a phantom intersection:
23
-
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>;
32
-
33
- literal: <T extends readonly [...] >(...values: T) =>
34
- new LiteralValidator<T>(values) as LiteralValidator<T> & StandardSchemaV1<T[number]>;
35
-
36
- // ...one intersection per factory.
37
- ```
38
-
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.
40
-
41
- ## What this means for app code
42
-
43
- You write the schema once, you use it anywhere a `StandardSchemaV1<T>` is expected, you never cast:
44
-
45
- ```ts
46
- import { v, validate, type Infer } from "@warlock.js/seal";
47
- import { ai } from "@warlock.js/ai";
48
-
49
- const userSchema = v.object({
50
- email: v.string().email(),
51
- age: v.int().optional(),
52
- });
53
-
54
- type User = Infer<typeof userSchema>;
55
-
56
- // ✅ ai-side slot — no cast
57
- ai.tool({
58
- name: "create_user",
59
- description: "Create a new user",
60
- input: userSchema,
61
- execute: async input => createUser(input),
62
- });
63
-
64
- // ✅ supervisor output — no cast
65
- ai.supervisor({
66
- name: "user-flow",
67
- output: userSchema,
68
- // ...
69
- });
70
-
71
- // ✅ direct validation — call ~standard
72
- const result = await userSchema["~standard"].validate(rawData);
73
- ```
74
-
75
- ## `Infer<typeof schema>` vs hand-rolled types
76
-
77
- Use `Infer<>`. Always:
78
-
79
- ```ts
80
- const schema = v.object({
81
- email: v.string(),
82
- blocks: v.array(v.literal("text", "image")).optional(),
83
- });
84
-
85
- type Output = Infer<typeof schema>;
86
- // { email: string; blocks?: ("text" | "image")[] }
87
- ```
88
-
89
- Hand-rolled parallel types drift the moment a field changes — and seal's tightened typing now catches that drift at compile time. The old loose typing hid the mismatch; the bridge surfaces it.
90
-
91
- ## When the bridge "fails"
92
-
93
- Three failure modes you'll see in real projects:
94
-
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
- ```
104
-
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>`.
106
-
107
- ### 2. The schema's inferred shape doesn't match the target type
108
-
109
- ```ts
110
- ai.supervisor<MyOutput, ...>({
111
- output: schema, // ❌ schema infers differently from MyOutput
112
- });
113
- ```
114
-
115
- The supervisor's explicit `<MyOutput>` generic is a constraint the schema must satisfy. If they diverge, TS rightly rejects. Two fixes:
116
-
117
- - **Drop the explicit generic** — let the supervisor infer output type from the schema. The hand-rolled type was probably documentation-only anyway.
118
- - **Align them** — if the hand-rolled type is the source of truth (e.g. a domain type from elsewhere), make the schema match.
119
-
120
- ### 3. The error says `Result<unknown>` even though `Infer<>` resolves correctly
121
-
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`.
123
-
124
- If you see this, ignore the `unknown` and ask: does `Infer<typeof schema>` match the slot's expected `T`? Probe with:
125
-
126
- ```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.
130
- ```
131
-
132
- That's the real mismatch.
133
-
134
- ## Why not `Omit<class, "~standard"> & StandardSchemaV1<T>`?
135
-
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.
137
-
138
- ## Why not import `@standard-schema/spec` instead of forking the types?
139
-
140
- Seal forks the types locally. Reasons:
141
-
142
- - Seal extends the spec with `StandardJSONSchemaV1` (JSON Schema converter on `~standard.jsonSchema`). The package doesn't have this — half-importing is the worst of both worlds.
143
- - V1 spec is locked. The fork is ~70 lines and updates rarely.
144
- - Avoids version-coupling pain across `@warlock.js/*` packages.
145
-
146
- If V2 lands, re-fork. Until then the local copy is the right call.
9
+ ---
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 — `@warlock.js/seal/seal-basics/SKILL.md`; JSON Schema gen — `@warlock.js/seal/generate-json-schema/SKILL.md`; competing spec `@standard-schema/spec`.'
12
+ ---
13
+
14
+ # The Standard Schema bridge
15
+
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
+
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.
19
+
20
+ ## The shape of the bridge
21
+
22
+ The `v` factory returns aren't bare validator classes. Each factory widens its return with a phantom intersection:
23
+
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>;
32
+
33
+ literal: <T extends readonly [...] >(...values: T) =>
34
+ new LiteralValidator<T>(values) as LiteralValidator<T> & StandardSchemaV1<T[number]>;
35
+
36
+ // ...one intersection per factory.
37
+ ```
38
+
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.
40
+
41
+ ## What this means for app code
42
+
43
+ You write the schema once, you use it anywhere a `StandardSchemaV1<T>` is expected, you never cast:
44
+
45
+ ```ts
46
+ import { v, validate, type Infer } from "@warlock.js/seal";
47
+ import { ai } from "@warlock.js/ai";
48
+
49
+ const userSchema = v.object({
50
+ email: v.string().email(),
51
+ age: v.int().optional(),
52
+ });
53
+
54
+ type User = Infer<typeof userSchema>;
55
+
56
+ // ✅ ai-side slot — no cast
57
+ ai.tool({
58
+ name: "create_user",
59
+ description: "Create a new user",
60
+ input: userSchema,
61
+ execute: async input => createUser(input),
62
+ });
63
+
64
+ // ✅ supervisor output — no cast
65
+ ai.supervisor({
66
+ name: "user-flow",
67
+ output: userSchema,
68
+ // ...
69
+ });
70
+
71
+ // ✅ direct validation — call ~standard
72
+ const result = await userSchema["~standard"].validate(rawData);
73
+ ```
74
+
75
+ ## `Infer<typeof schema>` vs hand-rolled types
76
+
77
+ Use `Infer<>`. Always:
78
+
79
+ ```ts
80
+ const schema = v.object({
81
+ email: v.string(),
82
+ blocks: v.array(v.literal("text", "image")).optional(),
83
+ });
84
+
85
+ type Output = Infer<typeof schema>;
86
+ // { email: string; blocks?: ("text" | "image")[] }
87
+ ```
88
+
89
+ Hand-rolled parallel types drift the moment a field changes — and seal's tightened typing now catches that drift at compile time. The old loose typing hid the mismatch; the bridge surfaces it.
90
+
91
+ ## When the bridge "fails"
92
+
93
+ Three failure modes you'll see in real projects:
94
+
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
+ ```
104
+
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>`.
106
+
107
+ ### 2. The schema's inferred shape doesn't match the target type
108
+
109
+ ```ts
110
+ ai.supervisor<MyOutput, ...>({
111
+ output: schema, // ❌ schema infers differently from MyOutput
112
+ });
113
+ ```
114
+
115
+ The supervisor's explicit `<MyOutput>` generic is a constraint the schema must satisfy. If they diverge, TS rightly rejects. Two fixes:
116
+
117
+ - **Drop the explicit generic** — let the supervisor infer output type from the schema. The hand-rolled type was probably documentation-only anyway.
118
+ - **Align them** — if the hand-rolled type is the source of truth (e.g. a domain type from elsewhere), make the schema match.
119
+
120
+ ### 3. The error says `Result<unknown>` even though `Infer<>` resolves correctly
121
+
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`.
123
+
124
+ If you see this, ignore the `unknown` and ask: does `Infer<typeof schema>` match the slot's expected `T`? Probe with:
125
+
126
+ ```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.
130
+ ```
131
+
132
+ That's the real mismatch.
133
+
134
+ ## Why not `Omit<class, "~standard"> & StandardSchemaV1<T>`?
135
+
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.
137
+
138
+ ## Why not import `@standard-schema/spec` instead of forking the types?
139
+
140
+ Seal forks the types locally. Reasons:
141
+
142
+ - Seal extends the spec with `StandardJSONSchemaV1` (JSON Schema converter on `~standard.jsonSchema`). The package doesn't have this — half-importing is the worst of both worlds.
143
+ - V1 spec is locked. The fork is ~70 lines and updates rarely.
144
+ - Avoids version-coupling pain across `@warlock.js/*` packages.
145
+
146
+ If V2 lands, re-fork. Until then the local copy is the right call.
147
147
 
148
148
 
149
149
  ## compose-seal-modifiers `@warlock.js/seal/compose-seal-modifiers/SKILL.md`
150
150
 
151
- ---
152
- name: compose-seal-modifiers
153
- 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`.'
154
- ---
155
-
156
- # Cross-cutting modifiers + the pipeline
157
-
158
- 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).
159
-
160
- ## The pipeline
161
-
162
- When `validate()` runs against a value, the order is:
163
-
164
- ```
165
- 1. default — fills if input is undefined
166
- 2. mutators — reshape value (string → Date, .trim(), .toUTC())
167
- 3. optional/required check — decide whether to run rules
168
- 4. requiredRule — required-condition rule (.requiredIf, .requiredWith, etc.)
169
- 5. rules — every other rule, in declaration order
170
- 6. transformers — reshape value into data (.toISOString, .toLowerCase)
171
- ```
172
-
173
- If a rule fails, transformers don't run for that field.
174
-
175
- **Mutator vs transformer mental model:**
176
- - **Mutator** = pre-validation reshape. `v.date()` ships one that parses strings. Use when you want rules to see the reshaped value.
177
- - **Transformer** = post-validation reshape. Lands in `data`. Use when you only care about the output form.
178
-
179
- ```ts
180
- // trim BEFORE length check — use a mutator
181
- v.string().addMutator(s => s.trim()).min(3)
182
- // " Hi " → mutator trims → "Hi" → fails min(3)
183
-
184
- // trim only the OUTPUT — use a transformer (.trim() on string is a transformer)
185
- v.string().min(3).trim()
186
- // " Hi " → rules see " Hi " (length 6, passes min(3)) → trim → data = "Hi"
187
- ```
188
-
189
- ## `.required()` / `.optional()` / `.present()`
190
-
191
- ```ts
192
- v.string() // already required inside v.object — no need to call .required()
193
- v.string().optional() // type: string | undefined — { isOptional: true } brand
194
- v.string().present() // must exist, may be "" / null
195
- v.string().required() // explicit form — same behavior, redundant
196
- ```
197
-
198
- **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
-
200
- The `{ isOptional: true }` brand survives chaining (`.optional().min(3)` is still optional) and `Infer<>` reads it to make the key optional.
201
-
202
- **When `.required()` is still useful:**
203
- - Visual contrast next to `.optional()` siblings when you want the asymmetry to be loud — style call.
204
- - It's not needed for conditional rules — `.requiredIf(field, value)` and friends *replace* the default required-condition slot, so they work standalone.
205
-
206
- Conditional variants (run inside `v.object` only):
207
- - `.requiredIf(field, value)` / `.requiredIfSibling(field, value)`
208
- - `.requiredWith(field)` / `.requiredWithSibling(field)`
209
- - `.requiredWithout(field)` / `.requiredWithoutSibling(field)`
210
- - `.requiredUnless(field, value)`
211
- - `.requiredWhen(callback)` — predicate
212
- - `.present()` / `.presentIf(field, value)` / `.presentUnless(field, value)`
213
- - `.forbidden()` / `.forbiddenIf(field, value)` — opposite of present
214
-
215
- ## `.nullable()` / `.notNullable()` / `.nullish()`
216
-
217
- ```ts
218
- v.string().nullable() // type: string | null
219
- v.string().nullish() // sugar for .optional().nullable()
220
- ```
221
-
222
- 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`.
223
-
224
- ## `.default(value | callback)`
225
-
226
- ```ts
227
- v.string().default("guest")
228
- v.int().default(0)
229
- v.date().default(() => new Date()) // lazy — fresh each validation
230
- v.array(v.string()).default([])
231
- ```
232
-
233
- 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.
234
-
235
- Date sugar: `v.date().defaultNow()` ≡ `.default(() => new Date())`.
236
-
237
- 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).
238
-
239
- ## `.catch(fallback)`
240
-
241
- Rescues *failed* validation by substituting a fallback. Complement to `.default()`:
242
-
243
- - `.default(x)` fires when input is **absent**
244
- - `.catch(y)` fires when input is **present but invalid**
245
-
246
- ```ts
247
- const config = v.object({
248
- retries: v.int().min(0).catch(3),
249
- region: v.string().in(["us", "eu"]).catch("us"),
250
- features: v.array(v.string()).catch([]),
251
- });
252
-
253
- await validate(config, { retries: "five", region: null, features: "x" });
254
- // { isValid: true, data: { retries: 3, region: "us", features: [] } }
255
- ```
256
-
257
- 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.
258
-
259
- **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.
260
-
261
- **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.
262
-
263
- The `{ hasCatch: true }` brand has the same effect on `Infer.Input`/`Infer.Output` as `{ hasDefault: true }`.
264
-
265
- ## `Infer.Input` vs `Infer.Output`
266
-
267
- The two inference helpers describe the two halves of the pipeline:
268
-
269
- ```ts
270
- const schema = v.object({
271
- bio: v.string().optional(),
272
- status: v.enum(Status).optional().default(Status.ACTIVE),
273
- retries: v.int().catch(3),
274
- });
275
-
276
- type In = Infer.Input<typeof schema>;
277
- // {
278
- // bio?: string;
279
- // status?: Status; ← default makes caller optional
280
- // retries?: number; ← catch makes caller optional
281
- // }
282
-
283
- type Out = Infer.Output<typeof schema>;
284
- // {
285
- // bio?: string;
286
- // status: Status; ← default guarantees a value
287
- // retries: number; ← catch guarantees a value
288
- // }
289
-
290
- type Default = Infer<typeof schema>; // alias for Infer.Input
291
- ```
292
-
293
- **When to reach for which:**
294
- - `Infer.Input<T>` (or bare `Infer<T>`) — for HTTP request bodies, form payloads, DTOs, anything pre-validation. **The common case in HTTP-shaped code.**
295
- - `Infer.Output<T>` — for Cascade `Model<>` params, validated state, anywhere downstream of `validate()`.
296
-
297
- Both widen with `| null` when `.nullable()` is set.
298
-
299
- ## Absent vs empty vs invalid — what `data` actually contains
300
-
301
- Three failure modes, three different rescue mechanisms:
302
-
303
- | Input state | Rescued by | What appears in `data` |
304
- | --- | --- | --- |
305
- | Field absent | `.default(x)` | `x` |
306
- | Field absent (no default) | `.optional()` | Key omitted entirely |
307
- | Field present and invalid | `.catch(y)` | `y` |
308
- | Field is `null` | `.nullable()` | `null` |
309
- | Field present, empty (`""`, `[]`, `{}`) | (none needed — empty is a valid value) | Preserved as-is |
310
-
311
- Full truth table for an `.optional()` field inside `v.object(…)`:
312
-
313
- | Input | What appears in `data` |
314
- | --- | --- |
315
- | Field absent | Key **omitted entirely** (not `undefined`-valued) |
316
- | Field is explicit `undefined` | Key omitted (treated identically to absent) |
317
- | 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. |
318
- | Field is `null` with `.nullable()` | Key is `null` |
319
- | Field present and empty (`""`, `[]`, `{}`) | Preserved as-is — empty ≠ absent |
320
- | Field is `.default(x)` and absent | Key is `x` (default fires, then rules run on `x`) |
321
- | Field is `.default(x)` and present | Caller value wins; default is unused |
322
- | Field is `.catch(y)` and validation fails | Key is `y` (catch rescues) |
323
-
324
- ```ts
325
- const schema = v.object({
326
- metadata: v.record(v.string()).optional(),
327
- embedding: v.array(v.number()).optional(),
328
- });
329
-
330
- (await validate(schema, {})).data
331
- // → {} — neither key appears
332
- // (NOT { metadata: {}, embedding: [] })
333
-
334
- (await validate(schema, { metadata: {}, embedding: [] })).data
335
- // → { metadata: {}, embedding: [] } — present-empty is preserved
336
- ```
337
-
338
- **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.
339
-
340
- 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.
341
-
342
- ## Membership rules (inherited by every primitive)
343
-
344
- Available on `v.string()`, `v.number()`, `v.int()`, `v.float()`, `v.boolean()`, `v.scalar()`:
345
-
346
- ```ts
347
- v.string().in(["admin", "user", "guest"]) // value must match one
348
- v.string().oneOf(["a", "b"]) // alias for .in
349
- v.string().notIn(["banned", "blocked"]) // value must NOT match
350
- v.string().forbids(["banned"]) // alias for .notIn
351
- v.number().allowsOnly([1, 2, 3]) // stricter — explicit allowlist
352
- v.string().enum(MyTSEnum) // accepts a TS enum object via Object.values
353
- ```
354
-
355
- For literal-typed narrowing (`"admin" | "user" | "guest"` instead of `string`), use `v.literal(...)` instead — `oneOf` keeps the broader primitive type.
356
-
357
- ## `.omit()` / `.exclude()`
358
-
359
- ```ts
360
- v.object({
361
- email: v.string(),
362
- password: v.string(),
363
- passwordConfirm: v.string().sameAs("password").omit(),
364
- })
365
- ```
366
-
367
- `.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.
368
-
369
- ## `.label("Display Name")` — field display name
370
-
371
- To control the `:input` placeholder in a field's own messages, call `.label()` on the field validator:
372
-
373
- ```ts
374
- v.object({
375
- email_address: v.string().label("Email Address"),
376
- })
377
- // Error message: "The Email Address is required" (instead of "The email_address is required")
378
- ```
379
-
380
- `.label(x)` sets the field's `:input` attribute, so every rule message for that field renders the friendly name.
381
-
382
- ### `.attributes({ ... })` is a different tool
383
-
384
- `.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`):
385
-
386
- ```ts
387
- v.string().sameAs("confirmPassword").attributes({
388
- matches: { confirmPassword: "Confirm Password" },
389
- })
390
- ```
391
-
392
- For per-field display names use `.label()`; for translated messages wire the `translateRule` / `translateAttribute` hooks via `configureSeal()`.
393
-
394
- ## Mutability — `.mutable` / `.immutable`
395
-
396
- Validators are **immutable by default**. Every chain method returns a clone:
397
-
398
- ```ts
399
- const baseString = v.string();
400
- const required = baseString.required();
401
- // baseString is unchanged
402
- ```
403
-
404
- This matters because schemas are often shared (`Model.schema = v.object({...})`). If chaining mutated, every reuse would carry forward the previous chain's state.
405
-
406
- Toggle in-place with the `.mutable` getter (rare):
407
-
408
- ```ts
409
- const schema = v.string().mutable.required().min(3);
410
- // Same instance throughout — useful when building dynamically
411
- ```
412
-
413
- 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.
414
-
415
- ## Things NOT to do
416
-
417
- - Don't put a transformer where a mutator belongs — `.trim()` is a transformer; if you need trimming *before* `.min()`, use `.addMutator(s => s.trim())`.
418
- - Don't combine `.required()` and `.optional()` on the same chain — last wins, but the intent is unclear; pick one.
419
- - Don't chain `.default("a")` with `.min(3)` and expect `"a"` to pass — the default goes through rules.
420
- - Don't expect `.requiredIf()` to work on a standalone validator outside `v.object` — sibling resolution silently passes.
421
- - Don't mutate a schema you handed to a Model. Default immutability protects you; opting into `.mutable` on shared schemas is asking for confusion.
151
+ ---
152
+ name: compose-seal-modifiers
153
+ 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`.'
154
+ ---
155
+
156
+ # Cross-cutting modifiers + the pipeline
157
+
158
+ 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).
159
+
160
+ ## The pipeline
161
+
162
+ When `validate()` runs against a value, the order is:
163
+
164
+ ```
165
+ 1. default — fills if input is undefined
166
+ 2. mutators — reshape value (string → Date, .trim(), .toUTC())
167
+ 3. optional/required check — decide whether to run rules
168
+ 4. requiredRule — required-condition rule (.requiredIf, .requiredWith, etc.)
169
+ 5. rules — every other rule, in declaration order
170
+ 6. transformers — reshape value into data (.toISOString, .toLowerCase)
171
+ ```
172
+
173
+ If a rule fails, transformers don't run for that field.
174
+
175
+ **Mutator vs transformer mental model:**
176
+ - **Mutator** = pre-validation reshape. `v.date()` ships one that parses strings. Use when you want rules to see the reshaped value.
177
+ - **Transformer** = post-validation reshape. Lands in `data`. Use when you only care about the output form.
178
+
179
+ ```ts
180
+ // trim BEFORE length check — use a mutator
181
+ v.string().addMutator(s => s.trim()).min(3)
182
+ // " Hi " → mutator trims → "Hi" → fails min(3)
183
+
184
+ // trim only the OUTPUT — use a transformer (.trim() on string is a transformer)
185
+ v.string().min(3).trim()
186
+ // " Hi " → rules see " Hi " (length 6, passes min(3)) → trim → data = "Hi"
187
+ ```
188
+
189
+ ## `.required()` / `.optional()` / `.present()`
190
+
191
+ ```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
195
+ ```
196
+
197
+ **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).
198
+
199
+ The `{ isOptional: true }` brand survives chaining (`.optional().min(3)` is still optional) and `Infer<>` reads it to make the key optional.
200
+
201
+ **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.
203
+
204
+ Conditional variants (run inside `v.object` only):
205
+ - `.requiredIf(field, value)` / `.requiredIfSibling(field, value)`
206
+ - `.requiredWith(field)` / `.requiredWithSibling(field)`
207
+ - `.requiredWithout(field)` / `.requiredWithoutSibling(field)`
208
+ - `.requiredUnless(field, value)`
209
+ - `.requiredWhen(callback)` — predicate
210
+ - `.present()` / `.presentIf(field, value)` / `.presentUnless(field, value)`
211
+ - `.forbidden()` / `.forbiddenIf(field, value)` — opposite of present
212
+
213
+ ## `.nullable()` / `.notNullable()` / `.nullish()`
214
+
215
+ ```ts
216
+ v.string().nullable() // type: string | null
217
+ v.string().nullish() // sugar for .optional().nullable()
218
+ ```
219
+
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`.
221
+
222
+ ## `.default(value | callback)`
223
+
224
+ ```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([])
229
+ ```
230
+
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.
232
+
233
+ Date sugar: `v.date().defaultNow()` ≡ `.default(() => new Date())`.
234
+
235
+ 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).
236
+
237
+ ## `.catch(fallback)`
238
+
239
+ Rescues *failed* validation by substituting a fallback. Complement to `.default()`:
240
+
241
+ - `.default(x)` fires when input is **absent**
242
+ - `.catch(y)` fires when input is **present but invalid**
243
+
244
+ ```ts
245
+ const config = v.object({
246
+ retries: v.int().min(0).catch(3),
247
+ region: v.string().in(["us", "eu"]).catch("us"),
248
+ features: v.array(v.string()).catch([]),
249
+ });
250
+
251
+ await validate(config, { retries: "five", region: null, features: "x" });
252
+ // { isValid: true, data: { retries: 3, region: "us", features: [] } }
253
+ ```
254
+
255
+ 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.
256
+
257
+ **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.
258
+
259
+ **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.
260
+
261
+ The `{ hasCatch: true }` brand has the same effect on `Infer.Input`/`Infer.Output` as `{ hasDefault: true }`.
262
+
263
+ ## `Infer.Input` vs `Infer.Output`
264
+
265
+ The two inference helpers describe the two halves of the pipeline:
266
+
267
+ ```ts
268
+ const schema = v.object({
269
+ bio: v.string().optional(),
270
+ status: v.enum(Status).optional().default(Status.ACTIVE),
271
+ retries: v.int().catch(3),
272
+ });
273
+
274
+ type In = Infer.Input<typeof schema>;
275
+ // {
276
+ // bio?: string;
277
+ // status?: Status; ← default makes caller optional
278
+ // retries?: number; ← catch makes caller optional
279
+ // }
280
+
281
+ type Out = Infer.Output<typeof schema>;
282
+ // {
283
+ // bio?: string;
284
+ // status: Status; ← default guarantees a value
285
+ // retries: number; ← catch guarantees a value
286
+ // }
287
+
288
+ type Default = Infer<typeof schema>; // alias for Infer.Input
289
+ ```
290
+
291
+ **When to reach for which:**
292
+ - `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
+ - `Infer.Output<T>` — for Cascade `Model<>` params, validated state, anywhere downstream of `validate()`.
294
+
295
+ Both widen with `| null` when `.nullable()` is set.
296
+
297
+ ## Absent vs empty vs invalid — what `data` actually contains
298
+
299
+ Three failure modes, three different rescue mechanisms:
300
+
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 |
308
+
309
+ Full truth table for an `.optional()` field inside `v.object(…)`:
310
+
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) |
321
+
322
+ ```ts
323
+ const schema = v.object({
324
+ metadata: v.record(v.string()).optional(),
325
+ embedding: v.array(v.number()).optional(),
326
+ });
327
+
328
+ (await validate(schema, {})).data
329
+ // → {} — neither key appears
330
+ // (NOT { metadata: {}, embedding: [] })
331
+
332
+ (await validate(schema, { metadata: {}, embedding: [] })).data
333
+ // → { metadata: {}, embedding: [] } — present-empty is preserved
334
+ ```
335
+
336
+ **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.
337
+
338
+ 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.
339
+
340
+ ## Membership rules (inherited by every primitive)
341
+
342
+ Available on `v.string()`, `v.number()`, `v.int()`, `v.float()`, `v.boolean()`, `v.scalar()`:
343
+
344
+ ```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
351
+ ```
352
+
353
+ For literal-typed narrowing (`"admin" | "user" | "guest"` instead of `string`), use `v.literal(...)` instead — `oneOf` keeps the broader primitive type.
354
+
355
+ ## `.omit()` / `.exclude()`
356
+
357
+ ```ts
358
+ v.object({
359
+ email: v.string(),
360
+ password: v.string(),
361
+ passwordConfirm: v.string().sameAs("password").omit(),
362
+ })
363
+ ```
364
+
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.
366
+
367
+ ## `.label("Display Name")` — field display name
368
+
369
+ To control the `:input` placeholder in a field's own messages, call `.label()` on the field validator:
370
+
371
+ ```ts
372
+ v.object({
373
+ email_address: v.string().label("Email Address"),
374
+ })
375
+ // Error message: "The Email Address is required" (instead of "The email_address is required")
376
+ ```
377
+
378
+ `.label(x)` sets the field's `:input` attribute, so every rule message for that field renders the friendly name.
379
+
380
+ ### `.attributes({ ... })` is a different tool
381
+
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`):
383
+
384
+ ```ts
385
+ v.string().sameAs("confirmPassword").attributes({
386
+ matches: { confirmPassword: "Confirm Password" },
387
+ })
388
+ ```
389
+
390
+ For per-field display names use `.label()`; for translated messages wire the `translateRule` / `translateAttribute` hooks via `configureSeal()`.
391
+
392
+ ## Mutability — `.mutable` / `.immutable`
393
+
394
+ Validators are **immutable by default**. Every chain method returns a clone:
395
+
396
+ ```ts
397
+ const baseString = v.string();
398
+ const short = baseString.min(3);
399
+ // baseString is unchanged
400
+ ```
401
+
402
+ This matters because schemas are often shared (`Model.schema = v.object({...})`). If chaining mutated, every reuse would carry forward the previous chain's state.
403
+
404
+ Toggle in-place with the `.mutable` getter (rare):
405
+
406
+ ```ts
407
+ const schema = v.string().mutable.min(3);
408
+ // Same instance throughout — useful when building dynamically
409
+ ```
410
+
411
+ 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.
412
+
413
+ ## Things NOT to do
414
+
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())`.
416
+ - Don't combine `.required()` and `.optional()` on the same chain — last wins, but the intent is unclear; pick one.
417
+ - Don't chain `.default("a")` with `.min(3)` and expect `"a"` to pass — the default goes through rules.
418
+ - Don't expect `.requiredIf()` to work on a standalone validator outside `v.object` — sibling resolution silently passes.
419
+ - Don't mutate a schema you handed to a Model. Default immutability protects you; opting into `.mutable` on shared schemas is asking for confusion.
422
420
 
423
421
 
424
422
  ## define-structural-shape `@warlock.js/seal/define-structural-shape/SKILL.md`
425
423
 
426
- ---
427
- name: define-structural-shape
428
- description: 'Compose `v.object` / `v.array` / `v.record` / `v.tuple` / `v.union` / `v.discriminatedUnion` / `v.lazy`. Triggers: `v.object`, `v.array`, `v.record`, `v.tuple`, `v.union`, `v.discriminatedUnion`, `v.lazy`, `ObjectValidator`; "how do I build an object schema", "dynamic-keyed record", "tagged union with discriminator", "recursive schema", "self-referencing schema"; typical import `import { v, type Infer } from "@warlock.js/seal"`. Skip: leaf primitives — `@warlock.js/seal/pick-seal-primitive/SKILL.md`; modifiers — `@warlock.js/seal/compose-seal-modifiers/SKILL.md`; standard-schema bridge — `@warlock.js/seal/bridge-standard-schema/SKILL.md`; competing libs `zod`, `valibot`.'
429
- ---
430
-
431
- # Structural validators — picking guide
432
-
433
- The five structural factories. Each composes — pass leaf primitives or other structural validators inside, infer with `Infer<typeof schema>`. **Method surface for `v.object` and `v.array` lives in `object-methods.md` and `array-methods.md`** — this file is just orientation.
434
-
435
- ## `v.object` — fixed-key records
436
-
437
- ```ts
438
- v.object({
439
- email: v.string().email(),
440
- age: v.int().min(13).optional(),
441
- role: v.literal("admin", "user", "guest"),
442
- })
443
- ```
444
-
445
- - **Required by default.** `.optional()` to opt out — the `{ isOptional: true }` brand makes the key optional in `Infer<>`.
446
- - **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).
447
- - **Schema composition.** `.extend(schema)`, `.merge(other)`, `.pick(...keys)`, `.without(...keys)`, `.partial(...keys)`, `.requiredFields(...keys)` — all in [`object-methods.md`](./object-methods.md).
448
- - **Cross-field rules** (`sameAs`, `requiredIf`, `requiredWith`) attach to fields *inside* a `v.object`. Without a parent, sibling resolution silently passes.
449
-
450
- ## `v.array` — homogeneous lists
451
-
452
- ```ts
453
- v.array(v.string()) // type: string[]
454
- v.array(userSchema) // type: User[]
455
- v.array(v.array(v.int())) // type: number[][] — recursive inference
456
- ```
457
-
458
- 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).
459
-
460
- ## `v.record` — homogeneous values, dynamic keys
461
-
462
- ```ts
463
- v.record(v.int()) // type: Record<string, number>
464
- v.record(v.object({ count: v.int() })) // type: Record<string, { count: number }>
465
- v.record() // type: Record<string, any>
466
- ```
467
-
468
- 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.
469
-
470
- ## `v.tuple` — positional types
471
-
472
- ```ts
473
- v.tuple([v.string(), v.int(), v.boolean()]) // type: [string, number, boolean]
474
- v.tuple([v.literal("ok"), v.string()]) // type: ["ok", string]
475
- ```
476
-
477
- 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]`).
478
-
479
- ## `v.union` — one of N validators (untagged)
480
-
481
- ```ts
482
- v.union([v.string(), v.int()]) // type: string | number
483
- ```
484
-
485
- 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.
486
-
487
- ## `v.discriminatedUnion` — tagged unions (recommended for objects)
488
-
489
- ```ts
490
- const email = v.object({ type: v.literal("email"), email: v.string().email() });
491
- const sms = v.object({ type: v.literal("sms"), phone: v.string() });
492
- const push = v.object({ type: v.literal("push"), deviceId: v.string() });
493
-
494
- const notif = v.discriminatedUnion("type", [email, sms, push]);
495
-
496
- type Notif = Infer<typeof notif>;
497
- // { type: "email"; email: string }
498
- // | { type: "sms"; phone: string }
499
- // | { type: "push"; deviceId: string }
500
- ```
501
-
502
- 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`:
503
-
504
- - **Precise errors.** Failures come from the matched branch, not from every branch.
505
- - **O(1) routing** instead of trial-and-error.
506
- - **Exact TS inference** — discriminated union with narrowing inside `if (x.type === "email")` blocks.
507
- - **Cleaner JSON Schema** — `oneOf` with literal discriminators; OpenAI-strict accepts it.
508
-
509
- Construction-time validation throws on:
510
-
511
- - Missing discriminator field in any branch
512
- - Non-literal discriminator (must be `v.literal(...)`)
513
- - Duplicate discriminator values across branches
514
-
515
- So misconfigurations surface at schema-build time, not runtime.
516
-
517
- ## `v.lazy` — recursive and forward-referenced schemas
518
-
519
- ```ts
520
- type Category = { name: string; children: Category[] };
521
-
522
- const categorySchema: ObjectValidator<...> = v.object({
523
- name: v.string(),
524
- children: v.array(v.lazy(() => categorySchema)),
525
- });
526
-
527
- type T = Infer<typeof categorySchema>;
528
- // { name: string; children: T[] } ← recursive type
529
- ```
530
-
531
- Defers resolution of the inner validator until validate-time. JavaScript evaluates the object literal before the `const` binding completes, so the inner reference would normally fail with `ReferenceError`. The thunk is invoked only when `validate()` runs, by which time the binding exists.
532
-
533
- **Memoised.** The thunk fires once on first use; subsequent calls reuse the cached validator.
534
-
535
- **JSON Schema caveat.** Simple-resolve in v1 — recursive shapes will infinite-loop in `toJsonSchema()`. If you need JSON Schema for a recursive shape, generate it manually with `$defs` + `$ref` until v2 lands.
536
-
537
- **TS inference requires a recursive type alias.** TS can't infer `Category = { name: string; children: Category[] }` from the schema alone — declare the type explicitly and annotate the schema variable with `ObjectValidator<...>` or similar (same pattern as Zod's `z.ZodType<Category>`).
538
-
539
- Use for: trees (categories, file systems), threaded data (comment chains), mutually recursive schemas (`A` references `B` references `A`), and forward references (a schema needs to use one defined later in the file).
540
-
541
- ## Quick map
542
-
543
- | Want | Reach for |
544
- | --- | --- |
545
- | Fixed-shape record | `v.object({...})` |
546
- | Dynamic keys, same value shape | `v.record(valueSchema)` |
547
- | List of items | `v.array(itemSchema)` |
548
- | Position-typed array | `v.tuple([a, b, c])` |
549
- | One of N scalar types | `v.union([...])` |
550
- | One of N object shapes with a tag field | `v.discriminatedUnion(key, [...])` |
551
- | Self-referencing or forward reference | `v.lazy(() => schema)` |
552
- | One of N constants | `v.literal(...values)` (not structural — see [`@warlock.js/seal/pick-seal-primitive/SKILL.md`](@warlock.js/seal/pick-seal-primitive/SKILL.md)) |
553
-
554
- ## A note on cascade Models
555
-
556
- `@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 [`@warlock.js/seal/bridge-standard-schema/SKILL.md`](@warlock.js/seal/bridge-standard-schema/SKILL.md).
557
-
558
- ## Method-surface reference
559
-
560
- - [`object-methods.md`](./object-methods.md) — `.extend` / `.merge` / `.pick` / `.without` / `.partial` / `.requiredFields` / `.allowUnknown` / `.stripUnknown` / `.allow` / `.trim`.
561
- - [`array-methods.md`](./array-methods.md) — `.minLength` / `.maxLength` / `.length` / `.between` / `.unique` / `.sorted` / `.flip` / `.sort` / `.onlyUnique`.
562
-
563
- `v.record` and `v.tuple` share the array-style length surface. `v.union` and `v.discriminatedUnion` have no chainable methods beyond what's shown above. `v.lazy` is a single-call factory.
424
+ ---
425
+ name: define-structural-shape
426
+ description: 'Compose `v.object` / `v.array` / `v.record` / `v.tuple` / `v.union` / `v.discriminatedUnion` / `v.lazy`. Triggers: `v.object`, `v.array`, `v.record`, `v.tuple`, `v.union`, `v.discriminatedUnion`, `v.lazy`, `ObjectValidator`; "how do I build an object schema", "dynamic-keyed record", "tagged union with discriminator", "recursive schema", "self-referencing schema"; typical import `import { v, type Infer } from "@warlock.js/seal"`. Skip: leaf primitives — `@warlock.js/seal/pick-seal-primitive/SKILL.md`; modifiers — `@warlock.js/seal/compose-seal-modifiers/SKILL.md`; standard-schema bridge — `@warlock.js/seal/bridge-standard-schema/SKILL.md`; competing libs `zod`, `valibot`.'
427
+ ---
428
+
429
+ # Structural validators — picking guide
430
+
431
+ The five structural factories. Each composes — pass leaf primitives or other structural validators inside, infer with `Infer<typeof schema>`. **Method surface for `v.object` and `v.array` lives in `object-methods.md` and `array-methods.md`** — this file is just orientation.
432
+
433
+ ## `v.object` — fixed-key records
434
+
435
+ ```ts
436
+ v.object({
437
+ email: v.string().email(),
438
+ age: v.int().min(13).optional(),
439
+ role: v.literal("admin", "user", "guest"),
440
+ })
441
+ ```
442
+
443
+ - **Required by default.** `.optional()` to opt out — the `{ isOptional: true }` brand makes the key optional in `Infer<>`.
444
+ - **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
+ - **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.
447
+
448
+ ## `v.array` — homogeneous lists
449
+
450
+ ```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
454
+ ```
455
+
456
+ 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).
457
+
458
+ ## `v.record` — homogeneous values, dynamic keys
459
+
460
+ ```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>
464
+ ```
465
+
466
+ 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.
467
+
468
+ ## `v.tuple` — positional types
469
+
470
+ ```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]
473
+ ```
474
+
475
+ 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
+
477
+ ## `v.union` — one of N validators (untagged)
478
+
479
+ ```ts
480
+ v.union([v.string(), v.int()]) // type: string | number
481
+ ```
482
+
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.
484
+
485
+ ## `v.discriminatedUnion` — tagged unions (recommended for objects)
486
+
487
+ ```ts
488
+ 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() });
491
+
492
+ const notif = v.discriminatedUnion("type", [email, sms, push]);
493
+
494
+ type Notif = Infer<typeof notif>;
495
+ // { type: "email"; email: string }
496
+ // | { type: "sms"; phone: string }
497
+ // | { type: "push"; deviceId: string }
498
+ ```
499
+
500
+ 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
+
502
+ - **Precise errors.** Failures come from the matched branch, not from every branch.
503
+ - **O(1) routing** instead of trial-and-error.
504
+ - **Exact TS inference** — discriminated union with narrowing inside `if (x.type === "email")` blocks.
505
+ - **Cleaner JSON Schema** — `oneOf` with literal discriminators; OpenAI-strict accepts it.
506
+
507
+ Construction-time validation throws on:
508
+
509
+ - Missing discriminator field in any branch
510
+ - Non-literal discriminator (must be `v.literal(...)`)
511
+ - Duplicate discriminator values across branches
512
+
513
+ So misconfigurations surface at schema-build time, not runtime.
514
+
515
+ ## `v.lazy` — recursive and forward-referenced schemas
516
+
517
+ ```ts
518
+ type Category = { name: string; children: Category[] };
519
+
520
+ const categorySchema: ObjectValidator<...> = v.object({
521
+ name: v.string(),
522
+ children: v.array(v.lazy(() => categorySchema)),
523
+ });
524
+
525
+ type T = Infer<typeof categorySchema>;
526
+ // { name: string; children: T[] } ← recursive type
527
+ ```
528
+
529
+ Defers resolution of the inner validator until validate-time. JavaScript evaluates the object literal before the `const` binding completes, so the inner reference would normally fail with `ReferenceError`. The thunk is invoked only when `validate()` runs, by which time the binding exists.
530
+
531
+ **Memoised.** The thunk fires once on first use; subsequent calls reuse the cached validator.
532
+
533
+ **JSON Schema caveat.** Simple-resolve in v1 — recursive shapes will infinite-loop in `toJsonSchema()`. If you need JSON Schema for a recursive shape, generate it manually with `$defs` + `$ref` until v2 lands.
534
+
535
+ **TS inference requires a recursive type alias.** TS can't infer `Category = { name: string; children: Category[] }` from the schema alone — declare the type explicitly and annotate the schema variable with `ObjectValidator<...>` or similar (same pattern as Zod's `z.ZodType<Category>`).
536
+
537
+ Use for: trees (categories, file systems), threaded data (comment chains), mutually recursive schemas (`A` references `B` references `A`), and forward references (a schema needs to use one defined later in the file).
538
+
539
+ ## Quick map
540
+
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 [`@warlock.js/seal/pick-seal-primitive/SKILL.md`](@warlock.js/seal/pick-seal-primitive/SKILL.md)) |
551
+
552
+ ## A note on cascade Models
553
+
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 [`@warlock.js/seal/bridge-standard-schema/SKILL.md`](@warlock.js/seal/bridge-standard-schema/SKILL.md).
555
+
556
+ ## Method-surface reference
557
+
558
+ - [`object-methods.md`](./object-methods.md) — `.extend` / `.merge` / `.pick` / `.without` / `.partial` / `.requiredFields` / `.allowUnknown` / `.stripUnknown` / `.allow` / `.trim`.
559
+ - [`array-methods.md`](./array-methods.md) — `.minLength` / `.maxLength` / `.length` / `.between` / `.unique` / `.sorted` / `.flip` / `.sort` / `.onlyUnique`.
560
+
561
+ `v.record` and `v.tuple` share the array-style length surface. `v.union` and `v.discriminatedUnion` have no chainable methods beyond what's shown above. `v.lazy` is a single-call factory.
564
562
 
565
563
 
566
564
  ### define-structural-shape/array-methods.md `@warlock.js/seal/define-structural-shape/array-methods.md`
@@ -749,8 +747,8 @@ The default is to **reject** unknown keys, which is what you want for inbound pa
749
747
 
750
748
  ```ts
751
749
  const publicArticle = v.object({
752
- id: v.string().required(),
753
- title: v.string().required(),
750
+ id: v.string(),
751
+ title: v.string(),
754
752
  }).stripUnknown();
755
753
 
756
754
  const { isValid, data } = await v.validate(publicArticle, record);
@@ -837,483 +835,483 @@ const signupSchema = v.object({
837
835
 
838
836
  ## extend-seal-with-plugins `@warlock.js/seal/extend-seal-with-plugins/SKILL.md`
839
837
 
840
- ---
841
- name: extend-seal-with-plugins
842
- description: 'Author a custom seal plugin to add validator methods (`.slug`, `.postalCode`, etc) — `SealPlugin` shape, `registerPlugin` lifecycle, TS prototype augmentation. Triggers: `SealPlugin`, `registerPlugin`, `unregisterPlugin`, `hasPlugin`, `getInstalledPlugins`, `StringValidator`, `NumberValidator`, `DateValidator`, `BaseValidator`, `install`, `uninstall`; "how do I add a custom validator method", "write a seal plugin", "extend v.string with .slug", "module augmentation for seal"; typical import `import { StringValidator, registerPlugin, type SealPlugin } from "@warlock.js/seal"`. Skip: built-in primitives — `@warlock.js/seal/pick-seal-primitive/SKILL.md`; bridge typing — `@warlock.js/seal/bridge-standard-schema/SKILL.md`; competing `zod` `.refine`/`.transform`.'
843
- ---
844
-
845
- # Extend seal with plugins
846
-
847
- Seal exposes a plugin system so you can add validator methods without forking the package. A plugin is an object with `name`, optional `version`/`description`, and an `install` function that runs once when the plugin is registered. The standard pattern is to `Object.assign(StringValidator.prototype, { ... })` to graft new methods onto a validator class.
848
-
849
- ## When to reach for a plugin
850
-
851
- - The validator method you want **does not exist** in built-in seal — check [`@warlock.js/seal/pick-seal-primitive/SKILL.md`](@warlock.js/seal/pick-seal-primitive/SKILL.md) and its method references first.
852
- - The validation is **stable and reusable** across modules — domain-specific formats (IBAN, postal codes, tax IDs, license plates, internal ID schemes).
853
- - You want the **chainable syntax** — `v.string().slug()` reads better than `v.string().pattern(/.../).addMutator(s => slugify(s))` at every call site.
854
-
855
- **Don't** reach for a plugin when a one-off `.pattern()` would do. The boilerplate (declare module, register on boot) is justified only when you'll call the new method many times.
856
-
857
- ## The plugin shape
858
-
859
- ```ts
860
- import type { SealPlugin } from "@warlock.js/seal";
861
-
862
- type SealPlugin = {
863
- name: string; // unique identifier — duplicates warn and skip install
864
- version?: string;
865
- description?: string;
866
- install: (context: { name: string; version?: string }) => void | Promise<void>;
867
- uninstall?: () => void | Promise<void>;
868
- };
869
- ```
870
-
871
- The `install` function is where you add methods. Typically you patch a validator class prototype:
872
-
873
- ```ts
874
- import { StringValidator, type SealPlugin } from "@warlock.js/seal";
875
-
876
- export const slugPlugin: SealPlugin = {
877
- name: "slug",
878
- version: "1.0.0",
879
- description: "Adds .slug() — pattern-only slug validation",
880
-
881
- install() {
882
- Object.assign(StringValidator.prototype, {
883
- slug(this: StringValidator, errorMessage?: string) {
884
- return this.pattern(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, errorMessage);
885
- },
886
- });
887
- },
888
- };
889
- ```
890
-
891
- `uninstall` is optional. Provide it when your plugin needs to clean up — e.g. removing prototype methods for hot-reload scenarios. Most production plugins skip it; methods grafted at boot stay for the process lifetime.
892
-
893
- ## Registering plugins
894
-
895
- ```ts
896
- import { registerPlugin, unregisterPlugin, hasPlugin, getInstalledPlugins } from "@warlock.js/seal";
897
-
898
- await registerPlugin(slugPlugin);
899
- // console.warn if "slug" is already installed; otherwise install() runs and the plugin is tracked.
900
-
901
- hasPlugin("slug"); // → true
902
- getInstalledPlugins(); // → [slugPlugin]
903
-
904
- await unregisterPlugin("slug");
905
- // runs slugPlugin.uninstall?.(); removes from registry.
906
- ```
907
-
908
- `registerPlugin` is async (the `install` function may be async). Await it during boot so the methods are available before the first request.
909
-
910
- **Where to register:** the conventional place in a Warlock app is a side-effect file loaded by `warlock.config.ts` — e.g. `src/setup/seal-plugins.ts`:
911
-
912
- ```ts title="src/setup/seal-plugins.ts"
913
- import { registerPlugin } from "@warlock.js/seal";
914
- import { slugPlugin } from "./plugins/slug-plugin";
915
- import { postalCodePlugin } from "./plugins/postal-code-plugin";
916
-
917
- export async function setupSealPlugins() {
918
- await registerPlugin(slugPlugin);
919
- await registerPlugin(postalCodePlugin);
920
- }
921
- ```
922
-
923
- Then call `setupSealPlugins()` in a bootstrap connector. Registering at top-level module scope works too (it's idempotent — duplicates warn and skip), but explicit setup is clearer.
924
-
925
- ## TypeScript — declare the new methods
926
-
927
- `Object.assign` on a prototype is invisible to TypeScript. Declare the new methods with module augmentation so call sites compile:
928
-
929
- ```ts title="src/setup/seal-plugins.types.ts"
930
- import "@warlock.js/seal";
931
-
932
- declare module "@warlock.js/seal" {
933
- interface StringValidator {
934
- /** Pattern-only slug — `"hello-world"`, not `"Hello World"`. */
935
- slug(errorMessage?: string): StringValidator;
936
- }
937
- }
938
- ```
939
-
940
- After this file is included in the project's `tsconfig.json` (via `include` or a side-effect import), `v.string().slug()` autocompletes and type-checks everywhere.
941
-
942
- **Important.** The augmentation has to declare on the **class** (`StringValidator`), not the factory return type. The factory return widens with `& StandardSchemaV1<...>` (see [`@warlock.js/seal/bridge-standard-schema/SKILL.md`](@warlock.js/seal/bridge-standard-schema/SKILL.md)) — augmentations on the intersection don't propagate. Patch the class, augment the class; the factory return picks up the new methods through structural inference.
943
-
944
- ## Larger example — postal codes per country
945
-
946
- ```ts
947
- import { StringValidator, type SealPlugin } from "@warlock.js/seal";
948
-
949
- const PATTERNS: Record<string, RegExp> = {
950
- US: /^\d{5}(?:-\d{4})?$/,
951
- DE: /^\d{5}$/,
952
- UK: /^[A-Z]{1,2}\d[A-Z\d]? \d[A-Z]{2}$/i,
953
- EG: /^\d{5}$/,
954
- };
955
-
956
- export const postalCodePlugin: SealPlugin = {
957
- name: "postal-code",
958
-
959
- install() {
960
- Object.assign(StringValidator.prototype, {
961
- postalCode(this: StringValidator, country: keyof typeof PATTERNS, errorMessage?: string) {
962
- const pattern = PATTERNS[country];
963
-
964
- if (!pattern) {
965
- throw new Error(`postalCode: unknown country "${country}"`);
966
- }
967
-
968
- return this.pattern(pattern, errorMessage ?? `Invalid ${country} postal code`);
969
- },
970
- });
971
- },
972
- };
973
- ```
974
-
975
- ```ts
976
- // Module augmentation
977
- declare module "@warlock.js/seal" {
978
- interface StringValidator {
979
- postalCode(country: "US" | "DE" | "UK" | "EG", errorMessage?: string): StringValidator;
980
- }
981
- }
982
-
983
- // Use site
984
- const addressSchema = v.object({
985
- country: v.literal("US", "DE", "UK", "EG"),
986
- postal: v.string().postalCode("DE"),
987
- });
988
- ```
989
-
990
- ## Patterns beyond `StringValidator`
991
-
992
- The same approach works on any validator class. Pick the right prototype:
993
-
994
- - `StringValidator` — string methods (`.slug`, `.postalCode`, `.licensePlate`).
995
- - `NumberValidator` / `IntValidator` / `FloatValidator` — number methods.
996
- - `DateValidator` — date methods (`.businessDayInCountry("US")`).
997
- - `ArrayValidator` / `ObjectValidator` — structural methods (rarer).
998
- - `BaseValidator` — universal methods (rare — usually a sign you want a separate `v.something()` factory instead).
999
-
1000
- For a method that creates a **new validator** (not chained from an existing one), expose it as a regular function alongside `v` rather than patching the factory. E.g. export `iban()` from your plugin module that returns a configured `v.string()`.
1001
-
1002
- ## Introspection — checking what's loaded
1003
-
1004
- ```ts
1005
- hasPlugin("slug"); // boolean
1006
- getInstalledPlugins(); // SealPlugin[]
1007
- ```
1008
-
1009
- Use these in startup diagnostics or in tests that need to assert a plugin is registered before exercising a method that depends on it.
1010
-
1011
- ## Things NOT to do
1012
-
1013
- - Don't `Object.assign(BaseValidator.prototype, ...)` for type-specific methods. The method would exist on every validator (`v.boolean().slug()` typechecks but breaks at runtime). Patch the narrowest class that owns the method.
1014
- - Don't forget the module augmentation. Without it, the new methods exist at runtime but TS rejects every call site.
1015
- - Don't make the plugin's `install` function depend on shared state mutable from elsewhere. Plugins should be idempotent — installing twice (or registering across hot-reloads) should not break anything.
1016
- - 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.
1017
- - 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.
838
+ ---
839
+ name: extend-seal-with-plugins
840
+ description: 'Author a custom seal plugin to add validator methods (`.slug`, `.postalCode`, etc) — `SealPlugin` shape, `registerPlugin` lifecycle, TS prototype augmentation. Triggers: `SealPlugin`, `registerPlugin`, `unregisterPlugin`, `hasPlugin`, `getInstalledPlugins`, `StringValidator`, `NumberValidator`, `DateValidator`, `BaseValidator`, `install`, `uninstall`; "how do I add a custom validator method", "write a seal plugin", "extend v.string with .slug", "module augmentation for seal"; typical import `import { StringValidator, registerPlugin, type SealPlugin } from "@warlock.js/seal"`. Skip: built-in primitives — `@warlock.js/seal/pick-seal-primitive/SKILL.md`; bridge typing — `@warlock.js/seal/bridge-standard-schema/SKILL.md`; competing `zod` `.refine`/`.transform`.'
841
+ ---
842
+
843
+ # Extend seal with plugins
844
+
845
+ Seal exposes a plugin system so you can add validator methods without forking the package. A plugin is an object with `name`, optional `version`/`description`, and an `install` function that runs once when the plugin is registered. The standard pattern is to `Object.assign(StringValidator.prototype, { ... })` to graft new methods onto a validator class.
846
+
847
+ ## When to reach for a plugin
848
+
849
+ - The validator method you want **does not exist** in built-in seal — check [`@warlock.js/seal/pick-seal-primitive/SKILL.md`](@warlock.js/seal/pick-seal-primitive/SKILL.md) and its method references first.
850
+ - The validation is **stable and reusable** across modules — domain-specific formats (IBAN, postal codes, tax IDs, license plates, internal ID schemes).
851
+ - You want the **chainable syntax** — `v.string().slug()` reads better than `v.string().pattern(/.../).addMutator(s => slugify(s))` at every call site.
852
+
853
+ **Don't** reach for a plugin when a one-off `.pattern()` would do. The boilerplate (declare module, register on boot) is justified only when you'll call the new method many times.
854
+
855
+ ## The plugin shape
856
+
857
+ ```ts
858
+ import type { SealPlugin } from "@warlock.js/seal";
859
+
860
+ type SealPlugin = {
861
+ name: string; // unique identifier — duplicates warn and skip install
862
+ version?: string;
863
+ description?: string;
864
+ install: (context: { name: string; version?: string }) => void | Promise<void>;
865
+ uninstall?: () => void | Promise<void>;
866
+ };
867
+ ```
868
+
869
+ The `install` function is where you add methods. Typically you patch a validator class prototype:
870
+
871
+ ```ts
872
+ import { StringValidator, type SealPlugin } from "@warlock.js/seal";
873
+
874
+ export const slugPlugin: SealPlugin = {
875
+ name: "slug",
876
+ version: "1.0.0",
877
+ description: "Adds .slug() — pattern-only slug validation",
878
+
879
+ install() {
880
+ Object.assign(StringValidator.prototype, {
881
+ slug(this: StringValidator, errorMessage?: string) {
882
+ return this.pattern(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, errorMessage);
883
+ },
884
+ });
885
+ },
886
+ };
887
+ ```
888
+
889
+ `uninstall` is optional. Provide it when your plugin needs to clean up — e.g. removing prototype methods for hot-reload scenarios. Most production plugins skip it; methods grafted at boot stay for the process lifetime.
890
+
891
+ ## Registering plugins
892
+
893
+ ```ts
894
+ import { registerPlugin, unregisterPlugin, hasPlugin, getInstalledPlugins } from "@warlock.js/seal";
895
+
896
+ await registerPlugin(slugPlugin);
897
+ // console.warn if "slug" is already installed; otherwise install() runs and the plugin is tracked.
898
+
899
+ hasPlugin("slug"); // → true
900
+ getInstalledPlugins(); // → [slugPlugin]
901
+
902
+ await unregisterPlugin("slug");
903
+ // runs slugPlugin.uninstall?.(); removes from registry.
904
+ ```
905
+
906
+ `registerPlugin` is async (the `install` function may be async). Await it during boot so the methods are available before the first request.
907
+
908
+ **Where to register:** the conventional place in a Warlock app is a side-effect file loaded by `warlock.config.ts` — e.g. `src/setup/seal-plugins.ts`:
909
+
910
+ ```ts title="src/setup/seal-plugins.ts"
911
+ import { registerPlugin } from "@warlock.js/seal";
912
+ import { slugPlugin } from "./plugins/slug-plugin";
913
+ import { postalCodePlugin } from "./plugins/postal-code-plugin";
914
+
915
+ export async function setupSealPlugins() {
916
+ await registerPlugin(slugPlugin);
917
+ await registerPlugin(postalCodePlugin);
918
+ }
919
+ ```
920
+
921
+ Then call `setupSealPlugins()` in a bootstrap connector. Registering at top-level module scope works too (it's idempotent — duplicates warn and skip), but explicit setup is clearer.
922
+
923
+ ## TypeScript — declare the new methods
924
+
925
+ `Object.assign` on a prototype is invisible to TypeScript. Declare the new methods with module augmentation so call sites compile:
926
+
927
+ ```ts title="src/setup/seal-plugins.types.ts"
928
+ import "@warlock.js/seal";
929
+
930
+ declare module "@warlock.js/seal" {
931
+ interface StringValidator {
932
+ /** Pattern-only slug — `"hello-world"`, not `"Hello World"`. */
933
+ slug(errorMessage?: string): StringValidator;
934
+ }
935
+ }
936
+ ```
937
+
938
+ After this file is included in the project's `tsconfig.json` (via `include` or a side-effect import), `v.string().slug()` autocompletes and type-checks everywhere.
939
+
940
+ **Important.** The augmentation has to declare on the **class** (`StringValidator`), not the factory return type. The factory return widens with `& StandardSchemaV1<...>` (see [`@warlock.js/seal/bridge-standard-schema/SKILL.md`](@warlock.js/seal/bridge-standard-schema/SKILL.md)) — augmentations on the intersection don't propagate. Patch the class, augment the class; the factory return picks up the new methods through structural inference.
941
+
942
+ ## Larger example — postal codes per country
943
+
944
+ ```ts
945
+ import { StringValidator, type SealPlugin } from "@warlock.js/seal";
946
+
947
+ const PATTERNS: Record<string, RegExp> = {
948
+ US: /^\d{5}(?:-\d{4})?$/,
949
+ DE: /^\d{5}$/,
950
+ UK: /^[A-Z]{1,2}\d[A-Z\d]? \d[A-Z]{2}$/i,
951
+ EG: /^\d{5}$/,
952
+ };
953
+
954
+ export const postalCodePlugin: SealPlugin = {
955
+ name: "postal-code",
956
+
957
+ install() {
958
+ Object.assign(StringValidator.prototype, {
959
+ postalCode(this: StringValidator, country: keyof typeof PATTERNS, errorMessage?: string) {
960
+ const pattern = PATTERNS[country];
961
+
962
+ if (!pattern) {
963
+ throw new Error(`postalCode: unknown country "${country}"`);
964
+ }
965
+
966
+ return this.pattern(pattern, errorMessage ?? `Invalid ${country} postal code`);
967
+ },
968
+ });
969
+ },
970
+ };
971
+ ```
972
+
973
+ ```ts
974
+ // Module augmentation
975
+ declare module "@warlock.js/seal" {
976
+ interface StringValidator {
977
+ postalCode(country: "US" | "DE" | "UK" | "EG", errorMessage?: string): StringValidator;
978
+ }
979
+ }
980
+
981
+ // Use site
982
+ const addressSchema = v.object({
983
+ country: v.literal("US", "DE", "UK", "EG"),
984
+ postal: v.string().postalCode("DE"),
985
+ });
986
+ ```
987
+
988
+ ## Patterns beyond `StringValidator`
989
+
990
+ The same approach works on any validator class. Pick the right prototype:
991
+
992
+ - `StringValidator` — string methods (`.slug`, `.postalCode`, `.licensePlate`).
993
+ - `NumberValidator` / `IntValidator` / `FloatValidator` — number methods.
994
+ - `DateValidator` — date methods (`.businessDayInCountry("US")`).
995
+ - `ArrayValidator` / `ObjectValidator` — structural methods (rarer).
996
+ - `BaseValidator` — universal methods (rare — usually a sign you want a separate `v.something()` factory instead).
997
+
998
+ For a method that creates a **new validator** (not chained from an existing one), expose it as a regular function alongside `v` rather than patching the factory. E.g. export `iban()` from your plugin module that returns a configured `v.string()`.
999
+
1000
+ ## Introspection — checking what's loaded
1001
+
1002
+ ```ts
1003
+ hasPlugin("slug"); // boolean
1004
+ getInstalledPlugins(); // SealPlugin[]
1005
+ ```
1006
+
1007
+ Use these in startup diagnostics or in tests that need to assert a plugin is registered before exercising a method that depends on it.
1008
+
1009
+ ## Things NOT to do
1010
+
1011
+ - Don't `Object.assign(BaseValidator.prototype, ...)` for type-specific methods. The method would exist on every validator (`v.boolean().slug()` typechecks but breaks at runtime). Patch the narrowest class that owns the method.
1012
+ - Don't forget the module augmentation. Without it, the new methods exist at runtime but TS rejects every call site.
1013
+ - Don't make the plugin's `install` function depend on shared state mutable from elsewhere. Plugins should be idempotent — installing twice (or registering across hot-reloads) should not break anything.
1014
+ - 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
+ - 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.
1018
1016
 
1019
1017
 
1020
1018
  ## generate-json-schema `@warlock.js/seal/generate-json-schema/SKILL.md`
1021
1019
 
1022
- ---
1023
- name: generate-json-schema
1024
- description: 'Generate JSON Schema via `schema.toJsonSchema(target)` — `draft-2020-12` / `draft-07` / `openapi-3.0` / `openai-strict`. Triggers: `toJsonSchema`, `JsonSchemaTarget`, `draft-2020-12`, `draft-07`, `openapi-3.0`, `openai-strict`, `response_format`, `additionalProperties`, `nullable`; "how do I generate JSON Schema from seal", "OpenAI structured outputs from schema", "OpenAPI 3.0 nullable", "json_schema strict mode"; typical import `import { v } from "@warlock.js/seal"`. Skip: foundations — `@warlock.js/seal/seal-basics/SKILL.md`; bridge typing — `@warlock.js/seal/bridge-standard-schema/SKILL.md`; competing libs `zod-to-json-schema`, `ajv`, `@anatine/zod-openapi`.'
1025
- ---
1026
-
1027
- # JSON Schema generation
1028
-
1029
- Every seal validator exposes `toJsonSchema(target)`. The result is a plain object — pass it straight to OpenAI's `response_format`, an OpenAPI spec, a UI form builder, or anywhere else JSON Schema is the contract.
1030
-
1031
- ```ts
1032
- const userSchema = v.object({
1033
- email: v.string().email(),
1034
- age: v.int().min(13).optional(),
1035
- });
1036
-
1037
- userSchema.toJsonSchema("draft-2020-12");
1038
- // {
1039
- // type: "object",
1040
- // properties: {
1041
- // email: { type: "string", format: "email" },
1042
- // age: { type: "integer", minimum: 13 },
1043
- // },
1044
- // required: ["email"],
1045
- // additionalProperties: false,
1046
- // }
1047
- ```
1048
-
1049
- ## The four targets
1050
-
1051
- ```ts
1052
- type JsonSchemaTarget =
1053
- | "draft-2020-12" // default — modern JSON Schema
1054
- | "draft-07" // older tooling, Swagger 2.0
1055
- | "openapi-3.0" // uses { nullable: true } instead of type unions
1056
- | "openai-strict" // OpenAI Structured Outputs strict mode
1057
- ```
1058
-
1059
- Pick by consumer:
1060
-
1061
- | Consumer | Target |
1062
- | --- | --- |
1063
- | Modern tooling, no specific reason otherwise | `"draft-2020-12"` |
1064
- | Swagger 2.0 / older OpenAPI / older form builders | `"draft-07"` |
1065
- | OpenAPI 3.0 spec (uses `nullable: true`) | `"openapi-3.0"` |
1066
- | OpenAI `response_format: { type: "json_schema", strict: true }` | `"openai-strict"` |
1067
-
1068
- ## OpenAI structured outputs (`openai-strict`)
1069
-
1070
- This target encodes the quirks of OpenAI's strict mode:
1071
-
1072
- - **Every field listed in `required`** — strict mode forbids leaving fields out.
1073
- - **Optional fields encoded as `type: ["T", "null"]`** instead of being omitted from `required`.
1074
- - **`additionalProperties: false` on every object.**
1075
-
1076
- ```ts
1077
- const schema = v.object({
1078
- reply: v.string(),
1079
- citations: v.array(v.string()).optional(),
1080
- });
1081
-
1082
- schema.toJsonSchema("openai-strict");
1083
- // {
1084
- // type: "object",
1085
- // properties: {
1086
- // reply: { type: "string" },
1087
- // citations: { type: ["array", "null"], items: { type: "string" } },
1088
- // },
1089
- // required: ["reply", "citations"], // every field listed
1090
- // additionalProperties: false,
1091
- // }
1092
- ```
1093
-
1094
- Hand to OpenAI:
1095
-
1096
- ```ts
1097
- import OpenAI from "openai";
1098
-
1099
- const completion = await openai.chat.completions.create({
1100
- model: "gpt-4o",
1101
- messages: [...],
1102
- response_format: {
1103
- type: "json_schema",
1104
- json_schema: {
1105
- name: "user_reply",
1106
- strict: true,
1107
- schema: schema.toJsonSchema("openai-strict"),
1108
- },
1109
- },
1110
- });
1111
- ```
1112
-
1113
- In `@warlock.js/ai`, this happens automatically when you set `output: schema` on a supervisor / agent — the runtime picks `openai-strict` for OpenAI providers. You only call `toJsonSchema()` directly when integrating with a non-warlock OpenAI usage.
1114
-
1115
- ## OpenAPI 3.0 nullable
1116
-
1117
- ```ts
1118
- v.string().nullable().toJsonSchema("openapi-3.0");
1119
- // { type: "string", nullable: true }
1120
-
1121
- v.string().nullable().toJsonSchema("draft-2020-12");
1122
- // { type: ["string", "null"] }
1123
- ```
1124
-
1125
- OpenAPI 3.0 uses the boolean `nullable` keyword instead of a type union. Use this target when generating a `paths.openapi.yaml` consumed by Swagger UI or codegen tools.
1126
-
1127
- ## What's representable
1128
-
1129
- Cleanly mapped:
1130
-
1131
- - `v.string()` — `{ type: "string" }` (with `format: email/url/uuid`, `pattern`, `minLength`, `maxLength`, `enum`)
1132
- - `v.int()` / `v.float()` — `{ type: "integer" | "number" }` (with `minimum`, `maximum`, `multipleOf`)
1133
- - `v.boolean()` — `{ type: "boolean" }`
1134
- - `v.date()` — `{ type: "string", format: "date-time" | "date" | "time" }` (format derived from transformer if applicable)
1135
- - `v.literal(values)` — `{ const: value }` (single) or `{ enum: [...] }` (multiple)
1136
- - `v.array(item)` — `{ type: "array", items: ... }` (with `minItems`, `maxItems` from length rules; `.unique()`/`.sorted()` are runtime-only and not emitted)
1137
- - `v.object({...})` — `{ type: "object", properties, required, additionalProperties }`
1138
- - `v.union([...])` — `{ oneOf: [...] }`
1139
- - `v.tuple([...])` — `{ type: "array", prefixItems: [...] }` (draft-2020-12) or `{ type: "array", items: [...] }` (draft-07)
1140
- - `v.nullable()` — type union or `nullable: true` per target
1141
-
1142
- ## What's silently dropped
1143
-
1144
- Some seal constructs have no JSON Schema representation:
1145
-
1146
- - **Cross-field rules** (`sameAs`, `requiredIf`, `requiredWith`, etc.) — runtime-only. The generated schema describes the *shape*, not the inter-field invariants.
1147
- - **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`).
1148
- - **`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.)
1149
- - **`v.instanceof(Ctor)`** — produces `{}`. Class identity isn't expressible. For `File`, attach `{ type: "string", format: "binary" }` manually after generation if needed for OpenAPI.
1150
- - **`v.any()`** — produces `{}` deliberately (any value is valid).
1151
-
1152
- Boolean rules `accepted` / `declined` and similar coercion-style rules are also dropped — JSON Schema doesn't have a notion of "yes/no/on/off" beyond `enum`.
1153
-
1154
- ## When the generated schema rejects valid data
1155
-
1156
- If the schema validator itself accepts data but the *generated* JSON Schema rejects the same data downstream, the cause is usually one of:
1157
-
1158
- - **Cross-field rule.** The generated schema doesn't enforce it, but a separate consumer might. (Or the runtime check fired at a different stage.)
1159
- - **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.
1160
- - **`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.
1161
-
1162
- ## Cost note
1163
-
1164
- Generating JSON Schema is cheap (pure-function tree walk), so don't worry about caching the result for schemas that change at startup. For dynamic schemas built per-request, generate per-request — there's no shared mutable state.
1020
+ ---
1021
+ name: generate-json-schema
1022
+ description: 'Generate JSON Schema via `schema.toJsonSchema(target)` — `draft-2020-12` / `draft-07` / `openapi-3.0` / `openai-strict`. Triggers: `toJsonSchema`, `JsonSchemaTarget`, `draft-2020-12`, `draft-07`, `openapi-3.0`, `openai-strict`, `response_format`, `additionalProperties`, `nullable`; "how do I generate JSON Schema from seal", "OpenAI structured outputs from schema", "OpenAPI 3.0 nullable", "json_schema strict mode"; typical import `import { v } from "@warlock.js/seal"`. Skip: foundations — `@warlock.js/seal/seal-basics/SKILL.md`; bridge typing — `@warlock.js/seal/bridge-standard-schema/SKILL.md`; competing libs `zod-to-json-schema`, `ajv`, `@anatine/zod-openapi`.'
1023
+ ---
1024
+
1025
+ # JSON Schema generation
1026
+
1027
+ Every seal validator exposes `toJsonSchema(target)`. The result is a plain object — pass it straight to OpenAI's `response_format`, an OpenAPI spec, a UI form builder, or anywhere else JSON Schema is the contract.
1028
+
1029
+ ```ts
1030
+ const userSchema = v.object({
1031
+ email: v.string().email(),
1032
+ age: v.int().min(13).optional(),
1033
+ });
1034
+
1035
+ userSchema.toJsonSchema("draft-2020-12");
1036
+ // {
1037
+ // type: "object",
1038
+ // properties: {
1039
+ // email: { type: "string", format: "email" },
1040
+ // age: { type: "integer", minimum: 13 },
1041
+ // },
1042
+ // required: ["email"],
1043
+ // additionalProperties: false,
1044
+ // }
1045
+ ```
1046
+
1047
+ ## The four targets
1048
+
1049
+ ```ts
1050
+ 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
1055
+ ```
1056
+
1057
+ Pick by consumer:
1058
+
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"` |
1064
+ | OpenAI `response_format: { type: "json_schema", strict: true }` | `"openai-strict"` |
1065
+
1066
+ ## OpenAI structured outputs (`openai-strict`)
1067
+
1068
+ This target encodes the quirks of OpenAI's strict mode:
1069
+
1070
+ - **Every field listed in `required`** — strict mode forbids leaving fields out.
1071
+ - **Optional fields encoded as `type: ["T", "null"]`** instead of being omitted from `required`.
1072
+ - **`additionalProperties: false` on every object.**
1073
+
1074
+ ```ts
1075
+ const schema = v.object({
1076
+ reply: v.string(),
1077
+ citations: v.array(v.string()).optional(),
1078
+ });
1079
+
1080
+ schema.toJsonSchema("openai-strict");
1081
+ // {
1082
+ // type: "object",
1083
+ // properties: {
1084
+ // reply: { type: "string" },
1085
+ // citations: { type: ["array", "null"], items: { type: "string" } },
1086
+ // },
1087
+ // required: ["reply", "citations"], // every field listed
1088
+ // additionalProperties: false,
1089
+ // }
1090
+ ```
1091
+
1092
+ Hand to OpenAI:
1093
+
1094
+ ```ts
1095
+ import OpenAI from "openai";
1096
+
1097
+ const completion = await openai.chat.completions.create({
1098
+ model: "gpt-4o",
1099
+ messages: [...],
1100
+ response_format: {
1101
+ type: "json_schema",
1102
+ json_schema: {
1103
+ name: "user_reply",
1104
+ strict: true,
1105
+ schema: schema.toJsonSchema("openai-strict"),
1106
+ },
1107
+ },
1108
+ });
1109
+ ```
1110
+
1111
+ In `@warlock.js/ai`, this happens automatically when you set `output: schema` on a supervisor / agent — the runtime picks `openai-strict` for OpenAI providers. You only call `toJsonSchema()` directly when integrating with a non-warlock OpenAI usage.
1112
+
1113
+ ## OpenAPI 3.0 nullable
1114
+
1115
+ ```ts
1116
+ v.string().nullable().toJsonSchema("openapi-3.0");
1117
+ // { type: "string", nullable: true }
1118
+
1119
+ v.string().nullable().toJsonSchema("draft-2020-12");
1120
+ // { type: ["string", "null"] }
1121
+ ```
1122
+
1123
+ OpenAPI 3.0 uses the boolean `nullable` keyword instead of a type union. Use this target when generating a `paths.openapi.yaml` consumed by Swagger UI or codegen tools.
1124
+
1125
+ ## What's representable
1126
+
1127
+ Cleanly mapped:
1128
+
1129
+ - `v.string()` — `{ type: "string" }` (with `format: email/url/uuid`, `pattern`, `minLength`, `maxLength`, `enum`)
1130
+ - `v.int()` / `v.float()` — `{ type: "integer" | "number" }` (with `minimum`, `maximum`, `multipleOf`)
1131
+ - `v.boolean()` — `{ type: "boolean" }`
1132
+ - `v.date()` — `{ type: "string", format: "date-time" | "date" | "time" }` (format derived from transformer if applicable)
1133
+ - `v.literal(values)` — `{ const: value }` (single) or `{ enum: [...] }` (multiple)
1134
+ - `v.array(item)` — `{ type: "array", items: ... }` (with `minItems`, `maxItems` from length rules; `.unique()`/`.sorted()` are runtime-only and not emitted)
1135
+ - `v.object({...})` — `{ type: "object", properties, required, additionalProperties }`
1136
+ - `v.union([...])` — `{ oneOf: [...] }`
1137
+ - `v.tuple([...])` — `{ type: "array", prefixItems: [...] }` (draft-2020-12) or `{ type: "array", items: [...] }` (draft-07)
1138
+ - `v.nullable()` — type union or `nullable: true` per target
1139
+
1140
+ ## What's silently dropped
1141
+
1142
+ Some seal constructs have no JSON Schema representation:
1143
+
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`).
1146
+ - **`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
+ - **`v.instanceof(Ctor)`** — produces `{}`. Class identity isn't expressible. For `File`, attach `{ type: "string", format: "binary" }` manually after generation if needed for OpenAPI.
1148
+ - **`v.any()`** — produces `{}` deliberately (any value is valid).
1149
+
1150
+ Boolean rules `accepted` / `declined` and similar coercion-style rules are also dropped — JSON Schema doesn't have a notion of "yes/no/on/off" beyond `enum`.
1151
+
1152
+ ## When the generated schema rejects valid data
1153
+
1154
+ If the schema validator itself accepts data but the *generated* JSON Schema rejects the same data downstream, the cause is usually one of:
1155
+
1156
+ - **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.
1158
+ - **`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
+
1160
+ ## Cost note
1161
+
1162
+ Generating JSON Schema is cheap (pure-function tree walk), so don't worry about caching the result for schemas that change at startup. For dynamic schemas built per-request, generate per-request — there's no shared mutable state.
1165
1163
 
1166
1164
 
1167
1165
  ## handle-seal-errors `@warlock.js/seal/handle-seal-errors/SKILL.md`
1168
1166
 
1169
- ---
1170
- name: handle-seal-errors
1171
- description: 'Read `ValidationResult` — `isValid`, `errors[]`, `data`. Branch on `error.type`, customize messages, hook translation. Triggers: `ValidationResult`, `ValidationError`, `validate`, `isValid`, `errors`, `data`, `error.type`, `error.input`, `error.error`, `translationParams`, `.attribute`, `SealConfig`; "how to read seal errors", "branch on a specific rule failure", "customize validation error message", "translate seal error", "surface validation errors as 422"; typical import `import { validate, v } from "@warlock.js/seal"`. Skip: modifiers — `@warlock.js/seal/compose-seal-modifiers/SKILL.md`; structural shapes — `@warlock.js/seal/define-structural-shape/SKILL.md`; competing libs `zod` `.safeParse`, `yup` `ValidationError`, `joi`.'
1172
- ---
1173
-
1174
- # `ValidationResult` — reading errors
1175
-
1176
- `validate(schema, data)` never throws. It returns a `ValidationResult`:
1177
-
1178
- ```ts
1179
- type ValidationResult = {
1180
- isValid: boolean;
1181
- data: any; // shape after mutators + transformers (the validated value)
1182
- errors: ValidationError[];
1183
- };
1184
-
1185
- type ValidationError = {
1186
- type: string; // rule type that failed — "required", "email", "min", "string", ...
1187
- error: string; // resolved message (translated, with attribute substitution)
1188
- input: string; // input field name — "email" or "address.city"
1189
- };
1190
- ```
1191
-
1192
- Branch on `isValid` first; reach for `errors[]` only when you need to act on a specific failure.
1193
-
1194
- ## Basic flow
1195
-
1196
- ```ts
1197
- import { validate, v } from "@warlock.js/seal";
1198
-
1199
- const schema = v.object({
1200
- email: v.string().email(),
1201
- age: v.int().min(13).optional(),
1202
- });
1203
-
1204
- const result = await validate(schema, input);
1205
-
1206
- if (result.isValid) {
1207
- return result.data; // typed as the inferred shape (post-transformer)
1208
- }
1209
-
1210
- return {
1211
- status: 422,
1212
- body: { errors: result.errors },
1213
- };
1214
- ```
1215
-
1216
- ## Branching on a specific rule
1217
-
1218
- ```ts
1219
- const result = await validate(schema, input);
1220
-
1221
- if (!result.isValid) {
1222
- const emailMissing = result.errors.find(
1223
- e => e.input === "email" && e.type === "required",
1224
- );
1225
- if (emailMissing) {
1226
- return { redirect: "/signup", reason: "no email" };
1227
- }
1228
-
1229
- const ageInvalid = result.errors.find(
1230
- e => e.input === "age" && (e.type === "int" || e.type === "min"),
1231
- );
1232
- if (ageInvalid) {
1233
- return { error: "Age must be 13 or older" };
1234
- }
1235
- }
1236
- ```
1237
-
1238
- The `type` field is the **stable** identifier — the message is human-facing and may be localized. Branch on `type`, never on the message string.
1239
-
1240
- ## Common rule type names
1241
-
1242
- These are the strings you'll see in `error.type`:
1243
-
1244
- 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:
1245
-
1246
- | Type | Produced by |
1247
- | --- | --- |
1248
- | `required`, `present` | `.required()`, `.present()` (note: `.optional()` has no error type — it clears the required rule) |
1249
- | `requiredIf`, `requiredWith`, `requiredWithout`, `requiredUnless` | conditional required methods |
1250
- | `string`, `number`, `int`, `float`, `boolean`, `scalar`, `object`, `array`, `date` | type guards from `matchesType` |
1251
- | `minLength`, `maxLength`, `betweenLength`, `length` | string / array length rules (`.min`/`.max` on a string alias to `minLength`/`maxLength`) |
1252
- | `min`, `max`, `betweenNumbers` | number range rules (`.min`/`.max`/`.between` on a number) |
1253
- | `email`, `url`, `uuid`, `pattern`, `matches` | string format rules (`.pattern()` → `pattern`) |
1254
- | `literal`, `enum`, `in`, `allowedValues`, `notAllowedValues` | value-membership rules (`.oneOf` aliases `in`; `.notIn`/`.forbids` → `notAllowedValues`; `.allowsOnly` → `allowedValues`) |
1255
- | `instanceof` | `v.instanceof(Ctor)` |
1256
- | `equalsField`, `notEqualsField` | cross-field equality (`.sameAs` → `equalsField`; `.differentFrom` → `notEqualsField`) |
1257
- | `minDate`, `maxDate`, `beforeField`, `afterField`, `today`, `past`, `future`, `weekDay`, `weekend`, `businessDay`, `birthday` | date rules (`.min`/`.max` → `minDate`/`maxDate`; `.before`/`.after` → `beforeField`/`afterField`) |
1258
-
1259
- 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.
1260
-
1261
- ## Customizing error messages
1262
-
1263
- Two layers:
1264
-
1265
- ```ts
1266
- v.string().email("Please enter a valid email address").required("Email is required");
1267
- // ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^
1268
- // per-rule override per-rule override
1269
- ```
1270
-
1271
- Each chain method takes an optional `errorMessage` as its last argument. That overrides the rule's `defaultErrorMessage`. Use it when a single rule needs a tailored message in a specific schema.
1272
-
1273
- For project-wide message overrides, hook into the translation layer (the framework calls `resolveTranslation` with the rule context — wire up your own `t()` function via `SealConfig`).
1274
-
1275
- ## Translation params
1276
-
1277
- Rules can stash dynamic substitution params on the context. The default messages reference them:
1278
-
1279
- ```
1280
- "The :input must be at least :min characters"
1281
- ```
1282
-
1283
- `:input` is the field name (or its translated display name); `:min` and others come from rule-specific metadata. If you need to render the message yourself in a custom UI, the params are available on the rule's context.
1284
-
1285
- For attribute display names ("email" → "Email Address"), use `.attributes({ email: "Email Address" })` on the parent `v.object` or pass via the `validate()` options. The `:input` placeholder picks up the configured display name.
1286
-
1287
- ## When to throw
1288
-
1289
- Don't wrap `validate()` in try/catch for *validation* failures — those land in `result.errors`. The only thing that *throws* is a programming bug:
1290
-
1291
- - A rule's callback threw (e.g. you wrote `async validate() { throw new Error(...) }`).
1292
- - A transformer threw on output.
1293
- - A mutator threw on input.
1294
-
1295
- Those are bugs — fix them. Don't try/catch them in app code as a way to handle bad input.
1296
-
1297
- ## At the framework boundary
1298
-
1299
- If you're surfacing seal errors through HTTP / RPC, the typical shape is:
1300
-
1301
- ```ts
1302
- if (!result.isValid) {
1303
- return reply.code(422).send({
1304
- error: "validation_failed",
1305
- fields: result.errors.map(e => ({
1306
- field: e.input,
1307
- type: e.type,
1308
- message: e.error,
1309
- })),
1310
- });
1311
- }
1312
- ```
1313
-
1314
- Avoid leaking `result.data` back to the client when rejecting — it might contain transformed sensitive fields.
1315
-
1316
- For server-side logs, log `errors[]` with the field paths and rule types; redact values unless you're certain the field isn't sensitive. The `@warlock.js/logger` redaction layer is the right place to enforce that.
1167
+ ---
1168
+ name: handle-seal-errors
1169
+ description: 'Read `ValidationResult` — `isValid`, `errors[]`, `data`. Branch on `error.type`, customize messages, hook translation. Triggers: `ValidationResult`, `ValidationError`, `validate`, `isValid`, `errors`, `data`, `error.type`, `error.input`, `error.error`, `translationParams`, `.attribute`, `SealConfig`; "how to read seal errors", "branch on a specific rule failure", "customize validation error message", "translate seal error", "surface validation errors as 422"; typical import `import { validate, v } from "@warlock.js/seal"`. Skip: modifiers — `@warlock.js/seal/compose-seal-modifiers/SKILL.md`; structural shapes — `@warlock.js/seal/define-structural-shape/SKILL.md`; competing libs `zod` `.safeParse`, `yup` `ValidationError`, `joi`.'
1170
+ ---
1171
+
1172
+ # `ValidationResult` — reading errors
1173
+
1174
+ `validate(schema, data)` never throws. It returns a `ValidationResult`:
1175
+
1176
+ ```ts
1177
+ type ValidationResult = {
1178
+ isValid: boolean;
1179
+ data: any; // shape after mutators + transformers (the validated value)
1180
+ errors: ValidationError[];
1181
+ };
1182
+
1183
+ type ValidationError = {
1184
+ type: string; // rule type that failed — "required", "email", "min", "string", ...
1185
+ error: string; // resolved message (translated, with attribute substitution)
1186
+ input: string; // input field name — "email" or "address.city"
1187
+ };
1188
+ ```
1189
+
1190
+ Branch on `isValid` first; reach for `errors[]` only when you need to act on a specific failure.
1191
+
1192
+ ## Basic flow
1193
+
1194
+ ```ts
1195
+ import { validate, v } from "@warlock.js/seal";
1196
+
1197
+ const schema = v.object({
1198
+ email: v.string().email(),
1199
+ age: v.int().min(13).optional(),
1200
+ });
1201
+
1202
+ const result = await validate(schema, input);
1203
+
1204
+ if (result.isValid) {
1205
+ return result.data; // typed as the inferred shape (post-transformer)
1206
+ }
1207
+
1208
+ return {
1209
+ status: 422,
1210
+ body: { errors: result.errors },
1211
+ };
1212
+ ```
1213
+
1214
+ ## Branching on a specific rule
1215
+
1216
+ ```ts
1217
+ const result = await validate(schema, input);
1218
+
1219
+ if (!result.isValid) {
1220
+ const emailMissing = result.errors.find(
1221
+ e => e.input === "email" && e.type === "required",
1222
+ );
1223
+ if (emailMissing) {
1224
+ return { redirect: "/signup", reason: "no email" };
1225
+ }
1226
+
1227
+ const ageInvalid = result.errors.find(
1228
+ e => e.input === "age" && (e.type === "int" || e.type === "min"),
1229
+ );
1230
+ if (ageInvalid) {
1231
+ return { error: "Age must be 13 or older" };
1232
+ }
1233
+ }
1234
+ ```
1235
+
1236
+ The `type` field is the **stable** identifier — the message is human-facing and may be localized. Branch on `type`, never on the message string.
1237
+
1238
+ ## Common rule type names
1239
+
1240
+ These are the strings you'll see in `error.type`:
1241
+
1242
+ 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:
1243
+
1244
+ | Type | Produced by |
1245
+ | --- | --- |
1246
+ | `required`, `present` | `.required()`, `.present()` (note: `.optional()` has no error type — it clears the required rule) |
1247
+ | `requiredIf`, `requiredWith`, `requiredWithout`, `requiredUnless` | conditional required methods |
1248
+ | `string`, `number`, `int`, `float`, `boolean`, `scalar`, `object`, `array`, `date` | type guards from `matchesType` |
1249
+ | `minLength`, `maxLength`, `betweenLength`, `length` | string / array length rules (`.min`/`.max` on a string alias to `minLength`/`maxLength`) |
1250
+ | `min`, `max`, `betweenNumbers` | number range rules (`.min`/`.max`/`.between` on a number) |
1251
+ | `email`, `url`, `uuid`, `pattern`, `matches` | string format rules (`.pattern()` → `pattern`) |
1252
+ | `literal`, `enum`, `in`, `allowedValues`, `notAllowedValues` | value-membership rules (`.oneOf` aliases `in`; `.notIn`/`.forbids` → `notAllowedValues`; `.allowsOnly` → `allowedValues`) |
1253
+ | `instanceof` | `v.instanceof(Ctor)` |
1254
+ | `equalsField`, `notEqualsField` | cross-field equality (`.sameAs` → `equalsField`; `.differentFrom` → `notEqualsField`) |
1255
+ | `minDate`, `maxDate`, `beforeField`, `afterField`, `today`, `past`, `future`, `weekDay`, `weekend`, `businessDay`, `birthday` | date rules (`.min`/`.max` → `minDate`/`maxDate`; `.before`/`.after` → `beforeField`/`afterField`) |
1256
+
1257
+ 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.
1258
+
1259
+ ## Customizing error messages
1260
+
1261
+ Two layers:
1262
+
1263
+ ```ts
1264
+ v.string().email("Please enter a valid email address").required("Email is required");
1265
+ // ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^
1266
+ // per-rule override per-rule override
1267
+ ```
1268
+
1269
+ Each chain method takes an optional `errorMessage` as its last argument. That overrides the rule's `defaultErrorMessage`. Use it when a single rule needs a tailored message in a specific schema.
1270
+
1271
+ For project-wide message overrides, hook into the translation layer (the framework calls `resolveTranslation` with the rule context — wire up your own `t()` function via `SealConfig`).
1272
+
1273
+ ## Translation params
1274
+
1275
+ Rules can stash dynamic substitution params on the context. The default messages reference them:
1276
+
1277
+ ```
1278
+ "The :input must be at least :min characters"
1279
+ ```
1280
+
1281
+ `:input` is the field name (or its translated display name); `:min` and others come from rule-specific metadata. If you need to render the message yourself in a custom UI, the params are available on the rule's context.
1282
+
1283
+ For attribute display names ("email" → "Email Address"), use `.attributes({ email: "Email Address" })` on the parent `v.object` or pass via the `validate()` options. The `:input` placeholder picks up the configured display name.
1284
+
1285
+ ## When to throw
1286
+
1287
+ Don't wrap `validate()` in try/catch for *validation* failures — those land in `result.errors`. The only thing that *throws* is a programming bug:
1288
+
1289
+ - A rule's callback threw (e.g. you wrote `async validate() { throw new Error(...) }`).
1290
+ - A transformer threw on output.
1291
+ - A mutator threw on input.
1292
+
1293
+ Those are bugs — fix them. Don't try/catch them in app code as a way to handle bad input.
1294
+
1295
+ ## At the framework boundary
1296
+
1297
+ If you're surfacing seal errors through HTTP / RPC, the typical shape is:
1298
+
1299
+ ```ts
1300
+ if (!result.isValid) {
1301
+ return reply.code(422).send({
1302
+ error: "validation_failed",
1303
+ fields: result.errors.map(e => ({
1304
+ field: e.input,
1305
+ type: e.type,
1306
+ message: e.error,
1307
+ })),
1308
+ });
1309
+ }
1310
+ ```
1311
+
1312
+ Avoid leaking `result.data` back to the client when rejecting — it might contain transformed sensitive fields.
1313
+
1314
+ For server-side logs, log `errors[]` with the field paths and rule types; redact values unless you're certain the field isn't sensitive. The `@warlock.js/logger` redaction layer is the right place to enforce that.
1317
1315
 
1318
1316
 
1319
1317
  ## overview `@warlock.js/seal/overview/SKILL.md`
@@ -1394,155 +1392,155 @@ Author a plugin to add validator methods (`.slug`, `.postalCode`, …) — the `
1394
1392
 
1395
1393
  ## pick-seal-primitive `@warlock.js/seal/pick-seal-primitive/SKILL.md`
1396
1394
 
1397
- ---
1398
- name: pick-seal-primitive
1399
- description: 'Pick the right `v` factory primitive — string / int / literal / date / enum / computed / managed / instanceof / any. Triggers: `v.string`, `v.email`, `v.number`, `v.int`, `v.float`, `v.numeric`, `v.boolean`, `v.scalar`, `v.date`, `v.literal`, `v.enum`, `v.instanceof`, `v.computed`, `v.managed`, `v.any`; "v.string vs v.scalar", "v.literal vs v.enum", "v.date vs v.instanceof(Date)", "what is v.computed"; typical import `import { v } from "@warlock.js/seal"`. Skip: structural shapes — `@warlock.js/seal/define-structural-shape/SKILL.md`; modifiers — `@warlock.js/seal/compose-seal-modifiers/SKILL.md`; competing libs `zod`, `valibot`, `yup`.'
1400
- ---
1401
-
1402
- # Picking the right primitive
1403
-
1404
- 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.
1405
-
1406
- ## Strings
1407
-
1408
- ```ts
1409
- v.string() // type: string — full surface in string-methods.md
1410
- v.email() // shorthand for v.string().email()
1411
- v.enum(["a", "b"]) // type: "a" | "b" — runs as v.string().oneOf, but the factory overload preserves the literal union
1412
- ```
1413
-
1414
- 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.
1415
-
1416
- ## Numbers — pick by what you accept
1417
-
1418
- | Validator | Accepts | When |
1419
- |---|---|---|
1420
- | `v.number()` | any finite number | accepts both integers and floats |
1421
- | `v.int()` | integers only | rejects `1.5` |
1422
- | `v.float()` | finite, non-integer | rejects `1` |
1423
- | `v.numeric()` | numeric strings + numbers | form/query inputs that arrive as `"42"` — coerces to number |
1424
-
1425
- All four share the same chain surface (see [`number-methods.md`](./number-methods.md)). Picking is about input acceptance, not chain power.
1426
-
1427
- ## Booleans & scalars
1428
-
1429
- ```ts
1430
- v.boolean() // type: boolean — adds .accepted() / .declined() for form-style inputs
1431
- v.scalar() // type: string | number | boolean — usually a smell pointing at a missing discriminator
1432
- ```
1433
-
1434
- 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.
1435
-
1436
- ## Dates
1437
-
1438
- ```ts
1439
- v.date() // type: Date — normalizes strings/timestamps to Date, rich rule surface
1440
- v.instanceof(Date) // type: Date — raw instanceof, no normalization, no rules
1441
- ```
1442
-
1443
- `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.
1444
-
1445
- ## Literals & instances
1446
-
1447
- ```ts
1448
- v.literal("items") // type: "items"
1449
- v.literal("draft", "published", "archived") // type: "draft" | "published" | "archived"
1450
- v.literal(1, 2, 3) // type: 1 | 2 | 3
1451
- v.literal(true) // type: true
1452
-
1453
- v.instanceof(File) // type: File
1454
- v.instanceof(Buffer) // type: Buffer
1455
- v.instanceof(MyClass) // type: MyClass
1456
- ```
1457
-
1458
- **`v.literal` vs `v.string().oneOf([...])` vs `v.enum([...])`:**
1459
-
1460
- - `v.literal("a", "b")` infers as `"a" | "b"` (literal narrowing). **Use this for discriminator fields.**
1461
- - `v.string().oneOf(["a", "b"])` infers as `string` (loses literal types). Use when broad type is fine.
1462
- - `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]`.
1463
-
1464
- `**`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*:
1465
-
1466
- ```ts
1467
- const decorative = v.object({ decorative: v.literal(true), alt: v.literal("") });
1468
-
1469
- await v.validate(decorative, { decorative: true, alt: "" }); // valid
1470
- await v.validate(decorative, { decorative: true, alt: "text" }); // invalid — not the literal
1471
- await v.validate(decorative, { decorative: true }); // invalid — key missing
1472
- ```
1473
-
1474
- 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.
1475
-
1476
- :::note[Fixed in 4.9.2]
1477
- 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.
1478
- :::
1479
-
1480
- `v.instanceof(Ctor)` for File/Buffer/Uint8Array/custom classes. Returns `{}` from `toJsonSchema()` (not representable). For OpenAPI `File`, attach `{ type: "string", format: "binary" }` manually after generation.
1481
-
1482
- ## `v.any` — escape hatch
1483
-
1484
- ```ts
1485
- v.any() // type: any — skips validation entirely
1486
- ```
1487
-
1488
- 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.
1489
-
1490
- ## Derived: `v.computed` and `v.managed`
1491
-
1492
- These two **don't validate input** — they produce a value as part of validation.
1493
-
1494
- ```ts
1495
- v.object({
1496
- firstName: v.string(),
1497
- lastName: v.string(),
1498
- fullName: v.computed<string>((data) => `${data.firstName} ${data.lastName}`),
1499
- createdAt: v.managed<Date>(() => new Date()),
1500
- createdBy: v.managed<string>((context) => context.context?.userId),
1501
- });
1502
- ```
1503
-
1504
- - **`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
- - **`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
-
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).
1508
-
1509
- ## Quick map — "I need to validate…"
1510
-
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 [`@warlock.js/seal/define-structural-shape/SKILL.md`](@warlock.js/seal/define-structural-shape/SKILL.md) |
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) |
1527
-
1528
- ## Method-surface reference
1529
-
1530
- Each primitive's full method list lives in a sibling file:
1531
-
1532
- - [`string-methods.md`](./string-methods.md) — `.email` / `.url` / `.uuid` / `.pattern` / `.startsWith` / `.alpha` / `.trim` / `.slug` / `.mask` / `.base64Encode` / …
1533
- - [`number-methods.md`](./number-methods.md) — `.min` / `.max` / `.between` / `.greaterThan` / `.positive` / `.even` / `.multipleOf` / `.minSibling` / `.round` / …
1534
- - [`date-methods.md`](./date-methods.md) — `.min` / `.before` / `.after` / `.today` / `.past` / `.future` / `.weekDay` / `.minAge` / `.year` / `.quarter` / `.toISOString` / …
1535
- - [`boolean-methods.md`](./boolean-methods.md) — `.accepted` / `.declined` / `.mustBeTrue` / `.mustBeFalse` / `.acceptedIf` / `.declinedWithout` / …
1536
-
1537
- For cross-cutting modifiers (`.optional`/`.nullable`/`.default`/`.catch`/`.in`/`.oneOf`), see [`@warlock.js/seal/compose-seal-modifiers/SKILL.md`](@warlock.js/seal/compose-seal-modifiers/SKILL.md). For containers (object/array/record/tuple/union), see [`@warlock.js/seal/define-structural-shape/SKILL.md`](@warlock.js/seal/define-structural-shape/SKILL.md).
1538
-
1539
- ## Things NOT to do
1540
-
1541
- - Don't `new ObjectValidator()` (or any class) directly — factory returns carry the StandardSchema bridge that bare instantiation loses.
1542
- - 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
- - Don't reach for `v.instanceof(Date)` when `v.date()` works. The latter is purpose-built.
1544
- - 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.
1395
+ ---
1396
+ name: pick-seal-primitive
1397
+ description: 'Pick the right `v` factory primitive — string / int / literal / date / enum / computed / managed / instanceof / any. Triggers: `v.string`, `v.email`, `v.number`, `v.int`, `v.float`, `v.numeric`, `v.boolean`, `v.scalar`, `v.date`, `v.literal`, `v.enum`, `v.instanceof`, `v.computed`, `v.managed`, `v.any`; "v.string vs v.scalar", "v.literal vs v.enum", "v.date vs v.instanceof(Date)", "what is v.computed"; typical import `import { v } from "@warlock.js/seal"`. Skip: structural shapes — `@warlock.js/seal/define-structural-shape/SKILL.md`; modifiers — `@warlock.js/seal/compose-seal-modifiers/SKILL.md`; competing libs `zod`, `valibot`, `yup`.'
1398
+ ---
1399
+
1400
+ # Picking the right primitive
1401
+
1402
+ 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.
1403
+
1404
+ ## Strings
1405
+
1406
+ ```ts
1407
+ v.string() // type: string — full surface in string-methods.md
1408
+ v.email() // shorthand for v.string().email()
1409
+ v.enum(["a", "b"]) // type: "a" | "b" — runs as v.string().oneOf, but the factory overload preserves the literal union
1410
+ ```
1411
+
1412
+ 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.
1413
+
1414
+ ## Numbers — pick by what you accept
1415
+
1416
+ | Validator | Accepts | When |
1417
+ |---|---|---|
1418
+ | `v.number()` | any finite number | accepts both integers and floats |
1419
+ | `v.int()` | integers only | rejects `1.5` |
1420
+ | `v.float()` | finite, non-integer | rejects `1` |
1421
+ | `v.numeric()` | numeric strings + numbers | form/query inputs that arrive as `"42"` — coerces to number |
1422
+
1423
+ All four share the same chain surface (see [`number-methods.md`](./number-methods.md)). Picking is about input acceptance, not chain power.
1424
+
1425
+ ## Booleans & scalars
1426
+
1427
+ ```ts
1428
+ v.boolean() // type: boolean — adds .accepted() / .declined() for form-style inputs
1429
+ v.scalar() // type: string | number | boolean — usually a smell pointing at a missing discriminator
1430
+ ```
1431
+
1432
+ 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.
1433
+
1434
+ ## Dates
1435
+
1436
+ ```ts
1437
+ v.date() // type: Date — normalizes strings/timestamps to Date, rich rule surface
1438
+ v.instanceof(Date) // type: Date — raw instanceof, no normalization, no rules
1439
+ ```
1440
+
1441
+ `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.
1442
+
1443
+ ## Literals & instances
1444
+
1445
+ ```ts
1446
+ v.literal("items") // type: "items"
1447
+ v.literal("draft", "published", "archived") // type: "draft" | "published" | "archived"
1448
+ v.literal(1, 2, 3) // type: 1 | 2 | 3
1449
+ v.literal(true) // type: true
1450
+
1451
+ v.instanceof(File) // type: File
1452
+ v.instanceof(Buffer) // type: Buffer
1453
+ v.instanceof(MyClass) // type: MyClass
1454
+ ```
1455
+
1456
+ **`v.literal` vs `v.string().oneOf([...])` vs `v.enum([...])`:**
1457
+
1458
+ - `v.literal("a", "b")` infers as `"a" | "b"` (literal narrowing). **Use this for discriminator fields.**
1459
+ - `v.string().oneOf(["a", "b"])` infers as `string` (loses literal types). Use when broad type is fine.
1460
+ - `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]`.
1461
+
1462
+ `**`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*:
1463
+
1464
+ ```ts
1465
+ const decorative = v.object({ decorative: v.literal(true), alt: v.literal("") });
1466
+
1467
+ await v.validate(decorative, { decorative: true, alt: "" }); // valid
1468
+ await v.validate(decorative, { decorative: true, alt: "text" }); // invalid — not the literal
1469
+ await v.validate(decorative, { decorative: true }); // invalid — key missing
1470
+ ```
1471
+
1472
+ 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.
1473
+
1474
+ :::note[Fixed in 4.9.2]
1475
+ 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.
1476
+ :::
1477
+
1478
+ `v.instanceof(Ctor)` for File/Buffer/Uint8Array/custom classes. Returns `{}` from `toJsonSchema()` (not representable). For OpenAPI `File`, attach `{ type: "string", format: "binary" }` manually after generation.
1479
+
1480
+ ## `v.any` — escape hatch
1481
+
1482
+ ```ts
1483
+ v.any() // type: any — skips validation entirely
1484
+ ```
1485
+
1486
+ 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.
1487
+
1488
+ ## Derived: `v.computed` and `v.managed`
1489
+
1490
+ These two **don't validate input** — they produce a value as part of validation.
1491
+
1492
+ ```ts
1493
+ v.object({
1494
+ firstName: v.string(),
1495
+ lastName: v.string(),
1496
+ fullName: v.computed<string>((data) => `${data.firstName} ${data.lastName}`),
1497
+ createdAt: v.managed<Date>(() => new Date()),
1498
+ createdBy: v.managed<string>((context) => context.context?.userId),
1499
+ });
1500
+ ```
1501
+
1502
+ - **`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))`.
1503
+ - **`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.
1504
+
1505
+ 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).
1506
+
1507
+ ## Quick map — "I need to validate…"
1508
+
1509
+ | Need | Reach for |
1510
+ |---|---|
1511
+ | Email | `v.string().email()` or `v.email()` |
1512
+ | URL | `v.string().url()` |
1513
+ | UUID | `v.string().uuid()` (see [`string-methods.md`](./string-methods.md)) |
1514
+ | Number 0–100 | `v.number().between(0, 100)` |
1515
+ | Positive integer | `v.int().positive()` |
1516
+ | One of N constants | `v.literal(...values)` |
1517
+ | One of TS enum values | `v.enum(MyEnum)` |
1518
+ | Date in the past | `v.date().past()` |
1519
+ | File upload | `v.instanceof(File)` |
1520
+ | Class instance (not Date) | `v.instanceof(Ctor)` |
1521
+ | Discriminated union | see [`@warlock.js/seal/define-structural-shape/SKILL.md`](@warlock.js/seal/define-structural-shape/SKILL.md) |
1522
+ | Derived value (computed from siblings) | `v.computed<T>(callback)` |
1523
+ | Framework-injected value | `v.managed<T>(callback)` |
1524
+ | Free-form / pass-through | `v.any()` (only when you've thought about it) |
1525
+
1526
+ ## Method-surface reference
1527
+
1528
+ Each primitive's full method list lives in a sibling file:
1529
+
1530
+ - [`string-methods.md`](./string-methods.md) — `.email` / `.url` / `.uuid` / `.pattern` / `.startsWith` / `.alpha` / `.trim` / `.slug` / `.mask` / `.base64Encode` / …
1531
+ - [`number-methods.md`](./number-methods.md) — `.min` / `.max` / `.between` / `.greaterThan` / `.positive` / `.even` / `.multipleOf` / `.minSibling` / `.round` / …
1532
+ - [`date-methods.md`](./date-methods.md) — `.min` / `.before` / `.after` / `.today` / `.past` / `.future` / `.weekDay` / `.minAge` / `.year` / `.quarter` / `.toISOString` / …
1533
+ - [`boolean-methods.md`](./boolean-methods.md) — `.accepted` / `.declined` / `.mustBeTrue` / `.mustBeFalse` / `.acceptedIf` / `.declinedWithout` / …
1534
+
1535
+ For cross-cutting modifiers (`.optional`/`.nullable`/`.default`/`.catch`/`.in`/`.oneOf`), see [`@warlock.js/seal/compose-seal-modifiers/SKILL.md`](@warlock.js/seal/compose-seal-modifiers/SKILL.md). For containers (object/array/record/tuple/union), see [`@warlock.js/seal/define-structural-shape/SKILL.md`](@warlock.js/seal/define-structural-shape/SKILL.md).
1536
+
1537
+ ## Things NOT to do
1538
+
1539
+ - Don't `new ObjectValidator()` (or any class) directly — factory returns carry the StandardSchema bridge that bare instantiation loses.
1540
+ - 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.
1541
+ - Don't reach for `v.instanceof(Date)` when `v.date()` works. The latter is purpose-built.
1542
+ - Don't use `v.string().oneOf(["a", "b"])` for discriminator fields where you need the literal type — use `v.literal("a", "b")`.
1543
+ - Don't expect `v.computed` / `v.managed` to validate input — they ignore input shape entirely. Reach for them only when *producing* a value.
1546
1544
 
1547
1545
 
1548
1546
  ### pick-seal-primitive/boolean-methods.md `@warlock.js/seal/pick-seal-primitive/boolean-methods.md`
@@ -2200,7 +2198,7 @@ import { object } from "@warlock.js/seal/object";
2200
2198
  import { string } from "@warlock.js/seal/string";
2201
2199
 
2202
2200
  const commentSchema = object({
2203
- comment: string().trim().required().minLength(3),
2201
+ comment: string().trim().minLength(3),
2204
2202
  });
2205
2203
 
2206
2204
  const result = await commentSchema["~standard"].validate({