@jarenjs/validate 0.9.2 → 0.34.2

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.
package/ARCHITECTURE.md CHANGED
@@ -27,6 +27,7 @@ The validator embraces these architectural principles:
27
27
  | `packages/validate/src/index.js` | Main validator classes (`JarenValidator`, `ValidationRoot`, `ValidationObject`) |
28
28
  | `packages/validate/src/traverse.js` | Schema traversal and ref resolution (`storeSchemaIdsInMap`, `restoreSchemaRefsInMap`) |
29
29
  | `packages/validate/src/schema.js` | Schema compilation dispatcher (`compileSchemaObject`) |
30
+ | `packages/validate/src/messages.js` | Error conversion, message catalogs, the `errorMessage` keyword specs (`ValidationError`, `messagesEn`, `localizeErrors`) |
30
31
  | `packages/validate/src/array.js` | Array validation logic |
31
32
  | `packages/validate/src/object.js` | Object validation logic |
32
33
  | `packages/validate/src/string.js` | String validation logic |
@@ -84,17 +85,30 @@ The validator operates in four distinct phases that cleanly separate concerns:
84
85
  **Entry Point**: `JarenValidator.addSchema(schema, key)`
85
86
 
86
87
  **Flow**:
87
- ```
88
- addSchema(schema, key)
89
- ├── Store schema under key (and alt key with/without #)
90
- └── #traverseAndStoreIds(baseUri, schema)
91
- └── storeSchemaIdsInMap(schemasMap, baseUri, schema)
92
- ├── Store root schema under baseUri
93
- └── BFS traverse schema structure:
94
- ├── On $id: Store subschema, update baseUri
95
- ├── On $anchor: Store anchor
96
- ├── On $ref: Store null placeholder (mark as ref)
97
- └── On schema objects/arrays: Continue traversal
88
+ ```mermaid
89
+ flowchart TD
90
+ A["addSchema(schema, key)"]
91
+ B["store schema under key<br/>(and the alt key with/without #)"]
92
+ C["#traverseAndStoreIds(baseUri, schema)"]
93
+ D["storeSchemaIdsInMap(schemasMap, baseUri, schema)"]
94
+ E["store the root schema under baseUri"]
95
+ F{"BFS over the schema structure"}
96
+ G["$id store subschema, update baseUri"]
97
+ H["$anchor store anchor"]
98
+ I["$ref store a null placeholder, marked as a ref"]
99
+ J["objects / arrays — keep traversing"]
100
+ A --> B
101
+ A --> C
102
+ C --> D
103
+ D --> E
104
+ D --> F
105
+ F --> G
106
+ F --> H
107
+ F --> I
108
+ F --> J
109
+ N["the placeholder is what lets refs<br/>be added in any order"]
110
+ I -.- N
111
+ class N note
98
112
  ```
99
113
 
100
114
  **Key Data Structure**: `#schemas Map<string, schema|null>`
@@ -349,6 +363,16 @@ The compilation context that:
349
363
  - Collects validation errors
350
364
  - Provides `$ref` resolution services
351
365
 
366
+ **Owner threading**: the constructor takes a sixth argument `owner`
367
+ (default `null`), exposed through an `owner` getter, and both construction
368
+ sites in `index.js` pass the owning `JarenValidator` for it. This lets
369
+ `query-keyword.js` hand that instance to `createTypeTestCompiler`, so the
370
+ schema literals inside a `$query` document (`$valid`/`$assert`/`$as`)
371
+ resolve their `$ref`s against the owner's `addSchema` registrations. The
372
+ resulting ESM cycle — `index.js` → `schema.js` → `query-keyword.js` →
373
+ `query.js` → `index.js` — is benign: `query.js` only *reads* the hoisted
374
+ `JarenValidator` declaration at call time, never at module-evaluation time.
375
+
352
376
  ### ValidationObject
353
377
 
354
378
  Represents a single schema location with its compiled validator:
@@ -576,6 +600,14 @@ flowchart TB
576
600
  B5 --> C
577
601
  ```
578
602
 
603
+ **Compile-failure fallback**: the ref compilers of `@jarenjs/json`
604
+ (`compileDataRef` for the `data` keyword, `compileRelativeJSONPointer` for
605
+ `$data`) *throw* on a malformed reference. `data.js` and `dollar-data.js`
606
+ each wrap the compile in a `compileRefResolver` try/catch that falls back to
607
+ an always-`JSONPOINTER_NOTHING` resolver. A malformed `$data`/`data`
608
+ reference therefore validates as "not found" — the keyword asserts nothing —
609
+ instead of throwing at compile time, keeping the lax keyword semantics.
610
+
579
611
  ---
580
612
 
581
613
  ## Error Handling Architecture
@@ -593,10 +625,10 @@ flowchart LR
593
625
  F --> G[All validators complete]
594
626
  G --> H{collectErrors?}
595
627
 
596
- H -->|true| I[Convert to ValidationError]
628
+ H -->|true| I[convertInternalErrors - messages.js]
597
629
  H -->|false| J[Return boolean only]
598
630
 
599
- I --> K[Add human-readable messages]
631
+ I --> K[Extract params, resolve msgid,<br/>match errorMessage registry,<br/>render through catalog]
600
632
  K --> L[Return {valid, errors}]
601
633
 
602
634
  style C fill:#9f9
@@ -604,6 +636,29 @@ flowchart LR
604
636
  style L fill:#9f9
605
637
  ```
606
638
 
639
+ ### Report-Time Messages (messages.js)
640
+
641
+ Conversion is structured-first, render-late (the normative spec is
642
+ [docs/ERROR-MESSAGES.md](./docs/ERROR-MESSAGES.md)):
643
+
644
+ - Every public `ValidationError` carries a stable `msgid` (the matched
645
+ `errorMessage` spec's `$msgid`, else the `$query` runtime code, else the
646
+ keyword) plus raw `params`; `instancePath` is a straight read of the
647
+ first meta argument every handler call site passes (the
648
+ handler-contract invariant), behind a charCode guard that yields `''`
649
+ rather than ever a wrong path.
650
+ - The `errorMessage` keyword compiles at schema compile time into a
651
+ registry on `ValidationRoot` (`registerErrorMessage`) — no validator
652
+ closure is emitted and the single-keyword fast paths stay eligible (the
653
+ key count excludes it). Matching happens only over the failed set:
654
+ nearest registered ancestor by segment-aware prefix; map-form entries
655
+ and `_` apply at the node itself, the string form covers the subtree.
656
+ - Human text renders through catalogs — plain objects of closures /
657
+ template strings (`messagesEn` built in; packs in `@jarenjs/locales`).
658
+ `localizeErrors(errors, catalog)` re-renders post hoc from
659
+ `msgid` + `params`; the `messages: false` option skips rendering
660
+ entirely (`message: ''`).
661
+
607
662
  ### Error Handler Creation
608
663
 
609
664
  ```mermaid
@@ -973,8 +1028,8 @@ console.log('Resolving ref:', ref, 'against baseUri:', baseUri);
973
1028
 
974
1029
  For broader context on how this package fits into the JarenJS ecosystem:
975
1030
 
976
- - **Project-wide Architecture**: See `/ARCHITECTURE.md` at the project root
977
- - **Developer Guide**: See `/HOWTO.md` at the project root
1031
+ - **Project-wide Architecture**: See `docs/ARCHITECTURE.md`
1032
+ - **Developer Guide**: See `docs/HOWTO.md`
978
1033
  - **Core Package**: Depends on `@jarenjs/core` for fundamental utilities and `@jarenjs/json` for the JSON addressing standards
979
1034
 
980
1035
  ---
@@ -983,7 +1038,8 @@ For broader context on how this package fits into the JarenJS ecosystem:
983
1038
 
984
1039
  | File | Purpose | Key Exports |
985
1040
  |------|---------|-------------|
986
- | `index.js` | Public API | `JarenValidator`, `ValidationOptions`, `ValidatorOptions`, `ValidationError` |
1041
+ | `index.js` | Public API | `JarenValidator`, `ValidationOptions`, `ValidatorOptions` |
1042
+ | `messages.js` | Error conversion & i18n | `ValidationError`, `convertInternalErrors`, `messagesEn`, `compileMessageTemplate`, `compileMessageCatalog`, `renderErrorMessage`, `localizeErrors`, `compileErrorMessageSpec` |
987
1043
  | `schema.js` | Schema compilation | `compileSchemaObject` |
988
1044
  | `traverse.js` | Schema traversal | `TraverseOptions`, `storeSchemaIdsInMap`, `resolveRefSchemaDeep` |
989
1045
  | `tools.js` | Shared utilities | `isBoolOrObjectClass`, `hasSchemaRef`, `createIsSchemaTypeHandler` |
@@ -1001,6 +1057,7 @@ For broader context on how this package fits into the JarenJS ecosystem:
1001
1057
  | `dollar-data.js` | $data keyword | `compileDollarDataSchema` |
1002
1058
  | `unevaluated.js` | unevaluated* keywords | `wrapUnevaluated` |
1003
1059
  | `dynamic-ref.js` | Dynamic scope helpers | `collectDynamicAnchorsDeep`, `hasRecursiveAnchor`, `getDynamicAnchorName` |
1060
+ | `query-keyword.js` | `$query` extension keyword | `compileQuerySchema` |
1004
1061
 
1005
1062
  ---
1006
1063
 
@@ -1028,12 +1085,19 @@ For broader context on how this package fits into the JarenJS ecosystem:
1028
1085
 
1029
1086
  ### 3. Lazy Error Generation
1030
1087
 
1031
- **Decision**: Only create error objects when `skipErrors` is false.
1088
+ **Decision**: Only create error objects when `skipErrors` is false, and
1089
+ only produce human-readable text at report time (`convertInternalErrors`
1090
+ in messages.js), over the already-failed set, from a `msgid` + `params`
1091
+ pair through a message catalog.
1032
1092
 
1033
1093
  **Rationale**:
1034
1094
  - Most production use cases only need boolean results
1035
1095
  - Error object creation is expensive
1036
1096
  - Reduces GC pressure during high-throughput validation
1097
+ - Structured-first errors make locale a report-time choice: switching
1098
+ language (`localizeErrors`, `@jarenjs/locales`) never recompiles a
1099
+ validator, and the `errorMessage` keyword resolves against a registry
1100
+ with zero validation-time cost (see docs/ERROR-MESSAGES.md)
1037
1101
 
1038
1102
  ### 4. Dual Data Reference Systems
1039
1103
 
package/README.md CHANGED
@@ -193,7 +193,7 @@ See also:
193
193
 
194
194
  Both absolute JSON Pointers (e.g., `/A`, `/limits/min`) and relative JSON Pointers (e.g., `0/parent`, `1/sibling`) are supported.
195
195
 
196
- - `$data` | Ajv-style instance references (`{ "minimum": { "$data": "1/limit" } }`) — supports every keyword the `data` list above supports, plus `uniqueItems` and `required`. Refs compile once at schema compile time through the compiled pointer engine of `@jarenjs/json`.
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
197
 
198
198
  - `$query` | Jaren's cross-field assertion keyword — see [below](#query--cross-field-assertions)
199
199
 
@@ -202,7 +202,7 @@ See also:
202
202
  - ❌ strict
203
203
  - ❌ strictFormat
204
204
  - ❌ strictTuple
205
- - errorMessage
205
+ - errorMessage | author-supplied messages that override text (never structure) — see [Error messages &amp; i18n](#error-messages--i18n)
206
206
  - definitions | used by initial schema traversal _deprecated in `draft2019`_
207
207
  - $defs | used by initial schema traversal _new `draft2019`_
208
208
  - components | _(OpenAPI)_
@@ -323,7 +323,13 @@ Semantics and composition:
323
323
  keyword: under `properties`/`items` the query's `$` is that location's
324
324
  value, `$path` its pointer (`/lines/0`, ...), `$root` the whole document.
325
325
  - A bare JSONPath string is the degenerate query: `{ "$query": "$.approved" }`
326
- asserts the EBV of that member (missing empty sequence false).
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.
327
333
  - Query **runtime** errors (`JQ2xxx` — e.g. arithmetic on a non-number, the
328
334
  EBV of a multi-item result) are validation **failures**, never throws; in
329
335
  `collectErrors` mode the error params carry the `code` and the query
@@ -331,10 +337,461 @@ Semantics and composition:
331
337
  `root`/`path` fail fast at `compile()`.
332
338
  - Schema literals inside the query (`$valid`/`$assert`/`$as`,
333
339
  QUERY-FORMAT §8.11) compile against the **same validator instance**, so
334
- their `$ref`s resolve to your `addSchema` registrations.
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
+ ```
335
792
 
336
793
  ## Development
337
794
 
338
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.
339
796
 
340
- 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](../../HOWTO.md); for the monorepo picture see the repository [README](../../README.md) and [ARCHITECTURE](../../ARCHITECTURE.md); for what is planned next see the [ROADMAP](../../ROADMAP.md).
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).