@amritk/generate-validators 0.15.1 → 0.16.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.
package/AI.md CHANGED
@@ -27,9 +27,11 @@ const files = await buildValidatorSchema(schema, 'Document')
27
27
  `validateFoo` returns `true | { valid: false; errors: ValidationError[] }`.
28
28
  Check `if (result !== true)` for the failure path — `if (result.valid)` is
29
29
  wrong.
30
- 2. **Small signature:** `buildValidatorSchema(rootSchema, rootTypeName, typeSuffix?)`
31
- — async, no `strict`/`typesOnly`/options. Returns `GeneratedFile[]` in memory
32
- (you write them).
30
+ 2. **Small signature:** `buildValidatorSchema(rootSchema, rootTypeName, typeSuffix?, schemas?, unknownKeys?)`
31
+ — async, no `strict`/`typesOnly`/options object. Returns `GeneratedFile[]` in
32
+ memory (you write them). `unknownKeys` (`'count-keys'` by default,
33
+ `'count-enumerable'` for Node-only output) picks how a closed object's guard
34
+ counts keys; nothing in the generated code detects its runtime.
33
35
  3. **Output includes a shared `validation-result.ts`** (`ValidationError`,
34
36
  `ValidationResult`, helpers) plus the `index.ts` barrel.
35
37
  4. **`NaN` fails a *constrained* number** (`minimum`/`maximum`/`multipleOf` all
package/README.md CHANGED
@@ -84,7 +84,7 @@ if (!result.valid) {
84
84
 
85
85
  ## API
86
86
 
87
- ### `buildValidatorSchema(rootSchema, rootTypeName, typeSuffix?, schemas?)`
87
+ ### `buildValidatorSchema(rootSchema, rootTypeName, typeSuffix?, schemas?, unknownKeys?)`
88
88
 
89
89
  | Parameter | Type | Default | Description |
90
90
  |:---|:---|:---|:---|
@@ -92,6 +92,7 @@ if (!result.valid) {
92
92
  | `rootTypeName` | `string` | — | Name used for the root type (e.g. `"Document"`). |
93
93
  | `typeSuffix` | `string` | `''` | Suffix appended to every `$ref`-derived type name (`'Object'` turns `Contact` into `ContactObject`). The root type name is unaffected. |
94
94
  | `schemas` | `Record<string, unknown>` | — | Documents you have **already loaded**, keyed by the absolute URI a `$ref` names them by. See below. |
95
+ | `unknownKeys` | `'count-keys' \| 'count-enumerable'` | `'count-keys'` | How the fast paths prove a closed object (`additionalProperties: false`) has no undeclared key: `Object.keys(obj).length` (fastest on Bun) or a `for…in` count (fastest on Node). See [Choosing how keys are counted](#choosing-how-keys-are-counted). |
95
96
 
96
97
  Returns: `Promise<GeneratedFile[]>` where `GeneratedFile = { filename: string; content: string }`.
97
98
 
@@ -244,30 +245,64 @@ of it is published.
244
245
  Generated validators are straight-line, monomorphic TypeScript with no generic
245
246
  dispatch. The exported `validateX` is split into a hot and a cold half: on the
246
247
  happy path it runs a single allocation-free boolean guard — a pure `&&` chain of
247
- `typeof` checks (plus an `Object.keys().length` count when an object is closed
248
- with `additionalProperties: false`) — and `return true`s straight away, only
249
- calling a separate error-collecting function when something is actually wrong.
250
- Keeping the hot function tiny lets the JIT optimise it aggressively, so a valid-input
251
- check beats every other library measured including the build-time transformer
252
- typia while still emitting full JSON-Pointer errors for invalid input, and
253
- emitting the validator stays far cheaper than compiling a schema at startup.
254
- Measured on Bun 1.4 (Linux x64), validating valid input at steady state:
248
+ `typeof` checks, every nested object loaded once into a local, plus a key count
249
+ when an object is closed with `additionalProperties: false` — and `return true`s
250
+ straight away, only calling a separate error-collecting function when something
251
+ is actually wrong.
252
+ Keeping the hot function tiny lets the JIT optimise it aggressively, so a
253
+ valid-input check beats every other library measured on JavaScriptCore the
254
+ build-time transformer typia included while still emitting full JSON-Pointer
255
+ errors for invalid input, and emitting the validator stays far cheaper than
256
+ compiling a schema at startup. On V8 that lead is not universal: TypeBox's
257
+ compiled checker wins the `assert-loose` shape outright and draws level on
258
+ `assert-strict`, which is why both engines get a table rather than one standing
259
+ in for the other.
260
+
261
+ Both were measured together on one machine (Linux x64, a 4-vCPU cloud box, Bun
262
+ 1.4.0 and Node 26.8.1 — the same machine and runtimes as every table in this
263
+ repo). Each cell is the median of three separate runs of the whole suite. Treat
264
+ the absolutes as a property of that box: within one sitting a cell repeats to
265
+ within a few percent, but the same suite measured again hours later moved by
266
+ ~60% across every case at once, so the ratios are the durable part and a
267
+ remembered number is not a baseline.
268
+
269
+ **Bun 1.4.0 / JavaScriptCore**, validating valid input at steady state:
255
270
 
256
271
  | schema | mjst (generated) | typia (transformed) | ajv (compiled) | typebox (compiled) | zod |
257
272
  |:--|--:|--:|--:|--:|--:|
258
- | small (4 fields) | **~59M** ops/s | ~5.8M ops/s | ~10M ops/s | ~7.6M ops/s | ~2.2M ops/s |
259
- | order (nested + array) | **~9.5M** ops/s | ~2M ops/s | ~3.9M ops/s | ~3.3M ops/s | ~0.54M ops/s |
260
- | assert-loose | **~189M** ops/s | ~170M ops/s | ~44M ops/s | ~78M ops/s | ~5M ops/s |
261
- | assert-strict | **~104M** ops/s | ~68M ops/s | ~21M ops/s | ~42M ops/s | ~1.8M ops/s |
273
+ | small (4 fields) | **~59M** ops/s | ~6.7M ops/s | ~11M ops/s | ~8.9M ops/s | ~2.4M ops/s |
274
+ | order (nested + array) | **~10M** ops/s | ~2.6M ops/s | ~4.1M ops/s | ~3.6M ops/s | ~0.50M ops/s |
275
+ | assert-loose | **~190M** ops/s | ~170M ops/s | ~46M ops/s | ~80M ops/s | ~3.6M ops/s |
276
+ | assert-strict | **~171M** ops/s | ~58M ops/s | ~23M ops/s | ~45M ops/s | ~1.4M ops/s |
277
+
278
+ **Node 26.8.1 / V8**, the same cases. typia is absent because its checks come
279
+ from a compile-time transform delivered as a Bun preload, so the Node run cannot
280
+ build one at all:
281
+
282
+ | schema | mjst (generated) | ajv (compiled) | typebox (compiled) | zod |
283
+ |:--|--:|--:|--:|--:|
284
+ | small (4 fields) | **~56M** ops/s | ~7.0M ops/s | ~6.6M ops/s | ~2.2M ops/s |
285
+ | order (nested + array) | **~8.9M** ops/s | ~2.8M ops/s | ~2.9M ops/s | ~0.48M ops/s |
286
+ | assert-loose | ~90M ops/s | ~71M ops/s | **~138M** ops/s | ~6.0M ops/s |
287
+ | assert-strict | ~37M ops/s | ~25M ops/s | **~37M** ops/s | ~3.6M ops/s |
288
+
289
+ Read the two together. On the object schemas — the shapes an application
290
+ actually validates — the generated validator leads on both engines, by 5–9× over
291
+ the next-fastest on Bun and 2.4–8× on Node. On the moltar shapes it leads
292
+ everything on JavaScriptCore and loses the loose one to TypeBox on V8 (~138M
293
+ against ~90M), with `assert-strict` a coin toss. Those two cases are seven
294
+ scalar roots and a nested object: near-trivial work where the engine's own
295
+ inlining decides the winner, not the validator's design.
262
296
 
263
297
  The `assert-loose` / `assert-strict` rows use the same *shape* as
264
298
  [`moltar/typescript-runtime-type-benchmarks`](https://github.com/moltar/typescript-runtime-type-benchmarks)
265
- (seven scalar roots plus a nested object): the boolean guard keeps mjst ahead of
266
- typia on both, by ~11% on `assert-loose` — close enough that the two can trade
267
- the lead run-to-run — and by ~52% on `assert-strict` (with
299
+ (seven scalar roots plus a nested object): on JavaScriptCore the boolean guard
300
+ keeps mjst ahead of typia on both, by ~12% on `assert-loose` — close enough that
301
+ the two trade the lead run-to-run — and by ~ on `assert-strict` (with
268
302
  `additionalProperties: false`), where mjst counts keys once and typia does not.
269
- (typia and TypeBox still win the *invalid* path, where they bail on the first
270
- error rather than collecting a full error list.)
303
+ On V8 the same two rows go the other way against TypeBox, as the tables above
304
+ show. (typia and TypeBox still win the *invalid* path on both engines, where
305
+ they bail on the first error rather than collecting a full error list.)
271
306
 
272
307
  They are **not** that project's numbers and they do not belong next to its
273
308
  leaderboard: the shape is shared, the harness is not, and the harness is worth
@@ -275,9 +310,16 @@ an order of magnitude. See
275
310
  [Against the moltar harness](#against-the-moltar-harness) for the same functions
276
311
  measured under benny, the way the leaderboard measures them.
277
312
 
278
- Preparing a validator costs ~0.3–0.7 ms for mjst codegen and ~0.05–0.2 ms for a
279
- TypeBox `TypeCompiler` compile, versus ~13–17 ms for an Ajv compile. Every library
280
- agrees on every verdict; parity is asserted before timing.
313
+ Preparing a validator, by runtime (medians over the four cases above):
314
+
315
+ | | mjst codegen | TypeBox compile | Ajv compile |
316
+ |:--|--:|--:|--:|
317
+ | Bun 1.4.0 | ~0.30–0.66 ms | ~0.04–0.23 ms | ~9.7–13 ms |
318
+ | Node 26.8.1 | ~0.27–0.58 ms | ~0.04–0.11 ms | ~5.3–6.4 ms |
319
+
320
+ Ajv's compile is the one prepare cost that roughly halves on V8; the other two
321
+ are within a whisker of each other on both engines. Every library agrees on
322
+ every verdict; parity is asserted before timing.
281
323
 
282
324
  One caveat on the first two rows: their schemas declare `format` (`uuid`,
283
325
  `email`), and Ajv, typia, zod, and TypeBox all check it, while mjst's generated
@@ -292,7 +334,8 @@ numbers stay reproducible. Micro-benchmark figures vary by machine and runtime
292
334
  reproduce with:
293
335
 
294
336
  ```bash
295
- bun run bench
337
+ bun run bench # Bun / JavaScriptCore
338
+ bun run bench:node # Node / V8 (builds the package first, then runs it under node)
296
339
  ```
297
340
 
298
341
  ### Against the moltar harness
@@ -313,22 +356,27 @@ measured.
313
356
  `bun run bench:moltar` runs exactly that harness over the same functions, always
314
357
  alongside a **no-op** control — a "validator" that checks nothing, which is the
315
358
  fastest number the harness can physically produce. One run on this machine
316
- (Linux x64, Bun 1.3.11 / Node 22.22, valid input):
359
+ (Linux x64, Bun 1.4.0 / Node 26.8.1, valid input):
317
360
 
318
361
  | harness | runtime | `assert-loose` | `assert-strict` |
319
362
  |:--|:--|--:|--:|
320
- | this package (`measure.ts`) | Bun | ~200M ops/s | ~185M ops/s |
321
- | benny, moltar's `Benchmark` | Node | ~100M ops/s | ~38M ops/s |
322
- | benny, moltar's `Benchmark` | Bun | ~70M ops/s | ~2.4M ops/s |
323
- | *no-op control, benny* | *Node* | *~120M ops/s* | *~120M ops/s* |
324
- | *no-op control, benny* | *Bun* | *~325M ops/s (±75%)* | *~325M ops/s (±75%)* |
325
-
326
- Read two things out of that. First, on Node the `assert-loose` figure sits
327
- within 20% of a validator that does nothing, so under that harness it is not a
363
+ | this package (`measure.ts`) | Bun | ~190M ops/s | ~171M ops/s |
364
+ | this package (`measure.ts`) | Node | ~90M ops/s | ~37M ops/s |
365
+ | benny, moltar's `Benchmark` | Bun | ~90M ops/s | ~76M ops/s |
366
+ | benny, moltar's `Benchmark` | Node | ~80M ops/s | ~34M ops/s |
367
+ | *no-op control, benny* | *Node* | *~91M ops/s* | *~96M ops/s* |
368
+ | *no-op control, benny* | *Bun* | *~508M ops/s (±46%)* | *~449M ops/s (±48%)* |
369
+
370
+ Read three things out of that. First, on Node the `assert-loose` figure sits
371
+ within 12% of a validator that does nothing, so under that harness it is not a
328
372
  validator measurement at all: above that floor a faster validator cannot show
329
373
  up as a faster number, and the published leaderboard runs on CI hardware slower
330
- than this box, where the floor sits lower still. Second, `assert-strict` on Bun collapses to
331
- ~2.4M because moltar's fixture is `Object.freeze({ })` see
374
+ than this box, where the floor sits lower still. Second, the harness reorders
375
+ the field: under benny on Node this validator leads TypeBox (~80M against
376
+ ~54M), the reverse of what `measure.ts` reports for the same two functions on
377
+ the same engine, because benny's floor compresses the top of the range. Third,
378
+ moltar's fixture is `Object.freeze({ … })`: under Bun 1.3 that collapsed the Bun
379
+ `assert-strict` cell to ~2.4M, and Bun 1.4.0 has closed that cliff — see
332
380
  [Frozen inputs](#frozen-inputs).
333
381
 
334
382
  The harness makes no difference to correctness and every difference to the
@@ -343,45 +391,101 @@ bun run bench:moltar # benny, under the leaderboard's conditions
343
391
 
344
392
  Closing an object with `additionalProperties: false` means proving no
345
393
  undeclared key is present, and every library answers that by enumerating keys:
346
- mjst's guard counts them (`Object.keys(obj).length === n`, exact because each
347
- declared property is required and already proven present), Ajv and Zod sweep
348
- with `for...in`, TypeBox runs its own sweep. On V8 that costs the same whatever
349
- the input looks like.
350
-
351
- On JavaScriptCore (Bun) it does not. Making an object non-extensible —
352
- `Object.freeze`, `Object.seal` or a bare `Object.preventExtensions` turns off
394
+ mjst's guard counts them (`Object.keys(obj).length === n` by default, exact
395
+ because each declared property is required and already proven present see
396
+ [Choosing how keys are counted](#choosing-how-keys-are-counted) for the
397
+ `for...in` alternative), Ajv and Zod sweep with `for...in`, TypeBox runs its own
398
+ sweep. On V8 that costs the same whatever the input looks like.
399
+
400
+ On JavaScriptCore under Bun 1.3 it did not. Making an object non-extensible
401
+ `Object.freeze`, `Object.seal` or a bare `Object.preventExtensions` — turned off
353
402
  the engine's cached own-keys fast path, and *every* form of key enumeration
354
- falls back to a generic walk: `Object.keys`, `Object.getOwnPropertyNames`,
355
- `Reflect.ownKeys` and `for...in` alike. Property reads are untouched (a frozen
356
- object reads at full speed), so the whole cost lands on the extra-key sweep, and
357
- therefore on strict schemas only. Frozen inputs are ordinary a config object
358
- frozen at startup, a shared fixture, a module-level constant so `bun run bench`
359
- carries `small (4 fields, frozen)` and `assert-strict (frozen)` cases to keep it
360
- measured. One run on this machine (Linux x64, Bun 1.3.11), valid input:
361
-
362
- | `assert-strict` | mutable input | frozen input |
363
- |:--|--:|--:|
364
- | mjst (generated) | ~185M ops/s | ~1.7M ops/s |
365
- | typia (transformed) | ~68M ops/s | ~1.7M ops/s |
366
- | typebox (compiled) | ~46M ops/s | ~1.7M ops/s |
367
- | ajv (compiled) | ~24M ops/s | ~1.5M ops/s |
368
- | zod | ~1.4M ops/s | ~0.7M ops/s |
369
-
370
- It is an engine-level cliff, not an mjst one: every compiled or generated strict
371
- validator lands within a hair of the same number, because they are all paying
372
- the same engine slow path. The generated code keeps the key count anyway. Every
373
- alternative was measured and every one is worse overall. `Object.values(obj)`
374
- and `Object.keys({ ...obj })` sidestep the cliff, but on the ordinary mutable
375
- path they cost 28–37× under JSC and 2–7× under V8. Branching on
376
- `Object.isExtensible(obj)` first keeps the mutable path recognisable, at ~4×
377
- under JSC and makes V8 slower in *both* directions (~2× mutable, ~7× frozen),
378
- where there was no cliff to fix in the first place. Trading a large, portable
379
- regression for a smaller win on one engine is not a good deal, so the sweep
380
- stays as it is.
381
-
382
- If it matters for your workload: validate before freezing (the verdict is the
383
- same either way `src/generators/frozen-input.test.ts` pins that), or run on a
384
- V8 runtime, where the cliff does not exist.
403
+ fell back to a generic walk: `Object.keys`, `Object.getOwnPropertyNames`,
404
+ `Reflect.ownKeys` and `for...in` alike. Property reads were untouched (a frozen
405
+ object read at full speed), so the whole cost landed on the extra-key sweep, and
406
+ therefore on strict schemas only. Bun 1.4.0 no longer shows the cliff — frozen
407
+ and mutable input run at the same speed for every library. Frozen inputs are
408
+ ordinary a config object frozen at startup, a shared fixture, a module-level
409
+ constant so `bun run bench` keeps carrying `small (4 fields, frozen)` and
410
+ `assert-strict (frozen)` cases to keep it measured. Medians of three runs on
411
+ this machine (Linux x64), valid input, on both current runtimes and on the Bun
412
+ version that had the cliff:
413
+
414
+ | `assert-strict` | Bun 1.4.0 mutable | Bun 1.4.0 frozen | Node 26 mutable | Node 26 frozen | Bun 1.3.11 mutable | Bun 1.3.11 frozen |
415
+ |:--|--:|--:|--:|--:|--:|--:|
416
+ | mjst (generated) | ~171M ops/s | ~166M ops/s | ~37M ops/s | ~35M ops/s | ~82M ops/s | ~1.5M ops/s |
417
+ | typia (transformed) | ~58M ops/s | ~89M ops/s | n/a | n/a | ~37M ops/s | ~1.5M ops/s |
418
+ | typebox (compiled) | ~45M ops/s | ~46M ops/s | ~37M ops/s | ~35M ops/s | ~27M ops/s | ~1.4M ops/s |
419
+ | ajv (compiled) | ~23M ops/s | ~22M ops/s | ~25M ops/s | ~25M ops/s | ~12M ops/s | ~1.2M ops/s |
420
+ | zod | ~1.4M ops/s | ~1.4M ops/s | ~3.6M ops/s | ~3.6M ops/s | ~0.91M ops/s | ~0.47M ops/s |
421
+
422
+ <sub>typia is Bun-only: its checks come from a compile-time transform delivered
423
+ as a Bun preload, so the Node run cannot build one.</sub>
424
+
425
+ It was an engine-level cliff, not an mjst one: on Bun 1.3 every compiled or
426
+ generated strict validator lands within a hair of the same number, because they
427
+ are all paying the same engine slow path. The Node 26 columns are flat, frozen
428
+ or not, which is what "V8 never had it" looks like measured rather than
429
+ asserted. The generated code keeps the key count
430
+ anyway. Every alternative was measured (on Bun 1.3.11) and every one is worse
431
+ overall. `Object.values(obj)` and `Object.keys({ ...obj })` sidestep the cliff,
432
+ but on the ordinary mutable path they cost 28–37× under JSC and 2–7× under V8.
433
+ Branching on `Object.isExtensible(obj)` first keeps the mutable path
434
+ recognisable, at ~4× under JSC — and makes V8 slower in *both* directions (~2×
435
+ mutable, ~7× frozen), where there was no cliff to fix in the first place.
436
+ Trading a large, portable regression for a smaller win on one engine is not a
437
+ good deal, so the sweep stays as it is — and Bun 1.4 has since closed the cliff
438
+ on its own.
439
+
440
+ If it matters for your workload on an older Bun: validate before freezing (the
441
+ verdict is the same either way — `src/generators/frozen-input.test.ts` pins
442
+ that), or run on Bun ≥ 1.4 or a V8 runtime, where the cliff does not exist.
443
+
444
+ ### Choosing how keys are counted
445
+
446
+ The count itself can be spelled two ways, and the two trade places between the
447
+ engines. `unknownKeys` — the last argument of `buildValidatorSchema`, the
448
+ `--unknown-keys` flag of the CLI, the same option on `@amritk/generate-parsers` —
449
+ picks one at generation time:
450
+
451
+ - **`'count-keys'`** (default) — `Object.keys(obj).length === n`. Builds a keys
452
+ array per call, which V8 scalar-replaces when only the length is read.
453
+ - **`'count-enumerable'`** — `let c = 0; for (const k in obj) c++`. Allocates
454
+ nothing, and on V8 is answered straight from the shape's enum cache. On
455
+ JavaScriptCore a `for...in` over a non-extensible object is the slow path
456
+ described above, and even on a mutable one it trails `Object.keys`.
457
+
458
+ Measured under the moltar harness (benny, the frozen fixture, each case alone in
459
+ its own process — `bun run bench:moltar:leaderboard` prints one row per strategy
460
+ on every runtime it finds), Linux x64. The Node 22 column is the earlier
461
+ measurement this option was introduced against; the Node 26 column is the same
462
+ harness on the current runtime:
463
+
464
+ | case | `unknownKeys` | Bun 1.4 | Node 22 | Node 26 |
465
+ |:--|:--|--:|--:|--:|
466
+ | `assertStrict` (`isX`) | `count-keys` | ~441M ops/s (the harness floor — the call is eliminated) | ~19M ops/s | ~31M ops/s |
467
+ | `assertStrict` (`isX`) | `count-enumerable` | ~42M ops/s | ~22M ops/s | ~30M ops/s |
468
+ | `parseStrict` (`@amritk/generate-parsers`) | `count-keys` | ~359M ops/s (the harness floor again) | ~14M ops/s | ~29M ops/s |
469
+ | `parseStrict` (`@amritk/generate-parsers`) | `count-enumerable` | ~22M ops/s | ~14M ops/s | ~26M ops/s |
470
+
471
+ The default is `count-keys` because it is never the slower form on Bun and,
472
+ with the nested shape check spelled out on the parse fast path, lets
473
+ JavaScriptCore eliminate the strict parse as well. The Node case has changed
474
+ with the engine: on Node 22 `count-enumerable` was level or slightly ahead,
475
+ which is where the advice to flip it for Node-only builds came from, but on
476
+ Node 26 the default is ahead in both rows. Keep it unless you are pinned to an
477
+ older V8 and have measured your own shapes. The choice is made once, when the
478
+ code is generated: nothing in the emitted file detects its runtime. (On Bun 1.3, where a frozen object puts
479
+ `Object.keys` on the same slow path as `for...in`, both strategies sit at ~2M
480
+ ops/s under this fixture and the default is a wash.)
481
+
482
+ The two strategies also read different key sets — `Object.keys` sees own keys,
483
+ `for...in` sees enumerable ones, inherited included — which no value parsed from
484
+ JSON can tell apart. The `for...in` count agrees with the cold path exactly, so
485
+ `isX` and `validateX` answer alike on a crafted prototype; the own-key count can
486
+ accept an *inherited* extra that `validateXErrors` would report, and `isX` under
487
+ it declines an inherited declared key that `validateX` accepts through its cold
488
+ path. Neither strategy ever accepts a value the interpreter rejects on JSON data.
385
489
 
386
490
  ---
387
491
 
@@ -1,3 +1,4 @@
1
+ import { type UnknownKeysStrategy } from '@amritk/helpers/unknown-keys-strategy';
1
2
  import type { JSONSchema } from 'json-schema-typed/draft-2020-12';
2
3
  /**
3
4
  * Represents a generated TypeScript file with its filename and content.
@@ -11,7 +12,7 @@ export type GeneratedFile = {
11
12
  * types plus the helpers emitted code calls as free identifiers. Exported so tests
12
13
  * can evaluate the very source that ships instead of reimplementing it.
13
14
  */
14
- export declare const VALIDATION_RESULT_CONTENT = "/**\n * A single validation error with a human-readable message and a JSON Pointer\n * path indicating where in the document the error occurred.\n */\nexport type ValidationError = {\n message: string\n path: string\n}\n\n/**\n * The result of a generated validator function.\n * Returns `true` when the input is valid, or an object with `valid: false`\n * and a list of errors when it is not.\n */\nexport type ValidationResult = true | { valid: false; errors: ValidationError[] }\n\n/**\n * How deep a structural comparison walks before it gives up and answers \"not\n * equal\".\n *\n * JSON data is acyclic, but a generated validator is a plain function applied to\n * whatever in-memory value a caller hands it \u2014 and a self-referential object\n * reaching a `const` / `enum` / `uniqueItems` check used to recurse until the\n * stack overflowed, so `validateFoo` threw a `RangeError` instead of returning\n * the `ValidationResult` its signature promises. The cap turns that into an\n * ordinary \"these are different\" without ever coming near real data;\n * `@amritk/runtime-validators` guards its own `deepEqual` at the same depth.\n */\nconst MAX_EQUAL_DEPTH = 512\n\n/**\n * Structural deep equality used by generated `const` checks. Objects compare by\n * their key sets rather than serialization, so `{ a: 1, b: 2 }` and\n * `{ b: 2, a: 1 }` are equal \u2014 unlike `JSON.stringify`, which is key-order\n * sensitive and would reject a reordered-but-equal value.\n */\nexport const valuesEqual = (a: unknown, b: unknown, depth = 0): boolean => {\n // SameValueZero: `===` settles every primitive except `NaN`, which counts as\n // equal to itself here. That is what the native `Set` in {@link allUnique} does,\n // what Ajv does, and what the interpreter's `deepEqual` does \u2014 leaving it out\n // made a `NaN` nested inside an object compare unequal to itself, so the same\n // array was \"unique\" here and \"duplicated\" everywhere else.\n if (a === b || (Number.isNaN(a) && Number.isNaN(b))) return true\n if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) return false\n if (depth >= MAX_EQUAL_DEPTH) return false\n const aArray = Array.isArray(a)\n const bArray = Array.isArray(b)\n if (aArray !== bArray) return false\n if (aArray) {\n const aa = a as unknown[]\n const bb = b as unknown[]\n if (aa.length !== bb.length) return false\n for (let i = 0; i < aa.length; i++) if (!valuesEqual(aa[i], bb[i], depth + 1)) return false\n return true\n }\n const ao = a as Record<string, unknown>\n const bo = b as Record<string, unknown>\n const keys = Object.keys(ao)\n if (keys.length !== Object.keys(bo).length) return false\n for (const key of keys) {\n if (!Object.hasOwn(bo, key) || !valuesEqual(ao[key], bo[key], depth + 1)) return false\n }\n return true\n}\n\n/**\n * A cheap, order-independent structural hash consistent with {@link valuesEqual}:\n * equal values always hash the same. It buckets candidate-equal elements in\n * {@link allUnique} so the exact comparison only ever runs inside a bucket.\n *\n * Object keys are folded commutatively (XOR) so key order does not change the\n * hash, and `NaN` / `-0` collapse the way SameValueZero does. Depth-capped like\n * {@link valuesEqual}: an over-deep value simply shares a bucket and is settled by\n * the (also capped) comparison, so the cap can cost a little time and never a\n * wrong answer. This is the same hash `@amritk/runtime-validators` uses.\n */\nconst structuralHash = (value: unknown, depth = 0): number => {\n if (value === null) return 0x1a2b3c\n const t = typeof value\n if (t === 'number') {\n const n = value as number\n return Number.isNaN(n) ? 0x7ff8 : n === 0 ? 0 : Math.trunc(n * 2654435761) | 0\n }\n if (t === 'string') {\n const s = value as string\n let h = 0x811c9dc5\n for (let i = 0; i < s.length; i++) h = Math.imul(h ^ s.charCodeAt(i), 0x01000193)\n return h | 0\n }\n if (t === 'boolean') return value ? 1 : 2\n if (t !== 'object') return 0x5eed\n if (depth >= MAX_EQUAL_DEPTH) return 0xdee9\n if (Array.isArray(value)) {\n let h = 0x12345 ^ value.length\n for (let i = 0; i < value.length; i++) h = (Math.imul(h, 31) + structuralHash(value[i], depth + 1)) | 0\n return h | 0\n }\n const obj = value as Record<string, unknown>\n const keys = Object.keys(obj)\n let h = 0xabcde ^ keys.length\n for (const k of keys) {\n let kh = 0x811c9dc5\n for (let i = 0; i < k.length; i++) kh = Math.imul(kh ^ k.charCodeAt(i), 0x01000193)\n h = (h ^ (Math.imul(kh, 0x9e3779b1) + structuralHash(obj[k], depth + 1))) | 0\n }\n return h | 0\n}\n\n/**\n * True when every element of `arr` is distinct under structural equality\n * ({@link valuesEqual}). Backs generated `uniqueItems` checks whose items may be\n * objects or arrays, where a `JSON.stringify` dedupe key would be key-order\n * sensitive and let a reordered-but-equal duplicate (`{ a: 1, b: 2 }` vs\n * `{ b: 2, a: 1 }`) slip through.\n *\n * A native `Set` dedupes the all-primitive case in one linear pass. Object and\n * array elements are bucketed by {@link structuralHash} first, so the exact\n * comparison runs only against elements that could actually be equal: an array of\n * distinct objects costs ~O(n) instead of the O(n\u00B2) an exhaustive pairwise sweep\n * charged \u2014 4 000 rows took over half a second of pure comparison before, which\n * is a lot to hand an unauthenticated caller.\n */\nexport const allUnique = (arr: readonly unknown[]): boolean => {\n const len = arr.length\n if (len < 2) return true\n let allPrimitive = true\n for (let i = 0; i < len; i++) {\n const v = arr[i]\n if (v !== null && typeof v === 'object') {\n allPrimitive = false\n break\n }\n }\n if (allPrimitive) return new Set(arr).size === len\n const buckets = new Map<number, unknown[]>()\n for (let i = 0; i < len; i++) {\n const item = arr[i]\n const hash = structuralHash(item)\n const bucket = buckets.get(hash)\n if (bucket === undefined) {\n buckets.set(hash, [item])\n continue\n }\n for (const seen of bucket) if (valuesEqual(seen, item)) return false\n bucket.push(item)\n }\n return true\n}\n\n/**\n * Escapes one JSON Pointer segment (RFC 6901): `~` \u2192 `~0`, `/` \u2192 `~1`, in that\n * order. Generated error paths are built from *runtime* keys wherever the schema\n * did not name them \u2014 a `patternProperties` match, an `additionalProperties`\n * sweep, a `propertyNames` loop \u2014 and a key containing a `/` would otherwise read\n * back as two segments, so an error on `{\"a/b\": \u2026}` pointed at `/a/b`, which is\n * the child `b` of a property `a`. Keys the schema *does* name are escaped at\n * generation time instead, and `@amritk/runtime-validators` escapes the same way,\n * so all three agree.\n *\n * The `indexOf` pre-test keeps the common key \u2014 no `/`, no `~` \u2014 off the replace\n * path entirely, which is what the interpreter does for the same reason.\n */\nexport const escapePointer = (key: string): string =>\n key.indexOf('/') !== -1 || key.indexOf('~') !== -1 ? key.replace(/~/g, '~0').replace(/\\//g, '~1') : key\n";
15
+ export declare const VALIDATION_RESULT_CONTENT = "/**\n * A single validation error with a human-readable message and a JSON Pointer\n * path indicating where in the document the error occurred.\n */\nexport type ValidationError = {\n message: string\n path: string\n}\n\n/**\n * The result of a generated validator function.\n * Returns `true` when the input is valid, or an object with `valid: false`\n * and a list of errors when it is not.\n */\nexport type ValidationResult = true | { valid: false; errors: ValidationError[] }\n\n/**\n * How deep a structural comparison walks before it gives up and answers \"not\n * equal\".\n *\n * JSON data is acyclic, but a generated validator is a plain function applied to\n * whatever in-memory value a caller hands it \u2014 and a self-referential object\n * reaching a `const` / `enum` / `uniqueItems` check used to recurse until the\n * stack overflowed, so `validateFoo` threw a `RangeError` instead of returning\n * the `ValidationResult` its signature promises. The cap turns that into an\n * ordinary \"these are different\" without ever coming near real data;\n * `@amritk/runtime-validators` guards its own `deepEqual` at the same depth.\n */\nconst MAX_EQUAL_DEPTH = 512\n\n/**\n * Structural deep equality used by generated `const` checks. Objects compare by\n * their key sets rather than serialization, so `{ a: 1, b: 2 }` and\n * `{ b: 2, a: 1 }` are equal \u2014 unlike `JSON.stringify`, which is key-order\n * sensitive and would reject a reordered-but-equal value.\n */\nexport const valuesEqual = (a: unknown, b: unknown, depth = 0): boolean => {\n // SameValueZero: `===` settles every primitive except `NaN`, which counts as\n // equal to itself here. That is what the native `Set` in {@link allUnique} does,\n // what Ajv does, and what the interpreter's `deepEqual` does \u2014 leaving it out\n // made a `NaN` nested inside an object compare unequal to itself, so the same\n // array was \"unique\" here and \"duplicated\" everywhere else.\n if (a === b || (Number.isNaN(a) && Number.isNaN(b))) return true\n if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) return false\n if (depth >= MAX_EQUAL_DEPTH) return false\n const aArray = Array.isArray(a)\n const bArray = Array.isArray(b)\n if (aArray !== bArray) return false\n if (aArray) {\n const aa = a as unknown[]\n const bb = b as unknown[]\n if (aa.length !== bb.length) return false\n for (let i = 0; i < aa.length; i++) if (!valuesEqual(aa[i], bb[i], depth + 1)) return false\n return true\n }\n const ao = a as Record<string, unknown>\n const bo = b as Record<string, unknown>\n const keys = Object.keys(ao)\n if (keys.length !== Object.keys(bo).length) return false\n for (const key of keys) {\n if (!Object.hasOwn(bo, key) || !valuesEqual(ao[key], bo[key], depth + 1)) return false\n }\n return true\n}\n\n/**\n * A cheap, order-independent structural hash consistent with {@link valuesEqual}:\n * equal values always hash the same. It buckets candidate-equal elements in\n * {@link allUnique} so the exact comparison only ever runs inside a bucket.\n *\n * Object keys are folded commutatively (XOR) so key order does not change the\n * hash, and `NaN` / `-0` collapse the way SameValueZero does. Depth-capped like\n * {@link valuesEqual}: an over-deep value simply shares a bucket and is settled by\n * the (also capped) comparison, so the cap can cost a little time and never a\n * wrong answer. This is the same hash `@amritk/runtime-validators` uses.\n */\nconst structuralHash = (value: unknown, depth = 0): number => {\n if (value === null) return 0x1a2b3c\n const t = typeof value\n if (t === 'number') {\n const n = value as number\n return Number.isNaN(n) ? 0x7ff8 : n === 0 ? 0 : Math.trunc(n * 2654435761) | 0\n }\n if (t === 'string') {\n const s = value as string\n let h = 0x811c9dc5\n for (let i = 0; i < s.length; i++) h = Math.imul(h ^ s.charCodeAt(i), 0x01000193)\n return h | 0\n }\n if (t === 'boolean') return value ? 1 : 2\n if (t !== 'object') return 0x5eed\n if (depth >= MAX_EQUAL_DEPTH) return 0xdee9\n if (Array.isArray(value)) {\n let h = 0x12345 ^ value.length\n for (let i = 0; i < value.length; i++) h = (Math.imul(h, 31) + structuralHash(value[i], depth + 1)) | 0\n return h | 0\n }\n const obj = value as Record<string, unknown>\n const keys = Object.keys(obj)\n let h = 0xabcde ^ keys.length\n for (const k of keys) {\n let kh = 0x811c9dc5\n for (let i = 0; i < k.length; i++) kh = Math.imul(kh ^ k.charCodeAt(i), 0x01000193)\n h = (h ^ (Math.imul(kh, 0x9e3779b1) + structuralHash(obj[k], depth + 1))) | 0\n }\n return h | 0\n}\n\n/**\n * True when every element of `arr` is distinct under structural equality\n * ({@link valuesEqual}). Backs generated `uniqueItems` checks whose items may be\n * objects or arrays, where a `JSON.stringify` dedupe key would be key-order\n * sensitive and let a reordered-but-equal duplicate (`{ a: 1, b: 2 }` vs\n * `{ b: 2, a: 1 }`) slip through.\n *\n * A native `Set` dedupes the all-primitive case in one linear pass. Object and\n * array elements are bucketed by {@link structuralHash} first, so the exact\n * comparison runs only against elements that could actually be equal: an array of\n * distinct objects costs ~O(n) instead of the O(n\u00B2) an exhaustive pairwise sweep\n * charged \u2014 4 000 rows took over half a second of pure comparison before, which\n * is a lot to hand an unauthenticated caller.\n */\nexport const allUnique = (arr: readonly unknown[]): boolean => {\n const len = arr.length\n if (len < 2) return true\n let allPrimitive = true\n for (let i = 0; i < len; i++) {\n const v = arr[i]\n if (v !== null && typeof v === 'object') {\n allPrimitive = false\n break\n }\n }\n if (allPrimitive) return new Set(arr).size === len\n const buckets = new Map<number, unknown[]>()\n for (let i = 0; i < len; i++) {\n const item = arr[i]\n const hash = structuralHash(item)\n const bucket = buckets.get(hash)\n if (bucket === undefined) {\n buckets.set(hash, [item])\n continue\n }\n for (const seen of bucket) if (valuesEqual(seen, item)) return false\n bucket.push(item)\n }\n return true\n}\n\n/**\n * True when `test` holds for every element of `arr`, holes included. Backs the\n * item check inside a generated boolean guard.\n *\n * Not `Array.prototype.every`, because that *skips holes* in a sparse array\n * (`[, 'x']`), whereas the validator's index-based loop reads a hole as\n * `undefined` and rejects it \u2014 and the guard must never accept what the validator\n * rejects. The guard used to get that by materialising `Array.from(arr)` first,\n * which copied every array it looked at; an index loop reads a hole the same way\n * and copies nothing.\n */\nexport const everyItem = (arr: readonly unknown[], test: (item: unknown) => boolean): boolean => {\n for (let i = 0; i < arr.length; i++) if (!test(arr[i])) return false\n return true\n}\n\n/**\n * Escapes one JSON Pointer segment (RFC 6901): `~` \u2192 `~0`, `/` \u2192 `~1`, in that\n * order. Generated error paths are built from *runtime* keys wherever the schema\n * did not name them \u2014 a `patternProperties` match, an `additionalProperties`\n * sweep, a `propertyNames` loop \u2014 and a key containing a `/` would otherwise read\n * back as two segments, so an error on `{\"a/b\": \u2026}` pointed at `/a/b`, which is\n * the child `b` of a property `a`. Keys the schema *does* name are escaped at\n * generation time instead, and `@amritk/runtime-validators` escapes the same way,\n * so all three agree.\n *\n * The `indexOf` pre-test keeps the common key \u2014 no `/`, no `~` \u2014 off the replace\n * path entirely, which is what the interpreter does for the same reason.\n */\nexport const escapePointer = (key: string): string =>\n key.indexOf('/') !== -1 || key.indexOf('~') !== -1 ? key.replace(/~/g, '~0').replace(/\\//g, '~1') : key\n";
15
16
  /**
16
17
  * Builds all TypeScript validator files from a JSON Schema by traversing all
17
18
  * `$ref` / `$dynamicRef` references recursively (via the shared
@@ -37,6 +38,11 @@ export declare const VALIDATION_RESULT_CONTENT = "/**\n * A single validation er
37
38
  * loading is yours to do (or `@amritk/resolve-refs`'), and a `$ref` to a URI
38
39
  * nobody registered still stops generation. Only the documents actually
39
40
  * referenced get files.
41
+ * @param unknownKeys - How the generated fast paths prove a closed object
42
+ * (`additionalProperties: false`) carries no undeclared key: `'count-keys'`
43
+ * (the default) compares `Object.keys(obj).length`, `'count-enumerable'`
44
+ * counts with `for…in`. The first is the faster form on JavaScriptCore (Bun),
45
+ * the second on V8 (Node) — see the README for the measurements.
40
46
  * @returns An array of generated TypeScript files
41
47
  *
42
48
  * @example
@@ -50,4 +56,4 @@ export declare const VALIDATION_RESULT_CONTENT = "/**\n * A single validation er
50
56
  * })
51
57
  * ```
52
58
  */
53
- export declare const buildValidatorSchema: (rootSchema: JSONSchema, rootTypeName: string, typeSuffix?: string, schemas?: Readonly<Record<string, unknown>>) => Promise<GeneratedFile[]>;
59
+ export declare const buildValidatorSchema: (rootSchema: JSONSchema, rootTypeName: string, typeSuffix?: string, schemas?: Readonly<Record<string, unknown>>, unknownKeys?: UnknownKeysStrategy) => Promise<GeneratedFile[]>;
@@ -1,4 +1,5 @@
1
1
  import { generateIndexBarrel } from "@amritk/helpers/generate-index-barrel";
2
+ import { DEFAULT_UNKNOWN_KEYS } from "@amritk/helpers/unknown-keys-strategy";
2
3
  import { walkRefGraph } from "@amritk/helpers/walk-ref-graph";
3
4
  import { generateValidatorFile } from "./generate-files.js";
4
5
  const VALIDATION_RESULT_CONTENT = `/**
@@ -150,6 +151,22 @@ export const allUnique = (arr: readonly unknown[]): boolean => {
150
151
  return true
151
152
  }
152
153
 
154
+ /**
155
+ * True when \`test\` holds for every element of \`arr\`, holes included. Backs the
156
+ * item check inside a generated boolean guard.
157
+ *
158
+ * Not \`Array.prototype.every\`, because that *skips holes* in a sparse array
159
+ * (\`[, 'x']\`), whereas the validator's index-based loop reads a hole as
160
+ * \`undefined\` and rejects it \u2014 and the guard must never accept what the validator
161
+ * rejects. The guard used to get that by materialising \`Array.from(arr)\` first,
162
+ * which copied every array it looked at; an index loop reads a hole the same way
163
+ * and copies nothing.
164
+ */
165
+ export const everyItem = (arr: readonly unknown[], test: (item: unknown) => boolean): boolean => {
166
+ for (let i = 0; i < arr.length; i++) if (!test(arr[i])) return false
167
+ return true
168
+ }
169
+
153
170
  /**
154
171
  * Escapes one JSON Pointer segment (RFC 6901): \`~\` \u2192 \`~0\`, \`/\` \u2192 \`~1\`, in that
155
172
  * order. Generated error paths are built from *runtime* keys wherever the schema
@@ -208,7 +225,7 @@ const RESERVED_WORDS = /* @__PURE__ */ new Set([
208
225
  "with",
209
226
  "yield"
210
227
  ]);
211
- const buildValidatorSchema = async (rootSchema, rootTypeName, typeSuffix = "", schemas) => {
228
+ const buildValidatorSchema = async (rootSchema, rootTypeName, typeSuffix = "", schemas, unknownKeys = DEFAULT_UNKNOWN_KEYS) => {
212
229
  const files = [];
213
230
  walkRefGraph(rootSchema, rootTypeName, { typeSuffix, ...schemas !== void 0 ? { schemas } : {} }, (node) => {
214
231
  if (node.filename === "validation-result" || node.filename === "index") {
@@ -228,6 +245,7 @@ const buildValidatorSchema = async (rootSchema, rootTypeName, typeSuffix = "", s
228
245
  const content = generateValidatorFile(node.schema, node.typeName, {
229
246
  rootSchema: node.rootSchema,
230
247
  typeSuffix,
248
+ unknownKeys,
231
249
  ...node.ref !== void 0 ? { selfRef: node.ref } : {}
232
250
  });
233
251
  files.push({ filename: `${node.filename}.ts`, content });
@@ -1,3 +1,4 @@
1
+ import { type UnknownKeysStrategy } from '@amritk/helpers/unknown-keys-strategy';
1
2
  import type { JSONSchema } from 'json-schema-typed/draft-2020-12';
2
3
  /**
3
4
  * Options for controlling what gets generated in a validator file.
@@ -17,6 +18,12 @@ type GenerateValidatorFileOptions = {
17
18
  * Defaults to `''` (no suffix).
18
19
  */
19
20
  readonly typeSuffix?: string;
21
+ /**
22
+ * How the fast paths prove a closed object carries no undeclared key —
23
+ * `Object.keys(obj).length` (the default) or a `for…in` count. See
24
+ * {@link UnknownKeysStrategy} for the trade-off between the two.
25
+ */
26
+ readonly unknownKeys?: UnknownKeysStrategy;
20
27
  };
21
28
  /**
22
29
  * Generates a complete TypeScript validator file from a JSON Schema.
@@ -1,4 +1,5 @@
1
1
  import { generateTypeDefinition } from "@amritk/helpers/generate-type-definition";
2
+ import { DEFAULT_UNKNOWN_KEYS } from "@amritk/helpers/unknown-keys-strategy";
2
3
  import { collectValidatorImports } from "./collect-validator-imports.js";
3
4
  import { generateBooleanGuard, generateValidatorFunction } from "./generate-validator-function.js";
4
5
  const escapeForWordMatch = (name) => name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
@@ -8,8 +9,9 @@ const generateValidatorFile = (schema, typeName, options) => {
8
9
  typeSuffix,
9
10
  ...options?.rootSchema !== void 0 ? { rootSchema: options.rootSchema } : {}
10
11
  });
11
- const validatorFunction = generateValidatorFunction(schema, typeName, typeSuffix, options?.rootSchema);
12
- const booleanGuard = generateBooleanGuard(schema, typeName, typeSuffix);
12
+ const unknownKeys = options?.unknownKeys ?? DEFAULT_UNKNOWN_KEYS;
13
+ const validatorFunction = generateValidatorFunction(schema, typeName, typeSuffix, options?.rootSchema, unknownKeys);
14
+ const booleanGuard = generateBooleanGuard(schema, typeName, typeSuffix, unknownKeys);
13
15
  const body = validatorFunction + booleanGuard;
14
16
  const emitted = typeDefinition + body;
15
17
  const mentions = (name) => new RegExp(`\\b${escapeForWordMatch(name)}\\b`).test(emitted);
@@ -25,7 +27,7 @@ const generateValidatorFile = (schema, typeName, options) => {
25
27
  const resultTypes = ["ValidationResult", .../\bValidationError\b/.test(body) ? ["ValidationError"] : []];
26
28
  let result = `import type { ${resultTypes.join(", ")} } from './validation-result.js'
27
29
  `;
28
- const runtimeHelpers = ["valuesEqual", "allUnique", "escapePointer"].filter((name) => body.includes(`${name}(`));
30
+ const runtimeHelpers = ["valuesEqual", "allUnique", "escapePointer", "everyItem"].filter((name) => body.includes(`${name}(`));
29
31
  if (runtimeHelpers.length > 0) {
30
32
  result += `import { ${runtimeHelpers.join(", ")} } from './validation-result.js'
31
33
  `;
@@ -1,3 +1,4 @@
1
+ import { type UnknownKeysStrategy } from '@amritk/helpers/unknown-keys-strategy';
1
2
  import type { JSONSchema } from 'json-schema-typed/draft-2020-12';
2
3
  /**
3
4
  * Generates the exported boolean guard `isTypeName`.
@@ -11,8 +12,11 @@ import type { JSONSchema } from 'json-schema-typed/draft-2020-12';
11
12
  *
12
13
  * The signature is `input is TypeName` whenever that narrowing is sound, and a
13
14
  * plain `boolean` when it is not — see {@link typeDescribesEveryAcceptedValue}.
15
+ *
16
+ * `unknownKeys` picks how a closed object's no-extras test counts keys — see
17
+ * {@link UnknownKeysStrategy}.
14
18
  */
15
- export declare const generateBooleanGuard: (schema: JSONSchema, typeName: string, _suffix?: string) => string;
19
+ export declare const generateBooleanGuard: (schema: JSONSchema, typeName: string, _suffix?: string, unknownKeys?: UnknownKeysStrategy) => string;
16
20
  /**
17
21
  * Generates a TypeScript validator function from a JSON Schema.
18
22
  *
@@ -35,4 +39,4 @@ export declare const generateBooleanGuard: (schema: JSONSchema, typeName: string
35
39
  * // }
36
40
  * ```
37
41
  */
38
- export declare const generateValidatorFunction: (schema: JSONSchema, typeName: string, suffix?: string, rootSchema?: Record<string, unknown>) => string;
42
+ export declare const generateValidatorFunction: (schema: JSONSchema, typeName: string, suffix?: string, rootSchema?: Record<string, unknown>, unknownKeys?: UnknownKeysStrategy) => string;
@@ -8,6 +8,7 @@ import { safeAccessor } from "@amritk/helpers/safe-accessor";
8
8
  import { hasAdditionalProperties, hasAllOf, hasAnyOf, hasConst, hasDependentRequired, hasEnum, hasExclusiveMaximum, hasExclusiveMinimum, hasItems, hasMaxItems, hasMaximum, hasMaxLength, hasMaxProperties, hasMinItems, hasMinimum, hasMinLength, hasMinProperties, hasMultipleOf, hasOneOf, hasPattern, hasProperties, hasPropertyNames, hasRef, hasRequired, hasStrictExclusiveMaximum, hasStrictExclusiveMinimum, hasType, hasUniqueItems, isObjectSchema, isSchemaObject } from "@amritk/helpers/schema-guards";
9
9
  import { maxLengthFailExpr, maxLengthPassExpr, minLengthFailExpr, minLengthPassExpr } from "@amritk/helpers/string-length-check";
10
10
  import { unknownKeyCheck } from "@amritk/helpers/unknown-key-check";
11
+ import { DEFAULT_UNKNOWN_KEYS } from "@amritk/helpers/unknown-keys-strategy";
11
12
  import { assertGeneratableRefs } from "./assert-generatable-refs.js";
12
13
  import { assertUnevaluatedGeneratable, UNPROVABLE_COVERAGE_MESSAGE } from "./assert-unevaluated-generatable.js";
13
14
  import { declaresKeywordOutside } from "./enforced-keywords.js";
@@ -856,7 +857,54 @@ const carriesUnevaluated = (schema) => {
856
857
  const s = schema;
857
858
  return declaresKey(s, "unevaluatedProperties") && s["unevaluatedProperties"] !== true || declaresKey(s, "unevaluatedItems") && s["unevaluatedItems"] !== true;
858
859
  };
859
- const guardPropConditions = (key, propSchema, objAcc) => {
860
+ const createGuardContext = (unknownKeys) => ({ unknownKeys, locals: 0, loops: 0 });
861
+ const emptyGuardBlock = () => ({ conditions: [], nested: [], keyChecks: [] });
862
+ const isFlatGuardBlock = (block) => block.nested.length === 0 && block.keyChecks.length === 0;
863
+ const keySetCheckLines = (check, bail, indent, unknownKeys) => {
864
+ const key = `_k${check.index}`;
865
+ if (check.count === null) {
866
+ const unknown = unknownKeyCheck(check.known, "", Number.POSITIVE_INFINITY).isUnknown(key);
867
+ return [`${indent}for (const ${key} in ${check.obj}) if (${unknown}) ${bail}`];
868
+ }
869
+ if (unknownKeys === "count-keys") {
870
+ return [`${indent}if (Object.keys(${check.obj}).length !== ${check.count}) ${bail}`];
871
+ }
872
+ const count = `_c${check.index}`;
873
+ return [
874
+ `${indent}let ${count} = 0`,
875
+ `${indent}for (const ${key} in ${check.obj}) ${count}++`,
876
+ `${indent}if (${count} !== ${check.count}) ${bail}`
877
+ ];
878
+ };
879
+ const renderChainBail = (conditions, bail, indent, layout) => layout === "inline" ? [`if (!(${conditions.join(" && ")})) ${bail}`] : [`${indent}if (!(`, conditions.map((condition) => `${indent} ${condition}`).join(" &&\n"), `${indent})) ${bail}`];
880
+ const renderGuardBlock = (block, bail, indent, layout, unknownKeys) => {
881
+ const lines = [];
882
+ if (block.conditions.length > 0)
883
+ lines.push(...renderChainBail(block.conditions, bail, indent, layout));
884
+ for (const nested of block.nested) {
885
+ lines.push(`${indent}const ${nested.local} = ${nested.read} as Record<string, unknown>`);
886
+ if (nested.optional) {
887
+ lines.push(`${indent}if (${nested.local} !== undefined) {`);
888
+ lines.push(...renderGuardBlock(nested.block, bail, `${indent} `, layout, unknownKeys));
889
+ lines.push(`${indent}}`);
890
+ } else {
891
+ lines.push(...renderGuardBlock(nested.block, bail, indent, layout, unknownKeys));
892
+ }
893
+ }
894
+ for (const check of block.keyChecks)
895
+ lines.push(...keySetCheckLines(check, bail, indent, unknownKeys));
896
+ return lines;
897
+ };
898
+ const joinInline = (lines) => {
899
+ const trimmed = lines.map((line) => line.trim());
900
+ return trimmed.reduce((text, line, i) => {
901
+ if (i === 0)
902
+ return line;
903
+ const previous = trimmed[i - 1];
904
+ return `${text}${previous.endsWith("{") || line === "}" ? " " : "; "}${line}`;
905
+ }, "");
906
+ };
907
+ const guardPropConditions = (key, propSchema, objAcc, block, ctx) => {
860
908
  if (!isSchemaObject(propSchema))
861
909
  return null;
862
910
  const raw = safeAccessor(objAcc, key);
@@ -876,8 +924,14 @@ const guardPropConditions = (key, propSchema, objAcc) => {
876
924
  return [`typeof ${raw} === 'boolean'`];
877
925
  case "null":
878
926
  return [`${raw} === null`];
879
- case "object":
880
- return guardObjectConditions(propSchema, raw, `(${raw} as Record<string, unknown>)`);
927
+ case "object": {
928
+ const local = `_n${ctx.locals++}`;
929
+ const nested = guardObjectConditions(propSchema, local, local, ctx);
930
+ if (nested === null)
931
+ return null;
932
+ block.nested.push({ local, read: raw, optional: false, block: nested });
933
+ return [];
934
+ }
881
935
  // Arrays need a per-item loop the guard can't express, and any other type
882
936
  // (null, multi-type, untyped) is left to the slow path.
883
937
  default:
@@ -897,7 +951,7 @@ const arrayRejectedByRequiredProp = (keys, required, properties) => {
897
951
  }
898
952
  return false;
899
953
  };
900
- const guardObjectConditions = (schema, raw, objAcc) => {
954
+ const guardObjectConditions = (schema, raw, objAcc, ctx) => {
901
955
  if (!isObjectSchema(schema))
902
956
  return null;
903
957
  if (hasDependentRequired(schema) || hasPropertyNames(schema) || declaresKey(schema, "dependentSchemas"))
@@ -925,23 +979,24 @@ const guardObjectConditions = (schema, raw, objAcc) => {
925
979
  return null;
926
980
  }
927
981
  const arrayCheck = arrayRejectedByRequiredProp(keys, required, properties) ? "" : ` && !Array.isArray(${raw})`;
928
- const conditions = [`typeof ${raw} === 'object' && ${raw} !== null${arrayCheck}`];
982
+ const block = emptyGuardBlock();
983
+ block.conditions.push(`typeof ${raw} === 'object' && ${raw} !== null${arrayCheck}`);
929
984
  for (const key of keys) {
930
985
  if (!required.has(key))
931
986
  return null;
932
- const propConditions = guardPropConditions(key, properties[key], objAcc);
987
+ const propConditions = guardPropConditions(key, properties[key], objAcc, block, ctx);
933
988
  if (propConditions === null)
934
989
  return null;
935
- conditions.push(...propConditions);
990
+ block.conditions.push(...propConditions);
936
991
  }
937
992
  if (strict) {
938
993
  if (!keys.every((key) => required.has(key)))
939
994
  return null;
940
- conditions.push(`Object.keys(${objAcc}).length === ${keys.length}`);
995
+ block.keyChecks.push({ obj: objAcc, count: keys.length, known: keys, index: ctx.loops++ });
941
996
  }
942
- return conditions;
997
+ return block;
943
998
  };
944
- const generateObjectValidator = (schema, typeName, suffix, rootSchema) => {
999
+ const generateObjectValidator = (schema, typeName, suffix, rootSchema, unknownKeys) => {
945
1000
  const vName = validatorName(typeName);
946
1001
  const required = new Set(hasRequired(schema) ? schema.required : []);
947
1002
  const properties = hasProperties(schema) ? schema.properties : {};
@@ -970,7 +1025,7 @@ const generateObjectValidator = (schema, typeName, suffix, rootSchema) => {
970
1025
  propertyLines.push(...objectCombinators);
971
1026
  }
972
1027
  const body = propertyLines.length > 0 ? "\n" + propertyLines.join("\n") + "\n" : "";
973
- const guard = guardObjectConditions(schema, "input", "obj");
1028
+ const guard = guardObjectConditions(schema, "input", "obj", createGuardContext(unknownKeys));
974
1029
  const objBinding = readsObjBinding(body) ? [` const obj = input as Record<string, unknown>`] : [];
975
1030
  const collectBody = (name, exported) => [
976
1031
  `${exported ? "export " : ""}const ${name} = (input: unknown, _path = ''): ValidationResult => {`,
@@ -987,19 +1042,22 @@ const generateObjectValidator = (schema, typeName, suffix, rootSchema) => {
987
1042
  if (!guard) {
988
1043
  return withHoisted(ctx.hoisted, collectBody(vName, true));
989
1044
  }
990
- const guardText = guard.join("\n");
991
1045
  const collectName = `${vName}Errors`;
992
- return withHoisted(ctx.hoisted, [
993
- collectBody(collectName, false),
994
- ``,
995
- `export const ${vName} = (input: unknown, _path = ''): ValidationResult => {`,
996
- ...readsObjBinding(guardText) ? [` const obj = input as Record<string, unknown>`] : [],
1046
+ const bail = `return ${collectName}(input, _path)`;
1047
+ const guardLines = isFlatGuardBlock(guard) ? [
997
1048
  ` if (`,
998
- guard.map((condition) => ` ${condition}`).join(" &&\n"),
1049
+ guard.conditions.map((condition) => ` ${condition}`).join(" &&\n"),
999
1050
  ` ) {`,
1000
1051
  ` return true`,
1001
1052
  ` }`,
1002
- ` return ${collectName}(input, _path)`,
1053
+ ` ${bail}`
1054
+ ] : [...renderGuardBlock(guard, bail, " ", "lines", unknownKeys), ` return true`];
1055
+ return withHoisted(ctx.hoisted, [
1056
+ collectBody(collectName, false),
1057
+ ``,
1058
+ `export const ${vName} = (input: unknown, _path = ''): ValidationResult => {`,
1059
+ ...readsObjBinding(guardLines.join("\n")) ? [` const obj = input as Record<string, unknown>`] : [],
1060
+ ...guardLines,
1003
1061
  `}`
1004
1062
  ].join("\n"));
1005
1063
  };
@@ -1024,16 +1082,16 @@ const valueCanHaveType = (value, type) => {
1024
1082
  return true;
1025
1083
  }
1026
1084
  };
1027
- const booleanLeafExpr = (schema, acc, narrowable = true) => {
1085
+ const flatLeafBails = (schema) => hasRef(schema) || hasConst(schema) || hasOneOf(schema) || declaresKey(schema, "anyOf") || declaresKey(schema, "allOf") || declaresKey(schema, "not") || declaresKey(schema, "if") || declaresKey(schema, "contains") || declaresKey(schema, "prefixItems") || // A draft-07 tuple: the fixed positions live in an *array* `items` and the
1086
+ // tail in `additionalItems`. Neither is expressible flat, and reading past
1087
+ // them let the guard accept tuples `validateX` rejects.
1088
+ Array.isArray(readKey(schema, "items")) || declaresKey(schema, "additionalItems") || carriesUnevaluated(schema) || getMjstInstanceOf(schema) !== void 0 || getMjstPrimitive(schema) !== void 0;
1089
+ const isNestedObjectLeaf = (schema) => isSchemaObject(schema) && !flatLeafBails(schema) && hasType(schema) && schema.type === "object";
1090
+ const booleanLeafExpr = (schema, acc, ctx) => {
1028
1091
  if (!isSchemaObject(schema))
1029
1092
  return null;
1030
- const typed = (type) => narrowable ? acc : `(${acc} as ${type})`;
1031
- if (hasRef(schema) || hasConst(schema) || hasOneOf(schema) || declaresKey(schema, "anyOf") || declaresKey(schema, "allOf") || declaresKey(schema, "not") || declaresKey(schema, "if") || declaresKey(schema, "contains") || declaresKey(schema, "prefixItems") || // A draft-07 tuple: the fixed positions live in an *array* `items` and the
1032
- // tail in `additionalItems`. Neither is expressible flat, and reading past
1033
- // them let the guard accept tuples `validateX` rejects.
1034
- Array.isArray(readKey(schema, "items")) || declaresKey(schema, "additionalItems") || carriesUnevaluated(schema) || getMjstInstanceOf(schema) !== void 0 || getMjstPrimitive(schema) !== void 0) {
1093
+ if (flatLeafBails(schema))
1035
1094
  return null;
1036
- }
1037
1095
  if (!hasType(schema)) {
1038
1096
  if (!hasEnum(schema))
1039
1097
  return null;
@@ -1051,62 +1109,62 @@ const booleanLeafExpr = (schema, acc, narrowable = true) => {
1051
1109
  // written as the pass condition (`x >= min`) because the validator's error
1052
1110
  // condition is its negation; the length checks come from the same
1053
1111
  // `string-length-check` emitter the validator uses, for the same reason.
1112
+ // A constrained check reads `acc` again after the `typeof`, and TypeScript
1113
+ // narrows it there because every `acc` is a *reference* — `obj.a`, `_n0.b`,
1114
+ // `_it` — never a cast expression: nested members read through a hoisted
1115
+ // local (see {@link NestedGuard}), and a narrowing does not survive two
1116
+ // spellings of `(obj.a as Record<string, unknown>).b`.
1054
1117
  case "string": {
1055
- const str = typed("string");
1056
1118
  const parts = [`typeof ${acc} === 'string'`];
1057
1119
  if (hasPattern(schema))
1058
- parts.push(`${regexLiteral(schema.pattern)}.test(${str})`);
1120
+ parts.push(`${regexLiteral(schema.pattern)}.test(${acc})`);
1059
1121
  if (hasMinLength(schema))
1060
- parts.push(minLengthPassExpr(str, schema.minLength));
1122
+ parts.push(minLengthPassExpr(acc, schema.minLength));
1061
1123
  if (hasMaxLength(schema))
1062
- parts.push(maxLengthPassExpr(str, schema.maxLength));
1124
+ parts.push(maxLengthPassExpr(acc, schema.maxLength));
1063
1125
  return withMembership(parts.join(" && "));
1064
1126
  }
1065
1127
  case "number":
1066
1128
  case "integer": {
1067
- const num = typed("number");
1068
1129
  const parts = [`typeof ${acc} === 'number'`];
1069
1130
  if (t === "integer")
1070
1131
  parts.push(`Number.isInteger(${acc})`);
1071
1132
  if (hasMinimum(schema))
1072
- parts.push(boundPassExpr(num, "minimum", schema.minimum, hasStrictExclusiveMinimum(schema)));
1133
+ parts.push(boundPassExpr(acc, "minimum", schema.minimum, hasStrictExclusiveMinimum(schema)));
1073
1134
  if (hasMaximum(schema))
1074
- parts.push(boundPassExpr(num, "maximum", schema.maximum, hasStrictExclusiveMaximum(schema)));
1135
+ parts.push(boundPassExpr(acc, "maximum", schema.maximum, hasStrictExclusiveMaximum(schema)));
1075
1136
  if (hasExclusiveMinimum(schema))
1076
- parts.push(boundPassExpr(num, "minimum", schema.exclusiveMinimum, true));
1137
+ parts.push(boundPassExpr(acc, "minimum", schema.exclusiveMinimum, true));
1077
1138
  if (hasExclusiveMaximum(schema))
1078
- parts.push(boundPassExpr(num, "maximum", schema.exclusiveMaximum, true));
1139
+ parts.push(boundPassExpr(acc, "maximum", schema.exclusiveMaximum, true));
1079
1140
  if (hasMultipleOf(schema))
1080
- parts.push(multipleOfPassExpr(num, schema.multipleOf));
1141
+ parts.push(multipleOfPassExpr(acc, schema.multipleOf));
1081
1142
  return withMembership(parts.join(" && "));
1082
1143
  }
1083
1144
  case "boolean":
1084
1145
  return withMembership(`typeof ${acc} === 'boolean'`);
1085
1146
  case "null":
1086
1147
  return withMembership(`${acc} === null`);
1087
- case "object": {
1088
- const parts = booleanObjectParts(schema, acc, `(${acc} as Record<string, unknown>)`, false);
1089
- return parts === null ? null : withMembership(parts.join(" && "));
1090
- }
1148
+ case "object":
1149
+ return null;
1091
1150
  case "array":
1092
- return withMembership(booleanArrayExpr(schema, acc, narrowable));
1151
+ return withMembership(booleanArrayExpr(schema, acc, ctx));
1093
1152
  default:
1094
1153
  return null;
1095
1154
  }
1096
1155
  };
1097
- const booleanArrayExpr = (schema, acc, narrowable = true) => {
1098
- const arr = narrowable ? acc : `(${acc} as unknown[])`;
1156
+ const booleanArrayExpr = (schema, acc, ctx) => {
1099
1157
  const parts = [`Array.isArray(${acc})`];
1100
1158
  if (hasMinItems(schema))
1101
- parts.push(`${arr}.length >= ${schema.minItems}`);
1159
+ parts.push(`${acc}.length >= ${schema.minItems}`);
1102
1160
  if (hasMaxItems(schema))
1103
- parts.push(`${arr}.length <= ${schema.maxItems}`);
1161
+ parts.push(`${acc}.length <= ${schema.maxItems}`);
1104
1162
  if (hasUniqueItems(schema) && schema.uniqueItems === true) {
1105
- parts.push(arrayItemsAreScalarOnly(schema) ? `new Set(${acc} as unknown[]).size === ${arr}.length` : `allUnique(${acc} as unknown[])`);
1163
+ parts.push(arrayItemsAreScalarOnly(schema) ? `new Set(${acc} as unknown[]).size === ${acc}.length` : `allUnique(${acc} as unknown[])`);
1106
1164
  }
1107
1165
  const base = parts.join(" && ");
1108
1166
  if (readKey(schema, "items") === false)
1109
- return `${base} && ${arr}.length === 0`;
1167
+ return `${base} && ${acc}.length === 0`;
1110
1168
  if (!hasItems(schema))
1111
1169
  return base;
1112
1170
  const items = schema.items;
@@ -1114,12 +1172,24 @@ const booleanArrayExpr = (schema, acc, narrowable = true) => {
1114
1172
  return base;
1115
1173
  if (hasRef(items))
1116
1174
  return null;
1117
- const itemExpr = booleanLeafExpr(items, "_it");
1175
+ if (isNestedObjectLeaf(items)) {
1176
+ const local = `_n${ctx.locals++}`;
1177
+ const block = booleanObjectParts(items, local, local, ctx);
1178
+ if (block === null)
1179
+ return null;
1180
+ const body = joinInline([
1181
+ `const ${local} = _it as Record<string, unknown>`,
1182
+ ...renderGuardBlock(block, "return false", "", "inline", ctx.unknownKeys),
1183
+ `return true`
1184
+ ]);
1185
+ return `${base} && everyItem(${acc} as unknown[], (_it) => { ${body} })`;
1186
+ }
1187
+ const itemExpr = booleanLeafExpr(items, "_it", ctx);
1118
1188
  if (itemExpr === null)
1119
1189
  return null;
1120
- return `${base} && Array.from(${acc} as unknown[]).every((_it) => (${itemExpr}))`;
1190
+ return `${base} && everyItem(${acc} as unknown[], (_it) => (${itemExpr}))`;
1121
1191
  };
1122
- const booleanObjectParts = (schema, raw, objAcc, narrowable = true) => {
1192
+ const booleanObjectParts = (schema, raw, objAcc, ctx) => {
1123
1193
  if (!isObjectSchema(schema))
1124
1194
  return null;
1125
1195
  if (hasRef(schema) || hasConst(schema) || hasEnum(schema))
@@ -1147,7 +1217,8 @@ const booleanObjectParts = (schema, raw, objAcc, narrowable = true) => {
1147
1217
  const properties = hasProperties(schema) ? schema.properties : {};
1148
1218
  const keys = Object.keys(properties);
1149
1219
  const arrayCheck = arrayRejectedByRequiredProp(keys, required, properties) ? "" : ` && !Array.isArray(${raw})`;
1150
- const parts = [`typeof ${raw} === 'object' && ${raw} !== null${arrayCheck}`];
1220
+ const block = emptyGuardBlock();
1221
+ block.conditions.push(`typeof ${raw} === 'object' && ${raw} !== null${arrayCheck}`);
1151
1222
  for (const key of keys) {
1152
1223
  const propSchema = properties[key];
1153
1224
  if (propSchema === void 0 || !isSchemaObject(propSchema))
@@ -1155,27 +1226,29 @@ const booleanObjectParts = (schema, raw, objAcc, narrowable = true) => {
1155
1226
  if (PROTOTYPE_MEMBERS.has(key))
1156
1227
  return null;
1157
1228
  const member = safeAccessor(objAcc, key);
1158
- const expr = booleanLeafExpr(propSchema, member, narrowable);
1229
+ if (isNestedObjectLeaf(propSchema)) {
1230
+ const local = `_n${ctx.locals++}`;
1231
+ const nested = booleanObjectParts(propSchema, local, local, ctx);
1232
+ if (nested === null)
1233
+ return null;
1234
+ block.nested.push({ local, read: member, optional: !required.has(key), block: nested });
1235
+ continue;
1236
+ }
1237
+ const expr = booleanLeafExpr(propSchema, member, ctx);
1159
1238
  if (expr === null)
1160
1239
  return null;
1161
- parts.push(required.has(key) ? expr : `(${member} === undefined || (${expr}))`);
1240
+ block.conditions.push(required.has(key) ? expr : `(${member} === undefined || (${expr}))`);
1162
1241
  }
1163
1242
  for (const key of required) {
1164
1243
  if (Object.hasOwn(properties, key))
1165
1244
  continue;
1166
- parts.push(hasOwnCheck(objAcc, key));
1245
+ block.conditions.push(hasOwnCheck(objAcc, key));
1167
1246
  }
1168
1247
  if (strict) {
1169
- if (keys.length === 0) {
1170
- parts.push(`Object.keys(${objAcc}).length === 0`);
1171
- } else if (keys.every((key) => required.has(key))) {
1172
- parts.push(`Object.keys(${objAcc}).length === ${keys.length}`);
1173
- } else {
1174
- const known = keys.map((key) => `_k === ${JSON.stringify(key)}`).join(" || ");
1175
- parts.push(`Object.keys(${objAcc}).every((_k) => ${known})`);
1176
- }
1248
+ const allRequired = keys.every((key) => required.has(key));
1249
+ block.keyChecks.push({ obj: objAcc, count: allRequired ? keys.length : null, known: keys, index: ctx.loops++ });
1177
1250
  }
1178
- return parts;
1251
+ return block;
1179
1252
  };
1180
1253
  const IMPLICIT_OBJECT_KEYWORDS = ["properties", "patternProperties", "additionalProperties"];
1181
1254
  const typeDescribesEveryAcceptedValue = (schema) => {
@@ -1193,27 +1266,29 @@ const typeDescribesEveryAcceptedValue = (schema) => {
1193
1266
  }
1194
1267
  return !IMPLICIT_OBJECT_KEYWORDS.some((keyword) => declaresKey(s, keyword));
1195
1268
  };
1196
- const generateBooleanGuard = (schema, typeName, _suffix = "") => {
1269
+ const generateBooleanGuard = (schema, typeName, _suffix = "", unknownKeys = DEFAULT_UNKNOWN_KEYS) => {
1197
1270
  const name = guardName(typeName);
1198
1271
  const returns = typeDescribesEveryAcceptedValue(rewriteNullable(schema)) ? `input is ${typeName}` : "boolean";
1199
1272
  const fallback = `export const ${name} = (input: unknown): ${returns} => ${validatorName(typeName)}(input) === true`;
1200
1273
  const rewritten = rewriteNullable(schema);
1201
- if (declaresObjectType(rewritten) && objectRootIsSelfContained(rewritten)) {
1202
- const parts = booleanObjectParts(rewritten, "input", "obj");
1203
- if (parts === null)
1274
+ const ctx = createGuardContext(unknownKeys);
1275
+ if (declaresObjectType(rewritten)) {
1276
+ if (!objectRootIsSelfContained(rewritten))
1277
+ return fallback;
1278
+ const block = booleanObjectParts(rewritten, "input", "obj", ctx);
1279
+ if (block === null)
1204
1280
  return fallback;
1281
+ const body = isFlatGuardBlock(block) ? [` return (`, block.conditions.map((part) => ` ${part}`).join(" &&\n"), ` )`] : [...renderGuardBlock(block, "return false", " ", "lines", unknownKeys), ` return true`];
1205
1282
  return [
1206
1283
  `export const ${name} = (input: unknown): ${returns} => {`,
1207
1284
  // Same unused-local as the validator's hot guard: a node with no property
1208
1285
  // to read guards on the shape alone and never touches the narrowing.
1209
- ...readsObjBinding(parts.join("\n")) ? [` const obj = input as Record<string, unknown>`] : [],
1210
- ` return (`,
1211
- parts.map((part) => ` ${part}`).join(" &&\n"),
1212
- ` )`,
1286
+ ...readsObjBinding(body.join("\n")) ? [` const obj = input as Record<string, unknown>`] : [],
1287
+ ...body,
1213
1288
  `}`
1214
1289
  ].join("\n");
1215
1290
  }
1216
- const expr = booleanLeafExpr(rewritten, "input");
1291
+ const expr = booleanLeafExpr(rewritten, "input", ctx);
1217
1292
  if (expr === null)
1218
1293
  return fallback;
1219
1294
  return `export const ${name} = (input: unknown): ${returns} => ${expr}`;
@@ -1471,7 +1546,7 @@ const rewriteNullable = (node) => {
1471
1546
  return { anyOf: [{ type: "null" }, out] };
1472
1547
  return out;
1473
1548
  };
1474
- const generateValidatorFunction = (schema, typeName, suffix = "", rootSchema) => {
1549
+ const generateValidatorFunction = (schema, typeName, suffix = "", rootSchema, unknownKeys = DEFAULT_UNKNOWN_KEYS) => {
1475
1550
  assertGeneratableRefs(schema, typeName);
1476
1551
  const rewritten = rewriteNullable(schema);
1477
1552
  const document = rootSchema ?? schema;
@@ -1480,7 +1555,7 @@ const generateValidatorFunction = (schema, typeName, suffix = "", rootSchema) =>
1480
1555
  return generateGeneralRootValidator(rewritten, typeName, suffix, document);
1481
1556
  }
1482
1557
  if (declaresObjectType(rewritten) && objectRootIsSelfContained(rewritten)) {
1483
- return generateObjectValidator(rewritten, typeName, suffix, document);
1558
+ return generateObjectValidator(rewritten, typeName, suffix, document, unknownKeys);
1484
1559
  }
1485
1560
  return generateScalarValidator(rewritten, typeName, suffix, document);
1486
1561
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@amritk/generate-validators",
3
- "version": "0.15.1",
3
+ "version": "0.16.0",
4
4
  "description": "Generate TypeScript validation functions from JSON Schemas.",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.js",
@@ -41,8 +41,12 @@
41
41
  "prepublishOnly": "node ../../scripts/check-publishable.mjs",
42
42
  "types:check": "tsgo -p . --noEmit",
43
43
  "test": "NODE_ENV=production vitest run --root ../.. generate-validators",
44
+ "prebench": "bun run --filter='@amritk/helpers' build && bun run build",
44
45
  "bench": "bun --conditions development ./bench/run.ts",
45
- "bench:moltar": "bun --conditions development ./bench/moltar.ts"
46
+ "prebench:node": "bun run --filter='@amritk/helpers' build && bun run build",
47
+ "bench:node": "node ./bench/run.ts",
48
+ "bench:moltar": "bun --conditions development ./bench/moltar.ts",
49
+ "bench:moltar:leaderboard": "bun --conditions development ./bench/moltar-leaderboard.ts"
46
50
  },
47
51
  "imports": {
48
52
  "#generators/*": "./src/generators/*.ts"
@@ -55,10 +59,11 @@
55
59
  },
56
60
  "dependencies": {
57
61
  "json-schema-typed": "^8.0.1",
58
- "@amritk/helpers": "^0.18.0"
62
+ "@amritk/helpers": "^0.19.0"
59
63
  },
60
64
  "devDependencies": {
61
- "@amritk/runtime-validators": "^0.13.0",
65
+ "@amritk/generate-parsers": "^0.21.0",
66
+ "@amritk/runtime-validators": "^0.13.1",
62
67
  "@ryoppippi/unplugin-typia": "^2.6.5",
63
68
  "@scalar/openapi-parser": "^0.26.1",
64
69
  "@sinclair/typebox": "^0.34.49",