@warlock.js/seal 5.19.1 → 5.20.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.
@@ -1,143 +1,143 @@
1
- ---
2
- name: generate-json-schema
3
- description: "JSON Schema generation in @warlock.js/seal; use when you need to generate json schema."
4
- ---
5
-
6
- # JSON Schema generation
7
-
8
- 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.
9
-
10
- ```ts
11
- const userSchema = v.object({
12
- email: v.string().email(),
13
- age: v.int().min(13).optional(),
14
- });
15
-
16
- userSchema.toJsonSchema("draft-2020-12");
17
- // {
18
- // type: "object",
19
- // properties: {
20
- // email: { type: "string", format: "email" },
21
- // age: { type: "integer", minimum: 13 },
22
- // },
23
- // required: ["email"],
24
- // additionalProperties: false,
25
- // }
26
- ```
27
-
28
- ## The four targets
29
-
30
- ```ts
31
- type JsonSchemaTarget =
32
- | "draft-2020-12" // default — modern JSON Schema
33
- | "draft-07" // older tooling, Swagger 2.0
34
- | "openapi-3.0" // uses { nullable: true } instead of type unions
35
- | "openai-strict" // OpenAI Structured Outputs strict mode
36
- ```
37
-
38
- Pick by consumer:
39
-
40
- | Consumer | Target |
41
- | --- | --- |
42
- | Modern tooling, no specific reason otherwise | `"draft-2020-12"` |
43
- | Swagger 2.0 / older OpenAPI / older form builders | `"draft-07"` |
44
- | OpenAPI 3.0 spec (uses `nullable: true`) | `"openapi-3.0"` |
45
- | OpenAI `response_format: { type: "json_schema", strict: true }` | `"openai-strict"` |
46
-
47
- ## OpenAI structured outputs (`openai-strict`)
48
-
49
- This target encodes the quirks of OpenAI's strict mode:
50
-
51
- - **Every field listed in `required`** — strict mode forbids leaving fields out.
52
- - **Optional fields encoded as `type: ["T", "null"]`** instead of being omitted from `required`.
53
- - **`additionalProperties: false` on every object.**
54
-
55
- ```ts
56
- const schema = v.object({
57
- reply: v.string(),
58
- citations: v.array(v.string()).optional(),
59
- });
60
-
61
- schema.toJsonSchema("openai-strict");
62
- // {
63
- // type: "object",
64
- // properties: {
65
- // reply: { type: "string" },
66
- // citations: { type: ["array", "null"], items: { type: "string" } },
67
- // },
68
- // required: ["reply", "citations"], // every field listed
69
- // additionalProperties: false,
70
- // }
71
- ```
72
-
73
- Hand to OpenAI:
74
-
75
- ```ts
76
- import OpenAI from "openai";
77
-
78
- const completion = await openai.chat.completions.create({
79
- model: "gpt-4o",
80
- messages: [...],
81
- response_format: {
82
- type: "json_schema",
83
- json_schema: {
84
- name: "user_reply",
85
- strict: true,
86
- schema: schema.toJsonSchema("openai-strict"),
87
- },
88
- },
89
- });
90
- ```
91
-
92
- 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.
93
-
94
- ## OpenAPI 3.0 nullable
95
-
96
- ```ts
97
- v.string().nullable().toJsonSchema("openapi-3.0");
98
- // { type: "string", nullable: true }
99
-
100
- v.string().nullable().toJsonSchema("draft-2020-12");
101
- // { type: ["string", "null"] }
102
- ```
103
-
104
- 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.
105
-
106
- ## What's representable
107
-
108
- Cleanly mapped:
109
-
110
- - `v.string()` — `{ type: "string" }` (with `format: email/url/uuid`, `pattern`, `minLength`, `maxLength`, `enum`)
111
- - `v.int()` / `v.float()` — `{ type: "integer" | "number" }` (with `minimum`, `maximum`, `multipleOf`)
112
- - `v.boolean()` — `{ type: "boolean" }`
113
- - `v.date()` — `{ type: "string", format: "date-time" | "date" | "time" }` (format derived from transformer if applicable)
114
- - `v.literal(values)` — `{ const: value }` (single) or `{ enum: [...] }` (multiple)
115
- - `v.array(item)` — `{ type: "array", items: ... }` (with `minItems`, `maxItems` from length rules; `.unique()`/`.sorted()` are runtime-only and not emitted)
116
- - `v.object({...})` — `{ type: "object", properties, required, additionalProperties }`
117
- - `v.union([...])` — `{ oneOf: [...] }`
118
- - `v.tuple([...])` — `{ type: "array", prefixItems: [...] }` (draft-2020-12) or `{ type: "array", items: [...] }` (draft-07)
119
- - `v.nullable()` — type union or `nullable: true` per target
120
-
121
- ## What's silently dropped
122
-
123
- Some seal constructs have no JSON Schema representation:
124
-
125
- - **Cross-field rules** (`sameAs`, `requiredIf`, `requiredWith`, etc.) — runtime-only. The generated schema describes the *shape*, not the inter-field invariants.
126
- - **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`).
127
- - **`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.)
128
- - **`v.instanceof(Ctor)`** — produces `{}`. Class identity isn't expressible. For `File`, attach `{ type: "string", format: "binary" }` manually after generation if needed for OpenAPI.
129
- - **`v.any()`** — produces `{}` deliberately (any value is valid).
130
-
131
- 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`.
132
-
133
- ## When the generated schema rejects valid data
134
-
135
- If the schema validator itself accepts data but the *generated* JSON Schema rejects the same data downstream, the cause is usually one of:
136
-
137
- - **Cross-field rule.** The generated schema doesn't enforce it, but a separate consumer might. (Or the runtime check fired at a different stage.)
138
- - **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.
139
- - **`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.
140
-
141
- ## Cost note
142
-
143
- 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.
1
+ ---
2
+ name: generate-json-schema
3
+ 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`.'
4
+ ---
5
+
6
+ # JSON Schema generation
7
+
8
+ 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.
9
+
10
+ ```ts
11
+ const userSchema = v.object({
12
+ email: v.string().email(),
13
+ age: v.int().min(13).optional(),
14
+ });
15
+
16
+ userSchema.toJsonSchema("draft-2020-12");
17
+ // {
18
+ // type: "object",
19
+ // properties: {
20
+ // email: { type: "string", format: "email" },
21
+ // age: { type: "integer", minimum: 13 },
22
+ // },
23
+ // required: ["email"],
24
+ // additionalProperties: false,
25
+ // }
26
+ ```
27
+
28
+ ## The four targets
29
+
30
+ ```ts
31
+ type JsonSchemaTarget =
32
+ | "draft-2020-12" // default — modern JSON Schema
33
+ | "draft-07" // older tooling, Swagger 2.0
34
+ | "openapi-3.0" // uses { nullable: true } instead of type unions
35
+ | "openai-strict" // OpenAI Structured Outputs strict mode
36
+ ```
37
+
38
+ Pick by consumer:
39
+
40
+ | Consumer | Target |
41
+ | --- | --- |
42
+ | Modern tooling, no specific reason otherwise | `"draft-2020-12"` |
43
+ | Swagger 2.0 / older OpenAPI / older form builders | `"draft-07"` |
44
+ | OpenAPI 3.0 spec (uses `nullable: true`) | `"openapi-3.0"` |
45
+ | OpenAI `response_format: { type: "json_schema", strict: true }` | `"openai-strict"` |
46
+
47
+ ## OpenAI structured outputs (`openai-strict`)
48
+
49
+ This target encodes the quirks of OpenAI's strict mode:
50
+
51
+ - **Every field listed in `required`** — strict mode forbids leaving fields out.
52
+ - **Optional fields encoded as `type: ["T", "null"]`** instead of being omitted from `required`.
53
+ - **`additionalProperties: false` on every object.**
54
+
55
+ ```ts
56
+ const schema = v.object({
57
+ reply: v.string(),
58
+ citations: v.array(v.string()).optional(),
59
+ });
60
+
61
+ schema.toJsonSchema("openai-strict");
62
+ // {
63
+ // type: "object",
64
+ // properties: {
65
+ // reply: { type: "string" },
66
+ // citations: { type: ["array", "null"], items: { type: "string" } },
67
+ // },
68
+ // required: ["reply", "citations"], // every field listed
69
+ // additionalProperties: false,
70
+ // }
71
+ ```
72
+
73
+ Hand to OpenAI:
74
+
75
+ ```ts
76
+ import OpenAI from "openai";
77
+
78
+ const completion = await openai.chat.completions.create({
79
+ model: "gpt-4o",
80
+ messages: [...],
81
+ response_format: {
82
+ type: "json_schema",
83
+ json_schema: {
84
+ name: "user_reply",
85
+ strict: true,
86
+ schema: schema.toJsonSchema("openai-strict"),
87
+ },
88
+ },
89
+ });
90
+ ```
91
+
92
+ 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.
93
+
94
+ ## OpenAPI 3.0 nullable
95
+
96
+ ```ts
97
+ v.string().nullable().toJsonSchema("openapi-3.0");
98
+ // { type: "string", nullable: true }
99
+
100
+ v.string().nullable().toJsonSchema("draft-2020-12");
101
+ // { type: ["string", "null"] }
102
+ ```
103
+
104
+ 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.
105
+
106
+ ## What's representable
107
+
108
+ Cleanly mapped:
109
+
110
+ - `v.string()` — `{ type: "string" }` (with `format: email/url/uuid`, `pattern`, `minLength`, `maxLength`, `enum`)
111
+ - `v.int()` / `v.float()` — `{ type: "integer" | "number" }` (with `minimum`, `maximum`, `multipleOf`)
112
+ - `v.boolean()` — `{ type: "boolean" }`
113
+ - `v.date()` — `{ type: "string", format: "date-time" | "date" | "time" }` (format derived from transformer if applicable)
114
+ - `v.literal(values)` — `{ const: value }` (single) or `{ enum: [...] }` (multiple)
115
+ - `v.array(item)` — `{ type: "array", items: ... }` (with `minItems`, `maxItems` from length rules; `.unique()`/`.sorted()` are runtime-only and not emitted)
116
+ - `v.object({...})` — `{ type: "object", properties, required, additionalProperties }`
117
+ - `v.union([...])` — `{ oneOf: [...] }`
118
+ - `v.tuple([...])` — `{ type: "array", prefixItems: [...] }` (draft-2020-12) or `{ type: "array", items: [...] }` (draft-07)
119
+ - `v.nullable()` — type union or `nullable: true` per target
120
+
121
+ ## What's silently dropped
122
+
123
+ Some seal constructs have no JSON Schema representation:
124
+
125
+ - **Cross-field rules** (`sameAs`, `requiredIf`, `requiredWith`, etc.) — runtime-only. The generated schema describes the *shape*, not the inter-field invariants.
126
+ - **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`).
127
+ - **`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.)
128
+ - **`v.instanceof(Ctor)`** — produces `{}`. Class identity isn't expressible. For `File`, attach `{ type: "string", format: "binary" }` manually after generation if needed for OpenAPI.
129
+ - **`v.any()`** — produces `{}` deliberately (any value is valid).
130
+
131
+ 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`.
132
+
133
+ ## When the generated schema rejects valid data
134
+
135
+ If the schema validator itself accepts data but the *generated* JSON Schema rejects the same data downstream, the cause is usually one of:
136
+
137
+ - **Cross-field rule.** The generated schema doesn't enforce it, but a separate consumer might. (Or the runtime check fired at a different stage.)
138
+ - **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.
139
+ - **`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.
140
+
141
+ ## Cost note
142
+
143
+ 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.
@@ -1,148 +1,148 @@
1
- ---
2
- name: handle-seal-errors
3
- description: "ValidationResult — reading errors in @warlock.js/seal; use when you need to handle seal errors."
4
- ---
5
-
6
- # `ValidationResult` — reading errors
7
-
8
- `validate(schema, data)` never throws. It returns a `ValidationResult`:
9
-
10
- ```ts
11
- type ValidationResult = {
12
- isValid: boolean;
13
- data: any; // shape after mutators + transformers (the validated value)
14
- errors: ValidationError[];
15
- };
16
-
17
- type ValidationError = {
18
- type: string; // rule type that failed — "required", "email", "min", "string", ...
19
- error: string; // resolved message (translated, with attribute substitution)
20
- input: string; // input field name — "email" or "address.city"
21
- };
22
- ```
23
-
24
- Branch on `isValid` first; reach for `errors[]` only when you need to act on a specific failure.
25
-
26
- ## Basic flow
27
-
28
- ```ts
29
- import { validate, v } from "@warlock.js/seal";
30
-
31
- const schema = v.object({
32
- email: v.string().email(),
33
- age: v.int().min(13).optional(),
34
- });
35
-
36
- const result = await validate(schema, input);
37
-
38
- if (result.isValid) {
39
- return result.data; // typed as the inferred shape (post-transformer)
40
- }
41
-
42
- return {
43
- status: 422,
44
- body: { errors: result.errors },
45
- };
46
- ```
47
-
48
- ## Branching on a specific rule
49
-
50
- ```ts
51
- const result = await validate(schema, input);
52
-
53
- if (!result.isValid) {
54
- const emailMissing = result.errors.find(
55
- e => e.input === "email" && e.type === "required",
56
- );
57
- if (emailMissing) {
58
- return { redirect: "/signup", reason: "no email" };
59
- }
60
-
61
- const ageInvalid = result.errors.find(
62
- e => e.input === "age" && (e.type === "int" || e.type === "min"),
63
- );
64
- if (ageInvalid) {
65
- return { error: "Age must be 13 or older" };
66
- }
67
- }
68
- ```
69
-
70
- The `type` field is the **stable** identifier — the message is human-facing and may be localized. Branch on `type`, never on the message string.
71
-
72
- ## Common rule type names
73
-
74
- These are the strings you'll see in `error.type`:
75
-
76
- 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:
77
-
78
- | Type | Produced by |
79
- | --- | --- |
80
- | `required`, `present` | `.required()`, `.present()` (note: `.optional()` has no error type — it clears the required rule) |
81
- | `requiredIf`, `requiredWith`, `requiredWithout`, `requiredUnless` | conditional required methods |
82
- | `string`, `number`, `int`, `float`, `boolean`, `scalar`, `object`, `array`, `date` | type guards from `matchesType` |
83
- | `minLength`, `maxLength`, `betweenLength`, `length` | string / array length rules (`.min`/`.max` on a string alias to `minLength`/`maxLength`) |
84
- | `min`, `max`, `betweenNumbers` | number range rules (`.min`/`.max`/`.between` on a number) |
85
- | `email`, `url`, `uuid`, `pattern`, `matches` | string format rules (`.pattern()` → `pattern`) |
86
- | `literal`, `enum`, `in`, `allowedValues`, `notAllowedValues` | value-membership rules (`.oneOf` aliases `in`; `.notIn`/`.forbids` → `notAllowedValues`; `.allowsOnly` → `allowedValues`) |
87
- | `instanceof` | `v.instanceof(Ctor)` |
88
- | `equalsField`, `notEqualsField` | cross-field equality (`.sameAs` → `equalsField`; `.differentFrom` → `notEqualsField`) |
89
- | `minDate`, `maxDate`, `beforeField`, `afterField`, `today`, `past`, `future`, `weekDay`, `weekend`, `businessDay`, `birthday` | date rules (`.min`/`.max` → `minDate`/`maxDate`; `.before`/`.after` → `beforeField`/`afterField`) |
90
-
91
- 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.
92
-
93
- ## Customizing error messages
94
-
95
- Two layers:
96
-
97
- ```ts
98
- v.string().email("Please enter a valid email address").required("Email is required");
99
- // ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^
100
- // per-rule override per-rule override
101
- ```
102
-
103
- 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.
104
-
105
- 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`).
106
-
107
- ## Translation params
108
-
109
- Rules can stash dynamic substitution params on the context. The default messages reference them:
110
-
111
- ```
112
- "The :input must be at least :min characters"
113
- ```
114
-
115
- `: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.
116
-
117
- 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.
118
-
119
- ## When to throw
120
-
121
- Don't wrap `validate()` in try/catch for *validation* failures — those land in `result.errors`. The only thing that *throws* is a programming bug:
122
-
123
- - A rule's callback threw (e.g. you wrote `async validate() { throw new Error(...) }`).
124
- - A transformer threw on output.
125
- - A mutator threw on input.
126
-
127
- Those are bugs — fix them. Don't try/catch them in app code as a way to handle bad input.
128
-
129
- ## At the framework boundary
130
-
131
- If you're surfacing seal errors through HTTP / RPC, the typical shape is:
132
-
133
- ```ts
134
- if (!result.isValid) {
135
- return reply.code(422).send({
136
- error: "validation_failed",
137
- fields: result.errors.map(e => ({
138
- field: e.input,
139
- type: e.type,
140
- message: e.error,
141
- })),
142
- });
143
- }
144
- ```
145
-
146
- Avoid leaking `result.data` back to the client when rejecting — it might contain transformed sensitive fields.
147
-
148
- 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.
1
+ ---
2
+ name: handle-seal-errors
3
+ 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`.'
4
+ ---
5
+
6
+ # `ValidationResult` — reading errors
7
+
8
+ `validate(schema, data)` never throws. It returns a `ValidationResult`:
9
+
10
+ ```ts
11
+ type ValidationResult = {
12
+ isValid: boolean;
13
+ data: any; // shape after mutators + transformers (the validated value)
14
+ errors: ValidationError[];
15
+ };
16
+
17
+ type ValidationError = {
18
+ type: string; // rule type that failed — "required", "email", "min", "string", ...
19
+ error: string; // resolved message (translated, with attribute substitution)
20
+ input: string; // input field name — "email" or "address.city"
21
+ };
22
+ ```
23
+
24
+ Branch on `isValid` first; reach for `errors[]` only when you need to act on a specific failure.
25
+
26
+ ## Basic flow
27
+
28
+ ```ts
29
+ import { validate, v } from "@warlock.js/seal";
30
+
31
+ const schema = v.object({
32
+ email: v.string().email(),
33
+ age: v.int().min(13).optional(),
34
+ });
35
+
36
+ const result = await validate(schema, input);
37
+
38
+ if (result.isValid) {
39
+ return result.data; // typed as the inferred shape (post-transformer)
40
+ }
41
+
42
+ return {
43
+ status: 422,
44
+ body: { errors: result.errors },
45
+ };
46
+ ```
47
+
48
+ ## Branching on a specific rule
49
+
50
+ ```ts
51
+ const result = await validate(schema, input);
52
+
53
+ if (!result.isValid) {
54
+ const emailMissing = result.errors.find(
55
+ e => e.input === "email" && e.type === "required",
56
+ );
57
+ if (emailMissing) {
58
+ return { redirect: "/signup", reason: "no email" };
59
+ }
60
+
61
+ const ageInvalid = result.errors.find(
62
+ e => e.input === "age" && (e.type === "int" || e.type === "min"),
63
+ );
64
+ if (ageInvalid) {
65
+ return { error: "Age must be 13 or older" };
66
+ }
67
+ }
68
+ ```
69
+
70
+ The `type` field is the **stable** identifier — the message is human-facing and may be localized. Branch on `type`, never on the message string.
71
+
72
+ ## Common rule type names
73
+
74
+ These are the strings you'll see in `error.type`:
75
+
76
+ 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:
77
+
78
+ | Type | Produced by |
79
+ | --- | --- |
80
+ | `required`, `present` | `.required()`, `.present()` (note: `.optional()` has no error type — it clears the required rule) |
81
+ | `requiredIf`, `requiredWith`, `requiredWithout`, `requiredUnless` | conditional required methods |
82
+ | `string`, `number`, `int`, `float`, `boolean`, `scalar`, `object`, `array`, `date` | type guards from `matchesType` |
83
+ | `minLength`, `maxLength`, `betweenLength`, `length` | string / array length rules (`.min`/`.max` on a string alias to `minLength`/`maxLength`) |
84
+ | `min`, `max`, `betweenNumbers` | number range rules (`.min`/`.max`/`.between` on a number) |
85
+ | `email`, `url`, `uuid`, `pattern`, `matches` | string format rules (`.pattern()` → `pattern`) |
86
+ | `literal`, `enum`, `in`, `allowedValues`, `notAllowedValues` | value-membership rules (`.oneOf` aliases `in`; `.notIn`/`.forbids` → `notAllowedValues`; `.allowsOnly` → `allowedValues`) |
87
+ | `instanceof` | `v.instanceof(Ctor)` |
88
+ | `equalsField`, `notEqualsField` | cross-field equality (`.sameAs` → `equalsField`; `.differentFrom` → `notEqualsField`) |
89
+ | `minDate`, `maxDate`, `beforeField`, `afterField`, `today`, `past`, `future`, `weekDay`, `weekend`, `businessDay`, `birthday` | date rules (`.min`/`.max` → `minDate`/`maxDate`; `.before`/`.after` → `beforeField`/`afterField`) |
90
+
91
+ 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.
92
+
93
+ ## Customizing error messages
94
+
95
+ Two layers:
96
+
97
+ ```ts
98
+ v.string().email("Please enter a valid email address").required("Email is required");
99
+ // ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^
100
+ // per-rule override per-rule override
101
+ ```
102
+
103
+ 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.
104
+
105
+ 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`).
106
+
107
+ ## Translation params
108
+
109
+ Rules can stash dynamic substitution params on the context. The default messages reference them:
110
+
111
+ ```
112
+ "The :input must be at least :min characters"
113
+ ```
114
+
115
+ `: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.
116
+
117
+ 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.
118
+
119
+ ## When to throw
120
+
121
+ Don't wrap `validate()` in try/catch for *validation* failures — those land in `result.errors`. The only thing that *throws* is a programming bug:
122
+
123
+ - A rule's callback threw (e.g. you wrote `async validate() { throw new Error(...) }`).
124
+ - A transformer threw on output.
125
+ - A mutator threw on input.
126
+
127
+ Those are bugs — fix them. Don't try/catch them in app code as a way to handle bad input.
128
+
129
+ ## At the framework boundary
130
+
131
+ If you're surfacing seal errors through HTTP / RPC, the typical shape is:
132
+
133
+ ```ts
134
+ if (!result.isValid) {
135
+ return reply.code(422).send({
136
+ error: "validation_failed",
137
+ fields: result.errors.map(e => ({
138
+ field: e.input,
139
+ type: e.type,
140
+ message: e.error,
141
+ })),
142
+ });
143
+ }
144
+ ```
145
+
146
+ Avoid leaking `result.data` back to the client when rejecting — it might contain transformed sensitive fields.
147
+
148
+ 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.
@@ -0,0 +1,21 @@
1
+ ---
2
+ description: "Type-safe, framework-agnostic validation for Warlock.js: schemas, coercion, modifiers, JSON Schema, Standard Schema. Exports `v`, `validate`, `ObjectValidator`, `StringValidator`, `Infer`, reached as `v.object`, `v.string`, `v.array`, `v.enum`, `v.union`. Use for: \"validate this request body\", \"require an email and a min-length password\", \"infer the TypeScript type from a schema\", \"make a field optional or give it a default\", \"generate JSON Schema\", \"write a custom rule\". Not this package: database rules and file validators live in @warlock.js/core; model schemas and persistence are @warlock.js/cascade."
3
+ ---
4
+ # @warlock.js/seal
5
+
6
+ Compose validators from the `v` factory (`v.object({ email: v.string().email() })`), run them with `validate(schema, data)`, and infer static types with `Infer`. Validators chain rules, mutators (transforms) and modifiers; the same schema works as a Standard Schema.
7
+
8
+ ## The 80% path
9
+ 1. Orient: `overview.md`, `seal-basics.md`.
10
+ 2. Choose the right primitive: `pick-seal-primitive.md`.
11
+ 3. Shape nested data: `define-structural-shape.md`.
12
+ 4. Refine with optional, defaults, nullable and transforms: `compose-seal-modifiers.md`.
13
+ 5. Handle failures and messages: `handle-seal-errors.md`.
14
+ 6. Interop: `bridge-standard-schema.md`, `generate-json-schema.md`; extend via `extend-seal-with-plugins.md`.
15
+
16
+ ## Conventions and pitfalls
17
+ - Validation is async; always `await validate(...)`.
18
+ - The package root is framework-agnostic; database-backed rules (unique, exists) and `FileValidator` come from `@warlock.js/core`, not here.
19
+ - Derive types with `Infer` instead of hand-writing parallel interfaces.
20
+ - Mutators change the data (trim, cast); order in the chain matters.
21
+ - Keep reusable schemas in shared modules so controllers and clients agree.