@warlock.js/seal 5.23.2 → 5.25.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 (116) 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.mjs.map +1 -1
  14. package/esm/factory/validate.mjs.map +1 -1
  15. package/esm/factory/validators.mjs +1 -1
  16. package/esm/factory/validators.mjs.map +1 -1
  17. package/esm/helpers/date-helpers.mjs.map +1 -1
  18. package/esm/helpers/get-field-value.mjs.map +1 -1
  19. package/esm/helpers/path-helpers.mjs.map +1 -1
  20. package/esm/mutators/array-mutators.mjs.map +1 -1
  21. package/esm/mutators/date-mutators.mjs.map +1 -1
  22. package/esm/mutators/number-mutators.mjs.map +1 -1
  23. package/esm/mutators/object-mutators.mjs.map +1 -1
  24. package/esm/mutators/scalar-mutators.mjs.map +1 -1
  25. package/esm/mutators/string-mutators.mjs.map +1 -1
  26. package/esm/plugins/plugin-system.mjs.map +1 -1
  27. package/esm/rules/array/array-rules.mjs.map +1 -1
  28. package/esm/rules/color/color-rules.mjs.map +1 -1
  29. package/esm/rules/common/enum.mjs.map +1 -1
  30. package/esm/rules/common/equals-field-rules.mjs.map +1 -1
  31. package/esm/rules/common/type-rules.mjs.map +1 -1
  32. package/esm/rules/conditional/forbidden-if-rules.mjs.map +1 -1
  33. package/esm/rules/conditional/present-if-rules.mjs.map +1 -1
  34. package/esm/rules/conditional/present-unless-rules.mjs.map +1 -1
  35. package/esm/rules/conditional/present-with-rules.mjs.map +1 -1
  36. package/esm/rules/conditional/present-without-rules.mjs.map +1 -1
  37. package/esm/rules/conditional/required-if-rules.mjs.map +1 -1
  38. package/esm/rules/conditional/required-unless-rules.mjs.map +1 -1
  39. package/esm/rules/conditional/required-with-rules.mjs.map +1 -1
  40. package/esm/rules/conditional/required-without-rules.mjs.map +1 -1
  41. package/esm/rules/core/equal.mjs.map +1 -1
  42. package/esm/rules/core/forbidden.mjs.map +1 -1
  43. package/esm/rules/core/required.mjs.map +1 -1
  44. package/esm/rules/core/union.mjs.map +1 -1
  45. package/esm/rules/core/when.mjs.map +1 -1
  46. package/esm/rules/date/date-comparison-rules.mjs.map +1 -1
  47. package/esm/rules/date/date-day-rules.mjs.map +1 -1
  48. package/esm/rules/date/date-field-comparison-rules.mjs.map +1 -1
  49. package/esm/rules/date/date-period-rules.mjs.map +1 -1
  50. package/esm/rules/date/date-relative-rules.mjs.map +1 -1
  51. package/esm/rules/date/date-special-rules.mjs.map +1 -1
  52. package/esm/rules/date/date.mjs.map +1 -1
  53. package/esm/rules/file/dimensions.mjs.map +1 -1
  54. package/esm/rules/file/file-size.mjs.map +1 -1
  55. package/esm/rules/length/length-rules.mjs.map +1 -1
  56. package/esm/rules/number/number-rules.mjs.map +1 -1
  57. package/esm/rules/scalar/accepted-rule.mjs.map +1 -1
  58. package/esm/rules/scalar/declined-rule.mjs.map +1 -1
  59. package/esm/rules/string/alpha.mjs.map +1 -1
  60. package/esm/rules/string/credit-card.mjs.map +1 -1
  61. package/esm/rules/string/email.mjs.map +1 -1
  62. package/esm/rules/string/matches.mjs.map +1 -1
  63. package/esm/rules/string/pattern.mjs.map +1 -1
  64. package/esm/rules/string/string-comparison.mjs.map +1 -1
  65. package/esm/rules/string/strong-password-rule.mjs.map +1 -1
  66. package/esm/rules/string/url.mjs.map +1 -1
  67. package/esm/rules/string/without-whitespace.mjs.map +1 -1
  68. package/esm/types/date-types.mjs.map +1 -1
  69. package/esm/validators/any-validator.mjs.map +1 -1
  70. package/esm/validators/array-validator.d.mts.map +1 -1
  71. package/esm/validators/array-validator.mjs +1 -0
  72. package/esm/validators/array-validator.mjs.map +1 -1
  73. package/esm/validators/base-validator.d.mts +2 -0
  74. package/esm/validators/base-validator.d.mts.map +1 -1
  75. package/esm/validators/base-validator.mjs +11 -1
  76. package/esm/validators/base-validator.mjs.map +1 -1
  77. package/esm/validators/boolean-validator.d.mts.map +1 -1
  78. package/esm/validators/boolean-validator.mjs +1 -0
  79. package/esm/validators/boolean-validator.mjs.map +1 -1
  80. package/esm/validators/date-validator.d.mts.map +1 -1
  81. package/esm/validators/date-validator.mjs +1 -0
  82. package/esm/validators/date-validator.mjs.map +1 -1
  83. package/esm/validators/float-validator.mjs.map +1 -1
  84. package/esm/validators/int-validator.mjs.map +1 -1
  85. package/esm/validators/literal-validator.d.mts.map +1 -1
  86. package/esm/validators/literal-validator.mjs +1 -3
  87. package/esm/validators/literal-validator.mjs.map +1 -1
  88. package/esm/validators/methods/equality-conditional-methods.mjs.map +1 -1
  89. package/esm/validators/methods/forbidden-methods.mjs.map +1 -1
  90. package/esm/validators/methods/present-methods.mjs.map +1 -1
  91. package/esm/validators/methods/required-methods.mjs.map +1 -1
  92. package/esm/validators/number-validator.d.mts.map +1 -1
  93. package/esm/validators/number-validator.mjs +1 -0
  94. package/esm/validators/number-validator.mjs.map +1 -1
  95. package/esm/validators/numeric-validator.mjs.map +1 -1
  96. package/esm/validators/object-validator.d.mts.map +1 -1
  97. package/esm/validators/object-validator.mjs +1 -1
  98. package/esm/validators/object-validator.mjs.map +1 -1
  99. package/esm/validators/record-validator.mjs.map +1 -1
  100. package/esm/validators/scalar-validator.d.mts.map +1 -1
  101. package/esm/validators/scalar-validator.mjs +1 -0
  102. package/esm/validators/scalar-validator.mjs.map +1 -1
  103. package/esm/validators/string-validator.mjs.map +1 -1
  104. package/esm/validators/tuple-validator.mjs.map +1 -1
  105. package/esm/validators/union-validator.mjs.map +1 -1
  106. package/llms-full.txt +1165 -1165
  107. package/package.json +1 -1
  108. package/skills/bridge-standard-schema/SKILL.md +138 -138
  109. package/skills/define-structural-shape/SKILL.md +138 -138
  110. package/skills/extend-seal-with-plugins/SKILL.md +178 -178
  111. package/skills/generate-json-schema/SKILL.md +143 -143
  112. package/skills/handle-seal-errors/SKILL.md +148 -148
  113. package/skills/pick-seal-primitive/SKILL.md +149 -149
  114. package/cjs/object-DPPttDAF.cjs.map +0 -1
  115. package/cjs/required-methods-DJTm7yNP.cjs.map +0 -1
  116. package/cjs/string-C_pAtZPC.cjs.map +0 -1
package/package.json CHANGED
@@ -22,7 +22,7 @@
22
22
  ],
23
23
  "author": "hassanzohdy",
24
24
  "license": "MIT",
25
- "version": "5.23.2",
25
+ "version": "5.25.0",
26
26
  "main": "./cjs/index.cjs",
27
27
  "module": "./esm/index.mjs",
28
28
  "types": "./esm/index.d.mts",
@@ -1,138 +1,138 @@
1
- ---
2
- name: bridge-standard-schema
3
- 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`.'
4
- ---
5
-
6
- # The Standard Schema bridge
7
-
8
- 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.
9
-
10
- This skill is about the **typing** of that interop — the phantom intersection that makes `v.object({...})` satisfy `StandardSchemaV1<T>` without `as unknown as` casts.
11
-
12
- ## The shape of the bridge
13
-
14
- The `v` factory returns aren't bare validator classes. Each factory widens its return with a phantom intersection:
15
-
16
- ```ts
17
- // Inside seal's factory:
18
- object: <T extends Schema>(schema: T) =>
19
- new ObjectValidator<T>(schema) as ObjectValidator<T>
20
- & StandardSchemaV1<Infer<ObjectValidator<T>>>;
21
-
22
- string: () =>
23
- new StringValidator() as StringValidator & StandardSchemaV1<string>;
24
-
25
- literal: <T extends readonly [...] >(...values: T) =>
26
- new LiteralValidator<T>(values) as LiteralValidator<T> & StandardSchemaV1<T[number]>;
27
-
28
- // ...one intersection per factory.
29
- ```
30
-
31
- 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.
32
-
33
- ## What this means for app code
34
-
35
- You write the schema once, you use it anywhere a `StandardSchemaV1<T>` is expected, you never cast:
36
-
37
- ```ts
38
- import { v, validate, type Infer } from "@warlock.js/seal";
39
- import { ai } from "@warlock.js/ai";
40
-
41
- const userSchema = v.object({
42
- email: v.string().email(),
43
- age: v.int().optional(),
44
- });
45
-
46
- type User = Infer<typeof userSchema>;
47
-
48
- // ✅ ai-side slot — no cast
49
- ai.tool({
50
- name: "create_user",
51
- description: "Create a new user",
52
- input: userSchema,
53
- execute: async input => createUser(input),
54
- });
55
-
56
- // ✅ supervisor output — no cast
57
- ai.supervisor({
58
- name: "user-flow",
59
- output: userSchema,
60
- // ...
61
- });
62
-
63
- // ✅ direct validation — call ~standard
64
- const result = await userSchema["~standard"].validate(rawData);
65
- ```
66
-
67
- ## `Infer<typeof schema>` vs hand-rolled types
68
-
69
- Use `Infer<>`. Always:
70
-
71
- ```ts
72
- const schema = v.object({
73
- email: v.string(),
74
- blocks: v.array(v.literal("text", "image")).optional(),
75
- });
76
-
77
- type Output = Infer<typeof schema>;
78
- // { email: string; blocks?: ("text" | "image")[] }
79
- ```
80
-
81
- 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.
82
-
83
- ## When the bridge "fails"
84
-
85
- Three failure modes you'll see in real projects:
86
-
87
- ### 1. You annotated the schema with the bare class type
88
-
89
- ```ts
90
- // ❌ Discards the phantom intersection
91
- const schema: ObjectValidator<{ email: StringValidator }> = v.object({...});
92
-
93
- // ✅ Let inference run
94
- const schema = v.object({...});
95
- ```
96
-
97
- The annotation strips `& StandardSchemaV1<...>` from the type. The schema stops fitting `StandardSchemaV1<T>` slots. Fix: remove the annotation. If you need the value type, use `Infer<typeof schema>`.
98
-
99
- ### 2. The schema's inferred shape doesn't match the target type
100
-
101
- ```ts
102
- ai.supervisor<MyOutput, ...>({
103
- output: schema, // ❌ schema infers differently from MyOutput
104
- });
105
- ```
106
-
107
- The supervisor's explicit `<MyOutput>` generic is a constraint the schema must satisfy. If they diverge, TS rightly rejects. Two fixes:
108
-
109
- - **Drop the explicit generic** — let the supervisor infer output type from the schema. The hand-rolled type was probably documentation-only anyway.
110
- - **Align them** — if the hand-rolled type is the source of truth (e.g. a domain type from elsewhere), make the schema match.
111
-
112
- ### 3. The error says `Result<unknown>` even though `Infer<>` resolves correctly
113
-
114
- 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`.
115
-
116
- If you see this, ignore the `unknown` and ask: does `Infer<typeof schema>` match the slot's expected `T`? Probe with:
117
-
118
- ```ts
119
- type _Probe = Infer<typeof schema>;
120
- const _force: { __nope: 1 } = null as unknown as _Probe;
121
- // Hover the error to see the resolved shape, then compare to the slot's T.
122
- ```
123
-
124
- That's the real mismatch.
125
-
126
- ## Why not `Omit<class, "~standard"> & StandardSchemaV1<T>`?
127
-
128
- 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.
129
-
130
- ## Why not import `@standard-schema/spec` instead of forking the types?
131
-
132
- Seal forks the types locally. Reasons:
133
-
134
- - 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.
135
- - V1 spec is locked. The fork is ~70 lines and updates rarely.
136
- - Avoids version-coupling pain across `@warlock.js/*` packages.
137
-
138
- If V2 lands, re-fork. Until then the local copy is the right call.
1
+ ---
2
+ name: bridge-standard-schema
3
+ 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`.'
4
+ ---
5
+
6
+ # The Standard Schema bridge
7
+
8
+ 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.
9
+
10
+ This skill is about the **typing** of that interop — the phantom intersection that makes `v.object({...})` satisfy `StandardSchemaV1<T>` without `as unknown as` casts.
11
+
12
+ ## The shape of the bridge
13
+
14
+ The `v` factory returns aren't bare validator classes. Each factory widens its return with a phantom intersection:
15
+
16
+ ```ts
17
+ // Inside seal's factory:
18
+ object: <T extends Schema>(schema: T) =>
19
+ new ObjectValidator<T>(schema) as ObjectValidator<T>
20
+ & StandardSchemaV1<Infer<ObjectValidator<T>>>;
21
+
22
+ string: () =>
23
+ new StringValidator() as StringValidator & StandardSchemaV1<string>;
24
+
25
+ literal: <T extends readonly [...] >(...values: T) =>
26
+ new LiteralValidator<T>(values) as LiteralValidator<T> & StandardSchemaV1<T[number]>;
27
+
28
+ // ...one intersection per factory.
29
+ ```
30
+
31
+ 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.
32
+
33
+ ## What this means for app code
34
+
35
+ You write the schema once, you use it anywhere a `StandardSchemaV1<T>` is expected, you never cast:
36
+
37
+ ```ts
38
+ import { v, validate, type Infer } from "@warlock.js/seal";
39
+ import { ai } from "@warlock.js/ai";
40
+
41
+ const userSchema = v.object({
42
+ email: v.string().email(),
43
+ age: v.int().optional(),
44
+ });
45
+
46
+ type User = Infer<typeof userSchema>;
47
+
48
+ // ✅ ai-side slot — no cast
49
+ ai.tool({
50
+ name: "create_user",
51
+ description: "Create a new user",
52
+ input: userSchema,
53
+ execute: async input => createUser(input),
54
+ });
55
+
56
+ // ✅ supervisor output — no cast
57
+ ai.supervisor({
58
+ name: "user-flow",
59
+ output: userSchema,
60
+ // ...
61
+ });
62
+
63
+ // ✅ direct validation — call ~standard
64
+ const result = await userSchema["~standard"].validate(rawData);
65
+ ```
66
+
67
+ ## `Infer<typeof schema>` vs hand-rolled types
68
+
69
+ Use `Infer<>`. Always:
70
+
71
+ ```ts
72
+ const schema = v.object({
73
+ email: v.string(),
74
+ blocks: v.array(v.literal("text", "image")).optional(),
75
+ });
76
+
77
+ type Output = Infer<typeof schema>;
78
+ // { email: string; blocks?: ("text" | "image")[] }
79
+ ```
80
+
81
+ 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.
82
+
83
+ ## When the bridge "fails"
84
+
85
+ Three failure modes you'll see in real projects:
86
+
87
+ ### 1. You annotated the schema with the bare class type
88
+
89
+ ```ts
90
+ // ❌ Discards the phantom intersection
91
+ const schema: ObjectValidator<{ email: StringValidator }> = v.object({...});
92
+
93
+ // ✅ Let inference run
94
+ const schema = v.object({...});
95
+ ```
96
+
97
+ The annotation strips `& StandardSchemaV1<...>` from the type. The schema stops fitting `StandardSchemaV1<T>` slots. Fix: remove the annotation. If you need the value type, use `Infer<typeof schema>`.
98
+
99
+ ### 2. The schema's inferred shape doesn't match the target type
100
+
101
+ ```ts
102
+ ai.supervisor<MyOutput, ...>({
103
+ output: schema, // ❌ schema infers differently from MyOutput
104
+ });
105
+ ```
106
+
107
+ The supervisor's explicit `<MyOutput>` generic is a constraint the schema must satisfy. If they diverge, TS rightly rejects. Two fixes:
108
+
109
+ - **Drop the explicit generic** — let the supervisor infer output type from the schema. The hand-rolled type was probably documentation-only anyway.
110
+ - **Align them** — if the hand-rolled type is the source of truth (e.g. a domain type from elsewhere), make the schema match.
111
+
112
+ ### 3. The error says `Result<unknown>` even though `Infer<>` resolves correctly
113
+
114
+ 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`.
115
+
116
+ If you see this, ignore the `unknown` and ask: does `Infer<typeof schema>` match the slot's expected `T`? Probe with:
117
+
118
+ ```ts
119
+ type _Probe = Infer<typeof schema>;
120
+ const _force: { __nope: 1 } = null as unknown as _Probe;
121
+ // Hover the error to see the resolved shape, then compare to the slot's T.
122
+ ```
123
+
124
+ That's the real mismatch.
125
+
126
+ ## Why not `Omit<class, "~standard"> & StandardSchemaV1<T>`?
127
+
128
+ 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.
129
+
130
+ ## Why not import `@standard-schema/spec` instead of forking the types?
131
+
132
+ Seal forks the types locally. Reasons:
133
+
134
+ - 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.
135
+ - V1 spec is locked. The fork is ~70 lines and updates rarely.
136
+ - Avoids version-coupling pain across `@warlock.js/*` packages.
137
+
138
+ If V2 lands, re-fork. Until then the local copy is the right call.
@@ -1,138 +1,138 @@
1
- ---
2
- name: define-structural-shape
3
- 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`.'
4
- ---
5
-
6
- # Structural validators — picking guide
7
-
8
- 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.
9
-
10
- ## `v.object` — fixed-key records
11
-
12
- ```ts
13
- v.object({
14
- email: v.string().email(),
15
- age: v.int().min(13).optional(),
16
- role: v.literal("admin", "user", "guest"),
17
- })
18
- ```
19
-
20
- - **Required by default.** `.optional()` to opt out — the `{ isOptional: true }` brand makes the key optional in `Infer<>`.
21
- - **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).
22
- - **Schema composition.** `.extend(schema)`, `.merge(other)`, `.pick(...keys)`, `.without(...keys)`, `.partial(...keys)`, `.requiredFields(...keys)` — all in [`object-methods.md`](./object-methods.md).
23
- - **Cross-field rules** (`sameAs`, `requiredIf`, `requiredWith`) attach to fields *inside* a `v.object`. Without a parent, sibling resolution silently passes.
24
-
25
- ## `v.array` — homogeneous lists
26
-
27
- ```ts
28
- v.array(v.string()) // type: string[]
29
- v.array(userSchema) // type: User[]
30
- v.array(v.array(v.int())) // type: number[][] — recursive inference
31
- ```
32
-
33
- 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).
34
-
35
- ## `v.record` — homogeneous values, dynamic keys
36
-
37
- ```ts
38
- v.record(v.int()) // type: Record<string, number>
39
- v.record(v.object({ count: v.int() })) // type: Record<string, { count: number }>
40
- v.record() // type: Record<string, any>
41
- ```
42
-
43
- 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.
44
-
45
- ## `v.tuple` — positional types
46
-
47
- ```ts
48
- v.tuple([v.string(), v.int(), v.boolean()]) // type: [string, number, boolean]
49
- v.tuple([v.literal("ok"), v.string()]) // type: ["ok", string]
50
- ```
51
-
52
- 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]`).
53
-
54
- ## `v.union` — one of N validators (untagged)
55
-
56
- ```ts
57
- v.union([v.string(), v.int()]) // type: string | number
58
- ```
59
-
60
- 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.
61
-
62
- ## `v.discriminatedUnion` — tagged unions (recommended for objects)
63
-
64
- ```ts
65
- const email = v.object({ type: v.literal("email"), email: v.string().email() });
66
- const sms = v.object({ type: v.literal("sms"), phone: v.string() });
67
- const push = v.object({ type: v.literal("push"), deviceId: v.string() });
68
-
69
- const notif = v.discriminatedUnion("type", [email, sms, push]);
70
-
71
- type Notif = Infer<typeof notif>;
72
- // { type: "email"; email: string }
73
- // | { type: "sms"; phone: string }
74
- // | { type: "push"; deviceId: string }
75
- ```
76
-
77
- 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`:
78
-
79
- - **Precise errors.** Failures come from the matched branch, not from every branch.
80
- - **O(1) routing** instead of trial-and-error.
81
- - **Exact TS inference** — discriminated union with narrowing inside `if (x.type === "email")` blocks.
82
- - **Cleaner JSON Schema** — `oneOf` with literal discriminators; OpenAI-strict accepts it.
83
-
84
- Construction-time validation throws on:
85
-
86
- - Missing discriminator field in any branch
87
- - Non-literal discriminator (must be `v.literal(...)`)
88
- - Duplicate discriminator values across branches
89
-
90
- So misconfigurations surface at schema-build time, not runtime.
91
-
92
- ## `v.lazy` — recursive and forward-referenced schemas
93
-
94
- ```ts
95
- type Category = { name: string; children: Category[] };
96
-
97
- const categorySchema: ObjectValidator<...> = v.object({
98
- name: v.string(),
99
- children: v.array(v.lazy(() => categorySchema)),
100
- });
101
-
102
- type T = Infer<typeof categorySchema>;
103
- // { name: string; children: T[] } ← recursive type
104
- ```
105
-
106
- 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.
107
-
108
- **Memoised.** The thunk fires once on first use; subsequent calls reuse the cached validator.
109
-
110
- **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.
111
-
112
- **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>`).
113
-
114
- 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).
115
-
116
- ## Quick map
117
-
118
- | Want | Reach for |
119
- | --- | --- |
120
- | Fixed-shape record | `v.object({...})` |
121
- | Dynamic keys, same value shape | `v.record(valueSchema)` |
122
- | List of items | `v.array(itemSchema)` |
123
- | Position-typed array | `v.tuple([a, b, c])` |
124
- | One of N scalar types | `v.union([...])` |
125
- | One of N object shapes with a tag field | `v.discriminatedUnion(key, [...])` |
126
- | Self-referencing or forward reference | `v.lazy(() => schema)` |
127
- | 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)) |
128
-
129
- ## A note on cascade Models
130
-
131
- `@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).
132
-
133
- ## Method-surface reference
134
-
135
- - [`object-methods.md`](./object-methods.md) — `.extend` / `.merge` / `.pick` / `.without` / `.partial` / `.requiredFields` / `.allowUnknown` / `.stripUnknown` / `.allow` / `.trim`.
136
- - [`array-methods.md`](./array-methods.md) — `.minLength` / `.maxLength` / `.length` / `.between` / `.unique` / `.sorted` / `.flip` / `.sort` / `.onlyUnique`.
137
-
138
- `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.
1
+ ---
2
+ name: define-structural-shape
3
+ 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`.'
4
+ ---
5
+
6
+ # Structural validators — picking guide
7
+
8
+ 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.
9
+
10
+ ## `v.object` — fixed-key records
11
+
12
+ ```ts
13
+ v.object({
14
+ email: v.string().email(),
15
+ age: v.int().min(13).optional(),
16
+ role: v.literal("admin", "user", "guest"),
17
+ })
18
+ ```
19
+
20
+ - **Required by default.** `.optional()` to opt out — the `{ isOptional: true }` brand makes the key optional in `Infer<>`.
21
+ - **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).
22
+ - **Schema composition.** `.extend(schema)`, `.merge(other)`, `.pick(...keys)`, `.without(...keys)`, `.partial(...keys)`, `.requiredFields(...keys)` — all in [`object-methods.md`](./object-methods.md).
23
+ - **Cross-field rules** (`sameAs`, `requiredIf`, `requiredWith`) attach to fields *inside* a `v.object`. Without a parent, sibling resolution silently passes.
24
+
25
+ ## `v.array` — homogeneous lists
26
+
27
+ ```ts
28
+ v.array(v.string()) // type: string[]
29
+ v.array(userSchema) // type: User[]
30
+ v.array(v.array(v.int())) // type: number[][] — recursive inference
31
+ ```
32
+
33
+ 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).
34
+
35
+ ## `v.record` — homogeneous values, dynamic keys
36
+
37
+ ```ts
38
+ v.record(v.int()) // type: Record<string, number>
39
+ v.record(v.object({ count: v.int() })) // type: Record<string, { count: number }>
40
+ v.record() // type: Record<string, any>
41
+ ```
42
+
43
+ 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.
44
+
45
+ ## `v.tuple` — positional types
46
+
47
+ ```ts
48
+ v.tuple([v.string(), v.int(), v.boolean()]) // type: [string, number, boolean]
49
+ v.tuple([v.literal("ok"), v.string()]) // type: ["ok", string]
50
+ ```
51
+
52
+ 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]`).
53
+
54
+ ## `v.union` — one of N validators (untagged)
55
+
56
+ ```ts
57
+ v.union([v.string(), v.int()]) // type: string | number
58
+ ```
59
+
60
+ 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.
61
+
62
+ ## `v.discriminatedUnion` — tagged unions (recommended for objects)
63
+
64
+ ```ts
65
+ const email = v.object({ type: v.literal("email"), email: v.string().email() });
66
+ const sms = v.object({ type: v.literal("sms"), phone: v.string() });
67
+ const push = v.object({ type: v.literal("push"), deviceId: v.string() });
68
+
69
+ const notif = v.discriminatedUnion("type", [email, sms, push]);
70
+
71
+ type Notif = Infer<typeof notif>;
72
+ // { type: "email"; email: string }
73
+ // | { type: "sms"; phone: string }
74
+ // | { type: "push"; deviceId: string }
75
+ ```
76
+
77
+ 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`:
78
+
79
+ - **Precise errors.** Failures come from the matched branch, not from every branch.
80
+ - **O(1) routing** instead of trial-and-error.
81
+ - **Exact TS inference** — discriminated union with narrowing inside `if (x.type === "email")` blocks.
82
+ - **Cleaner JSON Schema** — `oneOf` with literal discriminators; OpenAI-strict accepts it.
83
+
84
+ Construction-time validation throws on:
85
+
86
+ - Missing discriminator field in any branch
87
+ - Non-literal discriminator (must be `v.literal(...)`)
88
+ - Duplicate discriminator values across branches
89
+
90
+ So misconfigurations surface at schema-build time, not runtime.
91
+
92
+ ## `v.lazy` — recursive and forward-referenced schemas
93
+
94
+ ```ts
95
+ type Category = { name: string; children: Category[] };
96
+
97
+ const categorySchema: ObjectValidator<...> = v.object({
98
+ name: v.string(),
99
+ children: v.array(v.lazy(() => categorySchema)),
100
+ });
101
+
102
+ type T = Infer<typeof categorySchema>;
103
+ // { name: string; children: T[] } ← recursive type
104
+ ```
105
+
106
+ 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.
107
+
108
+ **Memoised.** The thunk fires once on first use; subsequent calls reuse the cached validator.
109
+
110
+ **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.
111
+
112
+ **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>`).
113
+
114
+ 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).
115
+
116
+ ## Quick map
117
+
118
+ | Want | Reach for |
119
+ | --- | --- |
120
+ | Fixed-shape record | `v.object({...})` |
121
+ | Dynamic keys, same value shape | `v.record(valueSchema)` |
122
+ | List of items | `v.array(itemSchema)` |
123
+ | Position-typed array | `v.tuple([a, b, c])` |
124
+ | One of N scalar types | `v.union([...])` |
125
+ | One of N object shapes with a tag field | `v.discriminatedUnion(key, [...])` |
126
+ | Self-referencing or forward reference | `v.lazy(() => schema)` |
127
+ | 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)) |
128
+
129
+ ## A note on cascade Models
130
+
131
+ `@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).
132
+
133
+ ## Method-surface reference
134
+
135
+ - [`object-methods.md`](./object-methods.md) — `.extend` / `.merge` / `.pick` / `.without` / `.partial` / `.requiredFields` / `.allowUnknown` / `.stripUnknown` / `.allow` / `.trim`.
136
+ - [`array-methods.md`](./array-methods.md) — `.minLength` / `.maxLength` / `.length` / `.between` / `.unique` / `.sorted` / `.flip` / `.sort` / `.onlyUnique`.
137
+
138
+ `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.