@jarenjs/validate 0.8.4 → 0.34.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 (53) hide show
  1. package/ARCHITECTURE.md +1131 -0
  2. package/LICENSE +21 -0
  3. package/README.md +796 -2
  4. package/dist/types/array.d.ts +2 -0
  5. package/dist/types/bigint.d.ts +1 -0
  6. package/dist/types/combine.d.ts +1 -0
  7. package/dist/types/condition.d.ts +1 -0
  8. package/dist/types/content.d.ts +3 -0
  9. package/dist/types/data.d.ts +7 -0
  10. package/dist/types/dollar-data.d.ts +11 -0
  11. package/dist/types/dynamic-ref.d.ts +44 -0
  12. package/dist/types/enum.d.ts +1 -0
  13. package/dist/types/format.d.ts +21 -0
  14. package/dist/types/index.d.ts +972 -0
  15. package/dist/types/messages.d.ts +142 -0
  16. package/dist/types/normalize.d.ts +107 -0
  17. package/dist/types/number.d.ts +1 -0
  18. package/dist/types/object.d.ts +3 -0
  19. package/dist/types/query-keyword.d.ts +19 -0
  20. package/dist/types/query.d.ts +29 -0
  21. package/dist/types/schema.d.ts +1 -0
  22. package/dist/types/string.d.ts +1 -0
  23. package/dist/types/tools.d.ts +109 -0
  24. package/dist/types/traverse.d.ts +32 -0
  25. package/dist/types/unevaluated.d.ts +12 -0
  26. package/docs/ERROR-MESSAGES.md +251 -0
  27. package/package.json +37 -7
  28. package/src/array.js +610 -0
  29. package/src/bigint.js +108 -0
  30. package/src/combine.js +276 -0
  31. package/src/condition.js +129 -0
  32. package/src/content.js +83 -0
  33. package/src/data.js +101 -0
  34. package/src/dollar-data.js +212 -0
  35. package/src/dynamic-ref.js +121 -0
  36. package/src/enum.js +147 -0
  37. package/src/format.js +108 -0
  38. package/src/index.js +1896 -0
  39. package/src/messages.js +497 -0
  40. package/src/normalize.js +585 -0
  41. package/src/number.js +169 -0
  42. package/src/object.js +848 -0
  43. package/src/query-keyword.js +99 -0
  44. package/src/query.js +85 -0
  45. package/src/schema.js +690 -0
  46. package/src/string.js +164 -0
  47. package/src/tools.js +397 -0
  48. package/src/traverse.js +442 -0
  49. package/src/unevaluated.js +173 -0
  50. package/dist/index.js +0 -1998
  51. package/dist/index.js.map +0 -7
  52. package/dist/index.min.js +0 -2
  53. package/dist/index.min.js.map +0 -7
package/README.md CHANGED
@@ -1,3 +1,797 @@
1
- # Jaren Core Package
1
+ # @jarenjs/validate
2
2
 
3
- **this is a working package in need of documentation**
3
+ The JSON Schema validating compiler at the heart of [Jaren](https://github.com/jklarenbeek/jarenjs). It compiles JSON Schemas into optimized validation functions and fully supports `draft-06`, `draft-07`, `draft 2019-09` and `draft 2020-12` — passing 100% of the official [JSON-Schema-Test-Suite](https://github.com/json-schema-org/JSON-Schema-Test-Suite) for draft-07, 2019-09 and 2020-12.
4
+
5
+ ## Usage
6
+
7
+ ```javascript
8
+ import { JarenValidator } from '@jarenjs/validate';
9
+
10
+ const jaren = new JarenValidator();
11
+
12
+ const validate = jaren.compile({
13
+ type: 'object',
14
+ properties: {
15
+ name: { type: 'string' },
16
+ age: { type: 'integer', minimum: 0 }
17
+ },
18
+ required: ['name']
19
+ });
20
+
21
+ validate({ name: 'John', age: 30 }); // true
22
+ ```
23
+
24
+ Key entry points:
25
+
26
+ - `new JarenValidator(options)` — create a validator instance
27
+ - `.addSchema(schema, key)` — register schemas for `$ref` resolution
28
+ - `.addMetaSchema(schemas, key)` — register (custom) meta-schemas, honoring `$vocabulary`
29
+ - `.addFormats(formats)` — register format validators (see [`@jarenjs/formats`](../formats))
30
+ - `.compile(schema)` — compile a schema into a validation function
31
+
32
+ Highlights:
33
+
34
+ - Annotation-based `unevaluatedProperties`/`unevaluatedItems`
35
+ - Spec-compliant dynamic scope for `$dynamicRef`/`$dynamicAnchor` and `$recursiveRef`/`$recursiveAnchor`
36
+ - Per-document draft handling for cross-draft references
37
+ - `$vocabulary`-aware keyword selection and per-draft `format`/content assertion defaults
38
+ - Instance-data references via the `data` keyword (json-everything data-ref) and Ajv-style `$data`
39
+ - Cross-field assertions via the `$query` extension keyword (a [Jaren JSON Query](../json/docs/QUERY-FORMAT.md) inside the schema)
40
+
41
+ ## 🔑 JSON Schema validation keywords
42
+
43
+ Jaren supports the full set of JSON Schema validation keywords. Here's a quick overview:
44
+
45
+ - JSON data type: `type`, `nullable`, `required`
46
+ - Numbers: `maximum`, `minimum`, `multipleOf`
47
+ - Strings: `maxLength`, `minLength`, `pattern`
48
+ - Content: `contentEncoding`, `contentMediaType`
49
+ - Arrays: `maxItems`, `minItems`, `uniqueItems`, `items`, `prefixItems`, `contains`, `unevaluatedItems`
50
+ - Objects: `maxProperties`, `minProperties`, `required`, `properties`, `patternProperties`, `unevaluatedProperties`
51
+ - All types: `enum`, `const`
52
+ - Compound: `not`, `oneOf`, `anyOf`, `allOf`, `if/then/else`
53
+ - Meta: `$schema`, `$id`, `$ref`, `$anchor`, `$dynamicRef`/`$dynamicAnchor`, `$recursiveRef`/`$recursiveAnchor`, `$vocabulary`
54
+ - **Non-standard**: `data` (json-everything's [data-ref](https://docs.json-everything.net/schema/examples/data-ref/) proposal), Ajv-style `$data` references, and the [`$query` keyword](#query--cross-field-assertions) below
55
+
56
+ <details>
57
+ <summary>🔥 For a complete list of supported keywords and their implementation status, click here</summary>
58
+
59
+ ### 🔑 JSON data type
60
+
61
+ - type
62
+ - nullable | _(OpenAPI)_
63
+ - required | _as boolean (OpenAPI)_
64
+
65
+ ### 🔑 Keywords for numbers
66
+
67
+ - maximum / minimum<br />or exclusiveMaximum / exclusiveMinimum
68
+ - multipleOf
69
+
70
+ BigInt instance values are supported too: `maximum`/`minimum`/`exclusiveMaximum`/`exclusiveMinimum` and `multipleOf` compile dedicated BigInt comparators when the data is a `bigint`.
71
+
72
+ ### 🔑 Keywords for strings
73
+
74
+ - maxLength / minLength
75
+ - pattern
76
+
77
+ ### 🔑 Keywords for content
78
+
79
+ - contentEncoding | asserts in `draft7`, annotation-only from `draft2019` on (spec default), controlled by the `contentValidation` option
80
+ - contentMediaType | same assertion defaults; `application/json` content is parse-checked
81
+ - ❌ contentSchema | annotation only (never asserted)
82
+
83
+ ### 🔑 Keywords for format
84
+
85
+ - format
86
+ - formatMinimum / formatMaximum<br />or formatExclusiveMinimum / formatExclusiveMaximum
87
+
88
+ ### 🔑 Keywords for array
89
+
90
+ - maxItems / minItems
91
+ - uniqueItems
92
+ - items
93
+ - items | as schema or tuple _deprecated in `draft2020`_
94
+ - items | as schema only _new `draft2020`_
95
+ - prefixItems | as tuple _new `draft2020`_
96
+ - additionalItems | as schema _deprecated in `draft2020`_
97
+ - contains
98
+ - maxContains / minContains | _new `draft2019`_
99
+ - unevaluatedItems | _new `draft2019`_
100
+
101
+ ### 🔑 Keywords for object
102
+
103
+ - maxProperties / minProperties
104
+ - required | _as array!_
105
+ - properties
106
+ - patternProperties
107
+ - additionalProperties
108
+ - dependencies | _deprecated in `draft2019`_
109
+ - dependentRequired | _new `draft2019`_
110
+ - dependentSchemas | _new `draft2019`_
111
+ - propertyNames
112
+ - unevaluatedProperties | _new `draft2019`_
113
+ - ❌ [propertyDependencies](https://github.com/json-schema-org/json-schema-spec/blob/main/proposals/propertyDependencies.md)
114
+
115
+ ### 🔑 Keywords for all types
116
+
117
+ - enum
118
+ - const
119
+
120
+ ### 🔑 Compound keywords
121
+
122
+ - not
123
+ - oneOf
124
+ - anyOf
125
+ - allOf
126
+ - if / then / else
127
+
128
+ See also:
129
+ - [Schema Composition](https://json-schema.org/understanding-json-schema/reference/combining)
130
+ - [Applying Subschemas Conditionally](https://json-schema.org/understanding-json-schema/reference/conditionals)
131
+
132
+ ### 🔑 Meta keywords
133
+
134
+ - $schema | used for draft detection, vocabulary selection and cross-draft references
135
+ - $id
136
+ - $ref
137
+ - $anchor
138
+ - $recursiveRef | _new `draft2019` &amp; deprecated in `draft2020`_
139
+ - $recursiveAnchor | _new `draft2019` &amp; deprecated in `draft2020`_
140
+ - $dynamicRef | _new `draft2020`_
141
+ - $dynamicAnchor | _new `draft2020`_
142
+ - $data | _(Ajv specific)_
143
+ - [$vocabulary](https://github.com/json-schema-org/json-schema-spec/blob/main/proposals/vocabularies.md) | _new `draft2019`_ - a custom metaschema that omits the validation vocabulary turns keywords like `type` and `minimum` into annotations; a metaschema that declares the `format-assertion` vocabulary turns format assertion on
144
+
145
+ ### 🔑 Non-standard keywords
146
+
147
+ - `data` | json-everything's [data-ref](https://docs.json-everything.net/schema/examples/data-ref/) proposal
148
+
149
+ The `data` keyword allows you to reference values from the instance being validated, enabling dynamic constraints based on other parts of the data.
150
+
151
+ **Example - Requiring B >= A:**
152
+ ```json
153
+ {
154
+ "type": "object",
155
+ "properties": {
156
+ "A": { "type": "number" },
157
+ "B": {
158
+ "type": "number",
159
+ "data": {
160
+ "minimum": "/A"
161
+ }
162
+ }
163
+ }
164
+ }
165
+ ```
166
+ - Passes: `{ "A": 5, "B": 10 }` (10 >= 5)
167
+ - Fails: `{ "A": 15, "B": 10 }` (10 < 15)
168
+
169
+ **Example - Enum from instance array:**
170
+ ```json
171
+ {
172
+ "type": "object",
173
+ "properties": {
174
+ "color": {
175
+ "data": {
176
+ "enum": "/validColors"
177
+ }
178
+ },
179
+ "validColors": {
180
+ "type": "array",
181
+ "items": { "type": "string" }
182
+ }
183
+ }
184
+ }
185
+ ```
186
+
187
+ **Supported keywords within `data`:**
188
+ - Number constraints: `minimum`, `maximum`, `exclusiveMinimum`, `exclusiveMaximum`, `multipleOf`
189
+ - String constraints: `minLength`, `maxLength`, `pattern`, `format`
190
+ - Array constraints: `minItems`, `maxItems`
191
+ - Object constraints: `minProperties`, `maxProperties`
192
+ - Value constraints: `enum`, `const`
193
+
194
+ Both absolute JSON Pointers (e.g., `/A`, `/limits/min`) and relative JSON Pointers (e.g., `0/parent`, `1/sibling`) are supported.
195
+
196
+ - `$data` | Ajv-style instance references (`{ "minimum": { "$data": "1/limit" } }`) — supports every keyword the `data` list above supports, plus `uniqueItems` and `required`. Takes the same three reference forms: empty for the whole instance, an absolute JSON Pointer (`/limit`), or a Relative JSON Pointer (`1/limit`). Refs compile once at schema compile time through the compiled pointer engine of `@jarenjs/json`, and a reference that cannot be compiled **fails the compile** rather than quietly resolving to nothing — a constraint that disables itself is worse than a rejected schema.
197
+
198
+ - `$query` | Jaren's cross-field assertion keyword — see [below](#query--cross-field-assertions)
199
+
200
+ ### 🔑 Miscellaneous keywords
201
+
202
+ - ❌ strict
203
+ - ❌ strictFormat
204
+ - ❌ strictTuple
205
+ - errorMessage | author-supplied messages that override text (never structure) — see [Error messages &amp; i18n](#error-messages--i18n)
206
+ - definitions | used by initial schema traversal _deprecated in `draft2019`_
207
+ - $defs | used by initial schema traversal _new `draft2019`_
208
+ - components | _(OpenAPI)_
209
+
210
+ </details>
211
+
212
+ ## 🛠️ Notable capabilities
213
+
214
+ ### 👉 Modelling Inheritance with JSON Schema
215
+
216
+ Jaren fully supports `unevaluatedProperties`, so the inheritance patterns from the [Modelling Inheritance](https://json-schema.org/blog/posts/modelling-inheritance) blog post work out of the box. Annotations flow from `properties`, `patternProperties`, `additionalProperties` and every in-place applicator (`allOf`/`anyOf`/`oneOf`/`if-then-else`/`$ref`/`dependentSchemas`), with annotations from failed branches correctly discarded.
217
+
218
+ See also:
219
+ - [json-schema-core](https://json-schema.org/draft/2020-12/json-schema-core#name-unevaluatedproperties)
220
+ - [Combining unevaluatedProperties and ref: # #375](https://github.com/orgs/json-schema-org/discussions/375)
221
+
222
+ ### 👉 Express array constraints more cleanly
223
+
224
+ Jaren fully supports `unevaluatedItems`, covering the array patterns from the 2020-12 [release notes](https://json-schema.org/draft/2020-12/release-notes#contains-and-unevaluateditems): items evaluated by `items`, `prefixItems`, `additionalItems` and (in 2020-12) `contains` are tracked, and everything left over is validated by the `unevaluatedItems` schema.
225
+
226
+ ### 👉 Using Dynamic References to Support Generic Types
227
+
228
+ Jaren fully supports `$dynamicRef`/`$dynamicAnchor` (2020-12) and `$recursiveRef`/`$recursiveAnchor` (2019-09), including the generic-type patterns from the [dynamicRef and generics](https://json-schema.org/blog/posts/dynamicref-and-generics) blog post. Resolution follows the specification's dynamic-scope rules: entering a schema resource brings all of its dynamic anchors into scope, and a `$dynamicRef` resolves to the anchor in the outermost resource of the dynamic scope.
229
+
230
+ See also:
231
+ - [Understanding lexical dynamic scopes](https://json-schema.org/blog/posts/understanding-lexical-dynamic-scopes)
232
+ - [$dynamicRef and $dynamicAnchor](https://json-schema.org/draft/2020-12/release-notes#dollardynamicref-and-dollardynamicanchor)
233
+
234
+ ### 👉 Runtime schema manipulation of constraints
235
+
236
+ Jaren supports the `data-ref` proposal from json-everything through the `data` keyword, plus Ajv-style `$data` references. Both allow a schema constraint to take its value from the instance being validated:
237
+
238
+ - Absolute JSON Pointers (e.g., `/A`, `/limits/min`)
239
+ - Relative JSON Pointers (e.g., `0/parent`, `1/sibling`)
240
+ - All common constraint keywords: `minimum`, `maximum`, `exclusiveMinimum`, `exclusiveMaximum`, `multipleOf`, `minLength`, `maxLength`, `pattern`, `format`, `enum`, `const`, `minItems`, `maxItems`, `minProperties`, `maxProperties`
241
+
242
+ The refs compile once at schema compile time through the compiled pointer engine of [`@jarenjs/json`](../json) and resolve allocation-free per validation.
243
+
244
+ See also:
245
+ - [$data](https://github.com/json-schema-org/json-schema-spec/issues/51)
246
+ - [Ajv $data spec](https://github.com/ajv-validator/ajv/tree/master/spec/extras/%24data)
247
+ - [data-ref](https://docs.json-everything.net/schema/examples/data-ref/)
248
+
249
+ ### 👉 Vocabularies and cross-draft references
250
+
251
+ A schema's `$schema` declaration is honored per document: referenced documents that declare a different draft are processed with that draft's keyword set (a draft-07 document ignores `dependentRequired`; a 2019-09 document ignores `prefixItems`). Custom metaschemas with `$vocabulary` are respected — omitting the validation vocabulary turns validation keywords into annotations, and declaring `format-assertion` turns format assertion on.
252
+
253
+ ## `$query` — cross-field assertions
254
+
255
+ `$query` is a **Jaren extension keyword**: its value is a
256
+ [Jaren JSON Query](../json/docs/QUERY-FORMAT.md) document, compiled once at
257
+ schema compile time and evaluated per validation against the current instance
258
+ location. The instance is valid when the query result's
259
+ [effective boolean value](../json/docs/QUERY-FORMAT.md#22-effective-boolean-value-ebv)
260
+ is true. This gives JSON Schema the class of constraint it is notoriously bad
261
+ at — cross-field arithmetic, ordering, aggregate consistency, quantification —
262
+ through the query engine that already sits underneath the stack. Other
263
+ validators treat `$query` as an unknown-keyword annotation, so schemas using
264
+ it stay portable; the constraint simply only asserts here.
265
+
266
+ An invoice whose `total` must equal the sum of its line amounts:
267
+
268
+ ```javascript
269
+ const validate = jaren.compile({
270
+ type: 'object',
271
+ properties: {
272
+ lines: { type: 'array', items: { type: 'object' } },
273
+ total: { type: 'number' }
274
+ },
275
+ $query: { $eq: ['$.total', { $sum: '$.lines[*].amount' }] }
276
+ });
277
+
278
+ validate({ lines: [{ amount: 12.5 }, { amount: 7.5 }], total: 20 }); // true
279
+ validate({ lines: [{ amount: 12.5 }, { amount: 7.5 }], total: 21 }); // false
280
+ ```
281
+
282
+ Date ordering (`$le` compares strings by code points, QUERY-FORMAT §8.4 —
283
+ exactly right for ISO dates):
284
+
285
+ ```javascript
286
+ jaren.compile({ $query: { $le: ['$.start', '$.end'] } });
287
+ ```
288
+
289
+ Quantification over items:
290
+
291
+ ```javascript
292
+ jaren.compile({
293
+ $query: { $every: { l: '$.lines[*]' }, $satisfies: { $gt: ['$l.qty', 0] } }
294
+ });
295
+ ```
296
+
297
+ Two external parameters are bound on every evaluation: `root` — the instance
298
+ root — and `path` — the current instance location as a JSON pointer string.
299
+ (The JSLT template layer reserves the same two names with one twist: its
300
+ matching language is JSONPath, so its `path` is an RFC 9535 *normalized
301
+ path*, not a pointer — see [JSLT-FORMAT §8.2](../json/docs/JSLT-FORMAT.md).)
302
+ So a subschema can reach across the document:
303
+
304
+ ```javascript
305
+ jaren.compile({
306
+ type: 'object',
307
+ properties: {
308
+ lines: {
309
+ type: 'array',
310
+ items: {
311
+ type: 'object',
312
+ // every line's currency must match the document-level currency
313
+ $query: { $eq: ['$.currency', '$root.currency'] }
314
+ }
315
+ }
316
+ }
317
+ });
318
+ ```
319
+
320
+ Semantics and composition:
321
+
322
+ - `$query` works at **any subschema level** and composes like every other
323
+ keyword: under `properties`/`items` the query's `$` is that location's
324
+ value, `$path` its pointer (`/lines/0`, ...), `$root` the whole document.
325
+ - A bare JSONPath string is the degenerate query: `{ "$query": "$.approved" }`
326
+ asserts the **EBV** of that member, not its mere existence: existence would
327
+ let `$.approved` pass on a literal `false`, exactly the case the constraint
328
+ means to reject (missing → empty sequence → false either way).
329
+ - `$query` is a **validation keyword**, so under 2019-09 and 2020-12 it
330
+ asserts as a sibling of `$ref` (both apply together). Under draft-07 the
331
+ `$ref`-overrides-siblings rule stands, so a `$query` written beside a `$ref`
332
+ is ignored on that node.
333
+ - Query **runtime** errors (`JQ2xxx` — e.g. arithmetic on a non-number, the
334
+ EBV of a multi-item result) are validation **failures**, never throws; in
335
+ `collectErrors` mode the error params carry the `code` and the query
336
+ `docPath`. Malformed query documents and free externals other than
337
+ `root`/`path` fail fast at `compile()`.
338
+ - Schema literals inside the query (`$valid`/`$assert`/`$as`,
339
+ QUERY-FORMAT §8.11) compile against the **same validator instance**, so
340
+ their `$ref`s resolve to your `addSchema` registrations. The bridge
341
+ (`createTypeTestCompiler`) accepts nothing (a fresh default instance), a
342
+ `JarenValidator` instance, or a zero-arg factory, and probes the compiled
343
+ validator's return shape **once per schema literal** — so even a
344
+ `collectErrors` instance is unwrapped into a boolean predicate.
345
+
346
+ ## Compatibility settings
347
+
348
+ Five options decide answers that differ between validators, between JSON
349
+ Schema drafts, or between Jaren and the library you are migrating from. Each
350
+ one is a deliberate default, and each one is worth setting explicitly in a
351
+ shared factory rather than inheriting.
352
+
353
+ | Option | Default | What it decides |
354
+ |---|---|---|
355
+ | `collectErrors` | `false` | Whether the compiled validator returns a boolean or `{ valid, errors }` |
356
+ | `skipErrors` | `!collectErrors` | Whether validation stops at the first failure |
357
+ | `useGrapheme` | **`true`** | Whether `minLength`/`maxLength` count grapheme clusters or UTF-16 code units |
358
+ | `formatAssertion` | auto by draft | Whether `format` asserts or only annotates |
359
+ | `contentValidation` | auto by draft | Whether `contentEncoding`/`contentMediaType` assert |
360
+
361
+ ### The return shape is `collectErrors`
362
+
363
+ `collectErrors` is the *only* switch between the two return shapes, and it is
364
+ off by default:
365
+
366
+ ```javascript
367
+ new JarenValidator().compile(schema)(data);
368
+ // => true | false
369
+
370
+ new JarenValidator({ collectErrors: true }).compile(schema)(data);
371
+ // => { valid: false, errors: [ /* ValidationError */ ] }
372
+ ```
373
+
374
+ Setting `collectErrors: true` implies `skipErrors: false` (collecting errors
375
+ means recording them), so you do not need to set both. Set `skipErrors`
376
+ yourself only to keep first-failure short-circuiting while still collecting.
377
+
378
+ There is **no `validator.errors` property**. Errors arrive in the returned
379
+ object and nowhere else, which is what makes a compiled validator reentrant
380
+ and safe to share across concurrent requests.
381
+
382
+ Each `ValidationError` carries six fields:
383
+
384
+ ```javascript
385
+ {
386
+ keyword: 'format', // the JSON Schema keyword that failed
387
+ instancePath: '/email', // RFC 6901 JSON Pointer into the DATA
388
+ schemaPath: 'https://…#/properties/email', // absolute URI into the SCHEMA
389
+ params: { format: 'email' }, // raw structured values, never prose
390
+ msgid: 'format', // stable catalog key for i18n
391
+ message: 'must match format "email"'
392
+ }
393
+ ```
394
+
395
+ Two notes for anyone diffing this against another validator's output. The
396
+ data location is `instancePath`, a **JSON Pointer string** — not the dotted
397
+ `dataPath` of Ajv v6, and not an array path. `parseJSONPointerPath` from
398
+ [`@jarenjs/json`](../json/README.md) converts it to the `(string|number)[]`
399
+ path shape that Zod's `issue.path` and most diffing tools use, narrowing
400
+ canonical array indexes to numbers:
401
+
402
+ ```javascript
403
+ import { parseJSONPointerPath } from '@jarenjs/json';
404
+
405
+ parseJSONPointerPath('/items/0/id'); // ['items', 0, 'id']
406
+ ```
407
+
408
+ And for `additionalProperties: false`, Jaren points `instancePath` at the
409
+ offending member (`/nested/extra`) where Ajv points at the parent object —
410
+ deliberate, and spec-truer.
411
+
412
+ **Collected errors are exhaustive across independent keywords.** Every
413
+ keyword that can fail independently reports its own fault, rather than the
414
+ first failure hiding the rest: keyword groups on one node (`enum` beside
415
+ `minLength`), `minProperties` beside `required`, a numeric bound beside
416
+ `multipleOf`, `minItems` beside `uniqueItems` and beside failing items, every
417
+ `allOf` branch, independent applicator groups, and every absent `required`
418
+ property. Boolean mode still stops at the first failure — that is the whole
419
+ point of it — so the two modes deliberately differ in how much work they do:
420
+
421
+ ```javascript
422
+ const contract = {
423
+ type: 'object',
424
+ properties: { slug: { type: 'string', minLength: 3, pattern: '^[a-z]+$' } },
425
+ required: ['name', 'slug'],
426
+ };
427
+
428
+ collecting.compile(contract)({ slug: '!' }).errors;
429
+ // required at '', minLength at '/slug', pattern at '/slug' — three issues
430
+ ```
431
+
432
+ **A speculative applicator never reports.** An `anyOf`/`oneOf` branch that was
433
+ not the one that matched, the subschema of a `not`, an `if` condition, and a
434
+ `contains` candidate that is not the match are all probes — the document was
435
+ never required to satisfy them, so their failures are rolled back rather than
436
+ handed to your caller. What survives is what genuinely explains the failure:
437
+ if *no* `anyOf` branch matched, every branch's errors are kept, because then
438
+ they are the reason.
439
+
440
+ ```javascript
441
+ collecting.compile({
442
+ type: 'string', minLength: 5,
443
+ anyOf: [{ type: 'number' }, { type: 'string' }],
444
+ })('x').errors;
445
+ // [minLength] — not a type error from the number branch that was never required
446
+ ```
447
+
448
+ A `required` error points at the **owning object**, not at the absent member
449
+ (there is no location for something that is not there); the missing name is in
450
+ `params.missingProperty`, which is what an adapter appends to build a
451
+ Zod-style path.
452
+
453
+ ### String lengths count graphemes by default
454
+
455
+ `useGrapheme` defaults to **`true`**, so `minLength`/`maxLength` count
456
+ user-perceived characters. Most other validators — and the JSON Schema
457
+ specification itself — count UTF-16 code units:
458
+
459
+ ```javascript
460
+ const family = '👨‍👩‍👧‍👦'; // 1 grapheme cluster, 11 UTF-16 code units
461
+
462
+ new JarenValidator().compile({ type: 'string', maxLength: 2 })(family);
463
+ // => true (1 grapheme)
464
+
465
+ new JarenValidator({ useGrapheme: false })
466
+ .compile({ type: 'string', maxLength: 2 })(family);
467
+ // => false (11 code units)
468
+ ```
469
+
470
+ **If you are migrating from a validator that counts code units, set
471
+ `useGrapheme: false`,** or strings containing emoji, combining marks, flags
472
+ or astral-plane characters will silently change validity at the boundaries.
473
+ Grapheme mode is not expensive — ASCII takes a `str.length` fast path and
474
+ most Unicode takes a code-point count; only cluster-forming strings reach
475
+ `Intl.Segmenter` — so the default is about correctness, not speed, and
476
+ switching to it later is a product decision rather than a performance one.
477
+
478
+ ### `format` and content assertion follow the draft
479
+
480
+ Both are annotation-only in the drafts that say so, and both take an explicit
481
+ override:
482
+
483
+ - **`format`** asserts through draft 2019-09 and is annotation-only from
484
+ draft 2020-12 on, per spec. It also turns on automatically when the
485
+ schema's meta-schema declares the `format-assertion` vocabulary.
486
+ - **`contentEncoding`/`contentMediaType`** assert through draft-07 and are
487
+ annotation-only from 2019-09 on.
488
+
489
+ ```javascript
490
+ const schema = { $schema: 'https://json-schema.org/draft/2020-12/schema',
491
+ type: 'string', format: 'email' };
492
+
493
+ new JarenValidator().addFormats(formats.stringFormats)
494
+ .compile(schema)('nope'); // => true (annotation only)
495
+
496
+ new JarenValidator({ formatAssertion: true }).addFormats(formats.stringFormats)
497
+ .compile(schema)('nope'); // => false (asserted)
498
+ ```
499
+
500
+ ### Formats are never registered implicitly — and an unregistered one is an error
501
+
502
+ `@jarenjs/validate` does not depend on `@jarenjs/formats`, so a fresh
503
+ validator has an **empty format registry**. Registering the groups you use is
504
+ the whole setup:
505
+
506
+ ```javascript
507
+ import * as formats from '@jarenjs/formats';
508
+
509
+ const jaren = new JarenValidator()
510
+ .addFormats(formats.stringFormats) // email, uri, uuid, hostname, iban, ...
511
+ .addFormats(formats.dateTimeFormats) // date-time, date, time, duration
512
+ .addFormats(formats.numberFormats) // int32, double, decimal, ...
513
+ .addFormats(formats.jsonFormats) // json-pointer, json-path, regex, ...
514
+ .addFormats(formats.geoFormats); // geohash, lat-long, ...
515
+ ```
516
+
517
+ **An unregistered format name asserts nothing, and by default says nothing** —
518
+ that is the specification's rule ("implementations MUST NOT fail validation or
519
+ cease processing due to an unknown format attribute"), and it is the default
520
+ because a library has to be able to compile a schema it did not write. The
521
+ cost is a real trap: a `format: 'date-time'` you believed was checking
522
+ timestamps, checking nothing, and looking exactly like one that works.
523
+
524
+ **`unknownFormats: 'error'` turns that into a compile-time throw.** Set it for
525
+ schemas you own:
526
+
527
+ ```javascript
528
+ new JarenValidator({ unknownFormats: 'error' })
529
+ .compile({ type: 'string', format: 'date-time' });
530
+ // => Error: Unknown format 'date-time': no compiler is registered for it,
531
+ // so this schema would accept every value for that keyword. Register one
532
+ // (addFormats(dateTimeFormats) …), or pass { unknownFormats: 'ignore' } …
533
+ ```
534
+
535
+ Instance validation is untouched either way — **no data is ever invalidated by
536
+ an unknown format**, so `'error'` stays inside the specification's letter: it
537
+ reports to the schema's *author*, at compile time, where a missing
538
+ registration is a fact about the setup rather than about the data. The
539
+ official suite's `optional/format/unknown.json` (unknown format, assertion
540
+ forced on, everything must validate) passes on the default and is the one
541
+ thing `'error'` would fail, which is why it is opt-in.
542
+
543
+ This repository sets `'error'` for every schema it ships, and a gate
544
+ (`test/validate/our-schema-formats.test.js`) fails if any of them names a
545
+ format `@jarenjs/formats` does not implement. It is worth copying: the
546
+ published `jaren-query` grammar declared `format: "json-path"` from the day it
547
+ was written, nothing that compiled it registered `jsonFormats`, and so no path
548
+ string was ever checked by that keyword. Nothing failed. That is the shape of
549
+ this bug.
550
+
551
+ Where **not** to reach for `'error'`: a schema a user hands you. Refusing to
552
+ compile a stranger's valid schema over a format you happen not to implement is
553
+ your problem presented as theirs — this repo keeps the default for the
554
+ validator playground's input and for project files in the studio, and uses
555
+ `'error'` only for its own grammars. `@jarenjs/ai`'s ledger is the other
556
+ exception: it cannot depend on `@jarenjs/formats` at all, so it stays on the
557
+ default and puts a `pattern` beside the `format` to do the enforcing.
558
+
559
+ Two things `'error'` deliberately leaves alone. It does not fire where
560
+ `format` is annotation-only anyway (draft 2020-12 without the format-assertion
561
+ vocabulary) — nothing is lost there, so there is nothing to report. And
562
+ **meta-schemas** are exempt: every JSON Schema meta-schema declares
563
+ `format: "uri-reference"` on `$id`, and nobody registers formats in order to
564
+ check that a *schema* is well-formed, so `addMetaSchema` compiles with
565
+ `unknownFormats: 'ignore'` regardless.
566
+
567
+ Registering a format with something that is **not a compiler** throws under
568
+ either setting, because that mistake is never intentional:
569
+
570
+ ```javascript
571
+ new JarenValidator().addFormats(formats.formatTesters) // testers, not compilers
572
+ .compile({ type: 'string', format: 'email' });
573
+ // => Error: Format 'email' failed to compile: … Register the format COMPILERS
574
+ // (stringFormats, dateTimeFormats, …) rather than the raw testers.
575
+ ```
576
+
577
+ `formatTesters` and `stringFormats` are both objects full of functions, so the
578
+ mistake registers cleanly and then checks nothing. Only the **compilers** take
579
+ `(schemaObj, jsonSchema)` and return the per-value validator.
580
+
581
+ Registration never overwrites an existing name, so register your own
582
+ overrides *before* a bundled group if you want them to win.
583
+
584
+ ### Recipe: migrating from Zod
585
+
586
+ Zod counts UTF-16 code units, always reports every issue, and always
587
+ asserts formats. This factory reproduces those three answers:
588
+
589
+ ```javascript
590
+ import { JarenValidator } from '@jarenjs/validate';
591
+ import * as formats from '@jarenjs/formats';
592
+
593
+ export const jaren = new JarenValidator({
594
+ collectErrors: true, // { valid, errors } instead of a boolean
595
+ useGrapheme: false, // UTF-16 code units, like Zod's .min()/.max()
596
+ formatAssertion: true, // assert format even under draft 2020-12
597
+ contentValidation: true, // assert contentEncoding/contentMediaType
598
+ })
599
+ .addFormats(formats.stringFormats)
600
+ .addFormats(formats.dateTimeFormats);
601
+ ```
602
+
603
+ What this recipe does **not** give you is Zod's output normalization: a
604
+ compiled validator is a pure predicate and never modifies its input. That
605
+ boundary does not move — but the normalization itself ships beside it, as a
606
+ separately compiled pass. See [Normalization](#normalization) below.
607
+
608
+ ## Normalization
609
+
610
+ Validation answers a question; it does not change your data. When you need
611
+ the *normalized output* that a parse-and-transform library returns —
612
+ materialized defaults, decoded transport values, stripped unknown members —
613
+ compile a normalizer from the same schema and run it first:
614
+
615
+ ```javascript
616
+ import { compileNormalizer } from '@jarenjs/validate/normalize';
617
+
618
+ const normalize = compileNormalizer(schema, {
619
+ useDefaults: true, // fill absent properties from `default`, recursively
620
+ removeAdditional: true, // drop members the schema forbids
621
+ coerceTypes: true, // '9000' -> 9000 where the schema says integer
622
+ trimStrings: true, // ' jaren ' -> 'jaren'
623
+ });
624
+
625
+ const shaped = normalize(input); // a NEW value; `input` is untouched
626
+ const result = validate(shaped);
627
+ ```
628
+
629
+ Every option is **off by default** — each one changes what your data means,
630
+ so each is a decision you make rather than one you inherit.
631
+ `compileNormalizer(schema)` with no options is the identity.
632
+
633
+ ### Per-field control, not just a global switch
634
+
635
+ `useDefaults`, `coerceTypes` and `trimStrings` each accept a **predicate**
636
+ `(schemaNode) => boolean` in place of a boolean. It runs once per node during
637
+ compilation, so it decides per field and costs nothing at runtime:
638
+
639
+ ```javascript
640
+ const normalize = compileNormalizer(schema, {
641
+ trimStrings: (node) => node['x-trim'] === true,
642
+ });
643
+ ```
644
+
645
+ This matters more than it looks. A contract typically trims a handful of its
646
+ string fields and must leave the rest byte-for-byte as supplied — a timezone
647
+ name, a deliberately padded identifier, a field whose whitespace is data.
648
+ `trimStrings: true` would quietly rewrite all of them. The predicate reads
649
+ whatever you put in the schema (an `x-` annotation, a `format`, a name
650
+ pattern), which keeps the policy next to the field it governs and portable
651
+ with the schema.
652
+
653
+ ### It never mutates, and it shares what it can
654
+
655
+ Ajv's `useDefaults`/`coerceTypes` write into the document you hand them.
656
+ This does not: it is a copy-on-write walk, so the input is exactly as it was
657
+ afterwards — you can normalize a frozen document, or keep the original as an
658
+ audit record, without defensive copying.
659
+
660
+ The other half of that design is identity. A subtree that needs no change is
661
+ returned by reference, and a document that needs no change at all returns the
662
+ input itself:
663
+
664
+ ```javascript
665
+ normalize(alreadyClean) === alreadyClean; // true
666
+ ```
667
+
668
+ So a no-op costs nothing, and downstream memoization keyed on identity keeps
669
+ working.
670
+
671
+ ### What it walks, and what it deliberately does not
672
+
673
+ Walked: `properties`, `patternProperties`, `additionalProperties`,
674
+ `items`/`prefixItems`/`additionalItems` (both tuple spellings), same-document
675
+ `$ref` including recursive ones, and `allOf` — whose branches compose, with
676
+ stripping disabled inside them because one branch cannot know what a sibling
677
+ declares.
678
+
679
+ Member schemas **compose the way JSON Schema says they do**: a member covered
680
+ by `properties` *and* by one or more matching `patternProperties` is
681
+ normalized by every one of them, in that order, and `additionalProperties`
682
+ applies only to a member nothing else covered. A materialized `default` runs
683
+ through its own property's normalizer too, so a defaulted `{ port: '8080' }`
684
+ is shaped exactly like a supplied one rather than keeping its string.
685
+
686
+ Not walked: `anyOf`, `oneOf`, `if`/`then`/`else`, `not`. Which branch applies
687
+ is only known after validating, and normalizing under one branch can change
688
+ which branch validates — so guessing would be worse than declining. There is
689
+ also no transform hook: an arbitrary transform is application code, not
690
+ schema semantics, and belongs on your side of the boundary.
691
+
692
+ ### The coercion table
693
+
694
+ Coercion exists to decode transport encodings — query strings, form fields,
695
+ environment variables, CSV cells — where everything arrives as a string. It
696
+ is conservative on purpose: a value it cannot convert unambiguously is passed
697
+ through unchanged, so validation reports the type error instead of the
698
+ normalizer hiding it.
699
+
700
+ | Declared `type` | Converted | Left alone |
701
+ |---|---|---|
702
+ | `number` | a string that is exactly a JSON number (`'1e5'`, `'-0.5'`) | `'0x10'`, `'1_000'`, `''`, `'Infinity'` |
703
+ | `integer` | as `number`, when the result is integral | `'4.5'` |
704
+ | `boolean` | `'true'`, `'false'` | `'yes'`, `'1'`, `0` |
705
+ | `string` | finite numbers and booleans | objects, arrays, `null` |
706
+ | `null` | `'null'` | `''`, `0`, `false` |
707
+
708
+ A union `type` (`['string', 'number']`) gives no single target, so coercion is
709
+ skipped rather than guessed. Trimming runs before coercion, so `' 42 '`
710
+ decodes for an integer field.
711
+
712
+ ## Error messages & i18n
713
+
714
+ Every collected error carries a stable message key (`msgid`) and raw
715
+ structured `params` next to its rendered `message` — prose is produced at
716
+ report time from a **catalog** (a plain object of closures), never on the
717
+ validation hot path. The normative spec is
718
+ [ERROR-MESSAGES.md](./docs/ERROR-MESSAGES.md).
719
+
720
+ ### The `errorMessage` keyword
721
+
722
+ Author-supplied messages that override *text*, never structure (no error
723
+ aggregation or removal — the deliberate divergence from ajv-errors, whose
724
+ two official plugins are mutually incompatible). Registered at schema
725
+ compile time, resolved only over the failed set at report time — zero
726
+ validation-time cost.
727
+
728
+ ```javascript
729
+ // string form: covers the node AND its subtree (quiet oneOf noise)
730
+ { "type": "string", "minLength": 8, "errorMessage": "Use at least 8 characters" }
731
+
732
+ // map form: per failing keyword, '_' as the node catch-all
733
+ { "type": "integer", "minimum": 18,
734
+ "errorMessage": { "minimum": "Must be an adult", "_": "Invalid age" } }
735
+
736
+ // required: per missing property
737
+ { "required": ["vatId", "name"],
738
+ "errorMessage": { "required": { "vatId": "VAT id is required for business accounts" } } }
739
+
740
+ // $query: EBV-false default and per runtime code
741
+ { "$query": { "$le": ["$.start", "$.end"] },
742
+ "errorMessage": { "$query": {
743
+ "default": "start must not be after end",
744
+ "JQ2003": "start/end must be single values" } } }
745
+ ```
746
+
747
+ Templates interpolate params: `"errorMessage": "needs {limit} characters"`.
748
+
749
+ ### `$msgid` — translatable authored messages
750
+
751
+ An `errorMessage` (or forms `x-form.message`) may be a **MessageSpec**
752
+ object pointing into the catalog space instead of inline text — that keeps
753
+ schema-authored messages translatable:
754
+
755
+ ```javascript
756
+ { "type": "number",
757
+ "errorMessage": { "type": {
758
+ "$msgid": "checkout.total-invalid", // catalog key
759
+ "message": "Total must be a number" // fallback when no catalog covers it
760
+ } } }
761
+ ```
762
+
763
+ ### Locale packs
764
+
765
+ ```javascript
766
+ import { JarenValidator, compileMessageCatalog, localizeErrors } from '@jarenjs/validate';
767
+ import { nl } from '@jarenjs/locales';
768
+
769
+ const catalog = compileMessageCatalog(nl);
770
+ const validator = new JarenValidator({ collectErrors: true });
771
+ const validate = validator.compile({ type: 'string', minLength: 2 });
772
+
773
+ const result = validate('x'); // English messages
774
+ localizeErrors(result.errors, catalog); // Dutch, re-rendered from msgid + params
775
+ // 'mag niet minder dan 2 tekens bevatten'
776
+ ```
777
+
778
+ The locale is chosen at **report time**; switching locale never recompiles
779
+ anything. Catalog entries are plain functions, so packs use the platform's
780
+ `Intl.PluralRules`/`Intl.NumberFormat` — see
781
+ [`@jarenjs/locales`](../locales/README.md) for the pack-authoring guide.
782
+
783
+ ### `messages: false`
784
+
785
+ For applications that render exclusively through `localizeErrors` (or
786
+ their own resolver), skip English rendering entirely:
787
+
788
+ ```javascript
789
+ const validator = new JarenValidator({ collectErrors: true, messages: false });
790
+ // errors arrive with message: '', params and msgid still set
791
+ ```
792
+
793
+ ## Development
794
+
795
+ Unit tests live in `test/validate/` at the repository root (`npm run test:validate`). Performance against Ajv is measured over the official test suite with the [benchmark workspace](../../benchmark/README.md) (`node benchmark/profiler.js --profile-all`), which also houses the test-failure debugger, coverage and call-graph tools.
796
+
797
+ This package's internals — the four-phase compile pipeline, ref flattening, annotation tracking, dynamic scope — are described in its own [ARCHITECTURE](./ARCHITECTURE.md) document. For practical usage recipes (options, lightweight setups, custom formats, pitfalls) see the repository [HOWTO](../../docs/HOWTO.md); for the monorepo picture see the repository [README](../../README.md) and [ARCHITECTURE](../../docs/ARCHITECTURE.md); for what is planned next see the [ROADMAP](../../docs/ROADMAP.md).