@amritk/generate-validators 0.15.0 → 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 +5 -3
- package/README.md +175 -71
- package/dist/generators/build-schema.d.ts +8 -2
- package/dist/generators/build-schema.js +19 -1
- package/dist/generators/generate-files.d.ts +7 -0
- package/dist/generators/generate-files.js +5 -3
- package/dist/generators/generate-validator-function.d.ts +6 -2
- package/dist/generators/generate-validator-function.js +156 -82
- package/package.json +9 -4
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
|
|
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
|
|
248
|
-
with `additionalProperties: false`
|
|
249
|
-
calling a separate error-collecting function when something
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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 | ~
|
|
259
|
-
| order (nested + array) | **~
|
|
260
|
-
| assert-loose | **~
|
|
261
|
-
| assert-strict | **~
|
|
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
|
|
266
|
-
typia on both, by ~
|
|
267
|
-
the lead run-to-run — and by ~
|
|
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 ~3× on `assert-strict` (with
|
|
268
302
|
`additionalProperties: false`), where mjst counts keys once and typia does not.
|
|
269
|
-
|
|
270
|
-
|
|
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
|
|
279
|
-
|
|
280
|
-
|
|
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.
|
|
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 | ~
|
|
321
|
-
|
|
|
322
|
-
| benny, moltar's `Benchmark` | Bun | ~
|
|
323
|
-
|
|
|
324
|
-
| *no-op control, benny* | *
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
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,
|
|
331
|
-
|
|
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
|
|
347
|
-
declared property is required and already proven present
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
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
|
-
|
|
355
|
-
`Reflect.ownKeys` and `for...in` alike. Property reads
|
|
356
|
-
object
|
|
357
|
-
therefore on strict schemas only.
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
|
366
|
-
|
|
367
|
-
|
|
|
368
|
-
|
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
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
|
|
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
|
|
12
|
-
const
|
|
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
|
|
42
|
+
export declare const generateValidatorFunction: (schema: JSONSchema, typeName: string, suffix?: string, rootSchema?: Record<string, unknown>, unknownKeys?: UnknownKeysStrategy) => string;
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import { regexFlagsFor, regexLiteral } from "@amritk/helpers/escape-regex-pattern";
|
|
2
2
|
import { getMjstInstanceOf, getMjstPrimitive, MJST_EXTENSION_KEY } from "@amritk/helpers/mjst-extension";
|
|
3
3
|
import { multipleOfFailExpr, multipleOfPassExpr } from "@amritk/helpers/multiple-of-check";
|
|
4
|
+
import { boundFailExpr, boundOperator, boundPassExpr } from "@amritk/helpers/numeric-bound-check";
|
|
4
5
|
import { declaresKey, readKey } from "@amritk/helpers/read-key";
|
|
5
6
|
import { refToName } from "@amritk/helpers/ref-to-name";
|
|
6
7
|
import { safeAccessor } from "@amritk/helpers/safe-accessor";
|
|
7
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";
|
|
8
9
|
import { maxLengthFailExpr, maxLengthPassExpr, minLengthFailExpr, minLengthPassExpr } from "@amritk/helpers/string-length-check";
|
|
9
10
|
import { unknownKeyCheck } from "@amritk/helpers/unknown-key-check";
|
|
11
|
+
import { DEFAULT_UNKNOWN_KEYS } from "@amritk/helpers/unknown-keys-strategy";
|
|
10
12
|
import { assertGeneratableRefs } from "./assert-generatable-refs.js";
|
|
11
13
|
import { assertUnevaluatedGeneratable, UNPROVABLE_COVERAGE_MESSAGE } from "./assert-unevaluated-generatable.js";
|
|
12
14
|
import { declaresKeywordOutside } from "./enforced-keywords.js";
|
|
@@ -338,25 +340,23 @@ const generateConstraintChecks = (key, raw, path, propSchema, suffix, ctx) => {
|
|
|
338
340
|
if (hasMinimum(propSchema) || hasMaximum(propSchema) || hasExclusiveMinimum(propSchema) || hasExclusiveMaximum(propSchema) || hasMultipleOf(propSchema)) {
|
|
339
341
|
if (hasMinimum(propSchema)) {
|
|
340
342
|
const strict = hasStrictExclusiveMinimum(propSchema);
|
|
341
|
-
|
|
342
|
-
lines.push(`
|
|
343
|
-
lines.push(` ${ctx.sink}.push({ message: 'must be ${rel} ${propSchema.minimum}', path: ${path} })`);
|
|
343
|
+
lines.push(` if (typeof ${raw} === 'number' && ${boundFailExpr(raw, "minimum", propSchema.minimum, strict)}) {`);
|
|
344
|
+
lines.push(` ${ctx.sink}.push({ message: 'must be ${boundOperator("minimum", strict)} ${propSchema.minimum}', path: ${path} })`);
|
|
344
345
|
lines.push(` }`);
|
|
345
346
|
}
|
|
346
347
|
if (hasMaximum(propSchema)) {
|
|
347
348
|
const strict = hasStrictExclusiveMaximum(propSchema);
|
|
348
|
-
|
|
349
|
-
lines.push(`
|
|
350
|
-
lines.push(` ${ctx.sink}.push({ message: 'must be ${rel} ${propSchema.maximum}', path: ${path} })`);
|
|
349
|
+
lines.push(` if (typeof ${raw} === 'number' && ${boundFailExpr(raw, "maximum", propSchema.maximum, strict)}) {`);
|
|
350
|
+
lines.push(` ${ctx.sink}.push({ message: 'must be ${boundOperator("maximum", strict)} ${propSchema.maximum}', path: ${path} })`);
|
|
351
351
|
lines.push(` }`);
|
|
352
352
|
}
|
|
353
353
|
if (hasExclusiveMinimum(propSchema)) {
|
|
354
|
-
lines.push(` if (typeof ${raw} === 'number' &&
|
|
354
|
+
lines.push(` if (typeof ${raw} === 'number' && ${boundFailExpr(raw, "minimum", propSchema.exclusiveMinimum, true)}) {`);
|
|
355
355
|
lines.push(` ${ctx.sink}.push({ message: 'must be > ${propSchema.exclusiveMinimum}', path: ${path} })`);
|
|
356
356
|
lines.push(` }`);
|
|
357
357
|
}
|
|
358
358
|
if (hasExclusiveMaximum(propSchema)) {
|
|
359
|
-
lines.push(` if (typeof ${raw} === 'number' &&
|
|
359
|
+
lines.push(` if (typeof ${raw} === 'number' && ${boundFailExpr(raw, "maximum", propSchema.exclusiveMaximum, true)}) {`);
|
|
360
360
|
lines.push(` ${ctx.sink}.push({ message: 'must be < ${propSchema.exclusiveMaximum}', path: ${path} })`);
|
|
361
361
|
lines.push(` }`);
|
|
362
362
|
}
|
|
@@ -857,7 +857,54 @@ const carriesUnevaluated = (schema) => {
|
|
|
857
857
|
const s = schema;
|
|
858
858
|
return declaresKey(s, "unevaluatedProperties") && s["unevaluatedProperties"] !== true || declaresKey(s, "unevaluatedItems") && s["unevaluatedItems"] !== true;
|
|
859
859
|
};
|
|
860
|
-
const
|
|
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) => {
|
|
861
908
|
if (!isSchemaObject(propSchema))
|
|
862
909
|
return null;
|
|
863
910
|
const raw = safeAccessor(objAcc, key);
|
|
@@ -877,8 +924,14 @@ const guardPropConditions = (key, propSchema, objAcc) => {
|
|
|
877
924
|
return [`typeof ${raw} === 'boolean'`];
|
|
878
925
|
case "null":
|
|
879
926
|
return [`${raw} === null`];
|
|
880
|
-
case "object":
|
|
881
|
-
|
|
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
|
+
}
|
|
882
935
|
// Arrays need a per-item loop the guard can't express, and any other type
|
|
883
936
|
// (null, multi-type, untyped) is left to the slow path.
|
|
884
937
|
default:
|
|
@@ -898,7 +951,7 @@ const arrayRejectedByRequiredProp = (keys, required, properties) => {
|
|
|
898
951
|
}
|
|
899
952
|
return false;
|
|
900
953
|
};
|
|
901
|
-
const guardObjectConditions = (schema, raw, objAcc) => {
|
|
954
|
+
const guardObjectConditions = (schema, raw, objAcc, ctx) => {
|
|
902
955
|
if (!isObjectSchema(schema))
|
|
903
956
|
return null;
|
|
904
957
|
if (hasDependentRequired(schema) || hasPropertyNames(schema) || declaresKey(schema, "dependentSchemas"))
|
|
@@ -926,23 +979,24 @@ const guardObjectConditions = (schema, raw, objAcc) => {
|
|
|
926
979
|
return null;
|
|
927
980
|
}
|
|
928
981
|
const arrayCheck = arrayRejectedByRequiredProp(keys, required, properties) ? "" : ` && !Array.isArray(${raw})`;
|
|
929
|
-
const
|
|
982
|
+
const block = emptyGuardBlock();
|
|
983
|
+
block.conditions.push(`typeof ${raw} === 'object' && ${raw} !== null${arrayCheck}`);
|
|
930
984
|
for (const key of keys) {
|
|
931
985
|
if (!required.has(key))
|
|
932
986
|
return null;
|
|
933
|
-
const propConditions = guardPropConditions(key, properties[key], objAcc);
|
|
987
|
+
const propConditions = guardPropConditions(key, properties[key], objAcc, block, ctx);
|
|
934
988
|
if (propConditions === null)
|
|
935
989
|
return null;
|
|
936
|
-
conditions.push(...propConditions);
|
|
990
|
+
block.conditions.push(...propConditions);
|
|
937
991
|
}
|
|
938
992
|
if (strict) {
|
|
939
993
|
if (!keys.every((key) => required.has(key)))
|
|
940
994
|
return null;
|
|
941
|
-
|
|
995
|
+
block.keyChecks.push({ obj: objAcc, count: keys.length, known: keys, index: ctx.loops++ });
|
|
942
996
|
}
|
|
943
|
-
return
|
|
997
|
+
return block;
|
|
944
998
|
};
|
|
945
|
-
const generateObjectValidator = (schema, typeName, suffix, rootSchema) => {
|
|
999
|
+
const generateObjectValidator = (schema, typeName, suffix, rootSchema, unknownKeys) => {
|
|
946
1000
|
const vName = validatorName(typeName);
|
|
947
1001
|
const required = new Set(hasRequired(schema) ? schema.required : []);
|
|
948
1002
|
const properties = hasProperties(schema) ? schema.properties : {};
|
|
@@ -971,7 +1025,7 @@ const generateObjectValidator = (schema, typeName, suffix, rootSchema) => {
|
|
|
971
1025
|
propertyLines.push(...objectCombinators);
|
|
972
1026
|
}
|
|
973
1027
|
const body = propertyLines.length > 0 ? "\n" + propertyLines.join("\n") + "\n" : "";
|
|
974
|
-
const guard = guardObjectConditions(schema, "input", "obj");
|
|
1028
|
+
const guard = guardObjectConditions(schema, "input", "obj", createGuardContext(unknownKeys));
|
|
975
1029
|
const objBinding = readsObjBinding(body) ? [` const obj = input as Record<string, unknown>`] : [];
|
|
976
1030
|
const collectBody = (name, exported) => [
|
|
977
1031
|
`${exported ? "export " : ""}const ${name} = (input: unknown, _path = ''): ValidationResult => {`,
|
|
@@ -988,19 +1042,22 @@ const generateObjectValidator = (schema, typeName, suffix, rootSchema) => {
|
|
|
988
1042
|
if (!guard) {
|
|
989
1043
|
return withHoisted(ctx.hoisted, collectBody(vName, true));
|
|
990
1044
|
}
|
|
991
|
-
const guardText = guard.join("\n");
|
|
992
1045
|
const collectName = `${vName}Errors`;
|
|
993
|
-
return
|
|
994
|
-
|
|
995
|
-
``,
|
|
996
|
-
`export const ${vName} = (input: unknown, _path = ''): ValidationResult => {`,
|
|
997
|
-
...readsObjBinding(guardText) ? [` const obj = input as Record<string, unknown>`] : [],
|
|
1046
|
+
const bail = `return ${collectName}(input, _path)`;
|
|
1047
|
+
const guardLines = isFlatGuardBlock(guard) ? [
|
|
998
1048
|
` if (`,
|
|
999
|
-
guard.map((condition) => ` ${condition}`).join(" &&\n"),
|
|
1049
|
+
guard.conditions.map((condition) => ` ${condition}`).join(" &&\n"),
|
|
1000
1050
|
` ) {`,
|
|
1001
1051
|
` return true`,
|
|
1002
1052
|
` }`,
|
|
1003
|
-
`
|
|
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,
|
|
1004
1061
|
`}`
|
|
1005
1062
|
].join("\n"));
|
|
1006
1063
|
};
|
|
@@ -1025,16 +1082,16 @@ const valueCanHaveType = (value, type) => {
|
|
|
1025
1082
|
return true;
|
|
1026
1083
|
}
|
|
1027
1084
|
};
|
|
1028
|
-
const
|
|
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) => {
|
|
1029
1091
|
if (!isSchemaObject(schema))
|
|
1030
1092
|
return null;
|
|
1031
|
-
|
|
1032
|
-
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
|
|
1033
|
-
// tail in `additionalItems`. Neither is expressible flat, and reading past
|
|
1034
|
-
// them let the guard accept tuples `validateX` rejects.
|
|
1035
|
-
Array.isArray(readKey(schema, "items")) || declaresKey(schema, "additionalItems") || carriesUnevaluated(schema) || getMjstInstanceOf(schema) !== void 0 || getMjstPrimitive(schema) !== void 0) {
|
|
1093
|
+
if (flatLeafBails(schema))
|
|
1036
1094
|
return null;
|
|
1037
|
-
}
|
|
1038
1095
|
if (!hasType(schema)) {
|
|
1039
1096
|
if (!hasEnum(schema))
|
|
1040
1097
|
return null;
|
|
@@ -1052,62 +1109,62 @@ const booleanLeafExpr = (schema, acc, narrowable = true) => {
|
|
|
1052
1109
|
// written as the pass condition (`x >= min`) because the validator's error
|
|
1053
1110
|
// condition is its negation; the length checks come from the same
|
|
1054
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`.
|
|
1055
1117
|
case "string": {
|
|
1056
|
-
const str = typed("string");
|
|
1057
1118
|
const parts = [`typeof ${acc} === 'string'`];
|
|
1058
1119
|
if (hasPattern(schema))
|
|
1059
|
-
parts.push(`${regexLiteral(schema.pattern)}.test(${
|
|
1120
|
+
parts.push(`${regexLiteral(schema.pattern)}.test(${acc})`);
|
|
1060
1121
|
if (hasMinLength(schema))
|
|
1061
|
-
parts.push(minLengthPassExpr(
|
|
1122
|
+
parts.push(minLengthPassExpr(acc, schema.minLength));
|
|
1062
1123
|
if (hasMaxLength(schema))
|
|
1063
|
-
parts.push(maxLengthPassExpr(
|
|
1124
|
+
parts.push(maxLengthPassExpr(acc, schema.maxLength));
|
|
1064
1125
|
return withMembership(parts.join(" && "));
|
|
1065
1126
|
}
|
|
1066
1127
|
case "number":
|
|
1067
1128
|
case "integer": {
|
|
1068
|
-
const num = typed("number");
|
|
1069
1129
|
const parts = [`typeof ${acc} === 'number'`];
|
|
1070
1130
|
if (t === "integer")
|
|
1071
1131
|
parts.push(`Number.isInteger(${acc})`);
|
|
1072
1132
|
if (hasMinimum(schema))
|
|
1073
|
-
parts.push(
|
|
1133
|
+
parts.push(boundPassExpr(acc, "minimum", schema.minimum, hasStrictExclusiveMinimum(schema)));
|
|
1074
1134
|
if (hasMaximum(schema))
|
|
1075
|
-
parts.push(
|
|
1135
|
+
parts.push(boundPassExpr(acc, "maximum", schema.maximum, hasStrictExclusiveMaximum(schema)));
|
|
1076
1136
|
if (hasExclusiveMinimum(schema))
|
|
1077
|
-
parts.push(
|
|
1137
|
+
parts.push(boundPassExpr(acc, "minimum", schema.exclusiveMinimum, true));
|
|
1078
1138
|
if (hasExclusiveMaximum(schema))
|
|
1079
|
-
parts.push(
|
|
1139
|
+
parts.push(boundPassExpr(acc, "maximum", schema.exclusiveMaximum, true));
|
|
1080
1140
|
if (hasMultipleOf(schema))
|
|
1081
|
-
parts.push(multipleOfPassExpr(
|
|
1141
|
+
parts.push(multipleOfPassExpr(acc, schema.multipleOf));
|
|
1082
1142
|
return withMembership(parts.join(" && "));
|
|
1083
1143
|
}
|
|
1084
1144
|
case "boolean":
|
|
1085
1145
|
return withMembership(`typeof ${acc} === 'boolean'`);
|
|
1086
1146
|
case "null":
|
|
1087
1147
|
return withMembership(`${acc} === null`);
|
|
1088
|
-
case "object":
|
|
1089
|
-
|
|
1090
|
-
return parts === null ? null : withMembership(parts.join(" && "));
|
|
1091
|
-
}
|
|
1148
|
+
case "object":
|
|
1149
|
+
return null;
|
|
1092
1150
|
case "array":
|
|
1093
|
-
return withMembership(booleanArrayExpr(schema, acc,
|
|
1151
|
+
return withMembership(booleanArrayExpr(schema, acc, ctx));
|
|
1094
1152
|
default:
|
|
1095
1153
|
return null;
|
|
1096
1154
|
}
|
|
1097
1155
|
};
|
|
1098
|
-
const booleanArrayExpr = (schema, acc,
|
|
1099
|
-
const arr = narrowable ? acc : `(${acc} as unknown[])`;
|
|
1156
|
+
const booleanArrayExpr = (schema, acc, ctx) => {
|
|
1100
1157
|
const parts = [`Array.isArray(${acc})`];
|
|
1101
1158
|
if (hasMinItems(schema))
|
|
1102
|
-
parts.push(`${
|
|
1159
|
+
parts.push(`${acc}.length >= ${schema.minItems}`);
|
|
1103
1160
|
if (hasMaxItems(schema))
|
|
1104
|
-
parts.push(`${
|
|
1161
|
+
parts.push(`${acc}.length <= ${schema.maxItems}`);
|
|
1105
1162
|
if (hasUniqueItems(schema) && schema.uniqueItems === true) {
|
|
1106
|
-
parts.push(arrayItemsAreScalarOnly(schema) ? `new Set(${acc} as unknown[]).size === ${
|
|
1163
|
+
parts.push(arrayItemsAreScalarOnly(schema) ? `new Set(${acc} as unknown[]).size === ${acc}.length` : `allUnique(${acc} as unknown[])`);
|
|
1107
1164
|
}
|
|
1108
1165
|
const base = parts.join(" && ");
|
|
1109
1166
|
if (readKey(schema, "items") === false)
|
|
1110
|
-
return `${base} && ${
|
|
1167
|
+
return `${base} && ${acc}.length === 0`;
|
|
1111
1168
|
if (!hasItems(schema))
|
|
1112
1169
|
return base;
|
|
1113
1170
|
const items = schema.items;
|
|
@@ -1115,12 +1172,24 @@ const booleanArrayExpr = (schema, acc, narrowable = true) => {
|
|
|
1115
1172
|
return base;
|
|
1116
1173
|
if (hasRef(items))
|
|
1117
1174
|
return null;
|
|
1118
|
-
|
|
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);
|
|
1119
1188
|
if (itemExpr === null)
|
|
1120
1189
|
return null;
|
|
1121
|
-
return `${base} &&
|
|
1190
|
+
return `${base} && everyItem(${acc} as unknown[], (_it) => (${itemExpr}))`;
|
|
1122
1191
|
};
|
|
1123
|
-
const booleanObjectParts = (schema, raw, objAcc,
|
|
1192
|
+
const booleanObjectParts = (schema, raw, objAcc, ctx) => {
|
|
1124
1193
|
if (!isObjectSchema(schema))
|
|
1125
1194
|
return null;
|
|
1126
1195
|
if (hasRef(schema) || hasConst(schema) || hasEnum(schema))
|
|
@@ -1148,7 +1217,8 @@ const booleanObjectParts = (schema, raw, objAcc, narrowable = true) => {
|
|
|
1148
1217
|
const properties = hasProperties(schema) ? schema.properties : {};
|
|
1149
1218
|
const keys = Object.keys(properties);
|
|
1150
1219
|
const arrayCheck = arrayRejectedByRequiredProp(keys, required, properties) ? "" : ` && !Array.isArray(${raw})`;
|
|
1151
|
-
const
|
|
1220
|
+
const block = emptyGuardBlock();
|
|
1221
|
+
block.conditions.push(`typeof ${raw} === 'object' && ${raw} !== null${arrayCheck}`);
|
|
1152
1222
|
for (const key of keys) {
|
|
1153
1223
|
const propSchema = properties[key];
|
|
1154
1224
|
if (propSchema === void 0 || !isSchemaObject(propSchema))
|
|
@@ -1156,27 +1226,29 @@ const booleanObjectParts = (schema, raw, objAcc, narrowable = true) => {
|
|
|
1156
1226
|
if (PROTOTYPE_MEMBERS.has(key))
|
|
1157
1227
|
return null;
|
|
1158
1228
|
const member = safeAccessor(objAcc, key);
|
|
1159
|
-
|
|
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);
|
|
1160
1238
|
if (expr === null)
|
|
1161
1239
|
return null;
|
|
1162
|
-
|
|
1240
|
+
block.conditions.push(required.has(key) ? expr : `(${member} === undefined || (${expr}))`);
|
|
1163
1241
|
}
|
|
1164
1242
|
for (const key of required) {
|
|
1165
1243
|
if (Object.hasOwn(properties, key))
|
|
1166
1244
|
continue;
|
|
1167
|
-
|
|
1245
|
+
block.conditions.push(hasOwnCheck(objAcc, key));
|
|
1168
1246
|
}
|
|
1169
1247
|
if (strict) {
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
} else if (keys.every((key) => required.has(key))) {
|
|
1173
|
-
parts.push(`Object.keys(${objAcc}).length === ${keys.length}`);
|
|
1174
|
-
} else {
|
|
1175
|
-
const known = keys.map((key) => `_k === ${JSON.stringify(key)}`).join(" || ");
|
|
1176
|
-
parts.push(`Object.keys(${objAcc}).every((_k) => ${known})`);
|
|
1177
|
-
}
|
|
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++ });
|
|
1178
1250
|
}
|
|
1179
|
-
return
|
|
1251
|
+
return block;
|
|
1180
1252
|
};
|
|
1181
1253
|
const IMPLICIT_OBJECT_KEYWORDS = ["properties", "patternProperties", "additionalProperties"];
|
|
1182
1254
|
const typeDescribesEveryAcceptedValue = (schema) => {
|
|
@@ -1194,27 +1266,29 @@ const typeDescribesEveryAcceptedValue = (schema) => {
|
|
|
1194
1266
|
}
|
|
1195
1267
|
return !IMPLICIT_OBJECT_KEYWORDS.some((keyword) => declaresKey(s, keyword));
|
|
1196
1268
|
};
|
|
1197
|
-
const generateBooleanGuard = (schema, typeName, _suffix = "") => {
|
|
1269
|
+
const generateBooleanGuard = (schema, typeName, _suffix = "", unknownKeys = DEFAULT_UNKNOWN_KEYS) => {
|
|
1198
1270
|
const name = guardName(typeName);
|
|
1199
1271
|
const returns = typeDescribesEveryAcceptedValue(rewriteNullable(schema)) ? `input is ${typeName}` : "boolean";
|
|
1200
1272
|
const fallback = `export const ${name} = (input: unknown): ${returns} => ${validatorName(typeName)}(input) === true`;
|
|
1201
1273
|
const rewritten = rewriteNullable(schema);
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
if (
|
|
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)
|
|
1205
1280
|
return fallback;
|
|
1281
|
+
const body = isFlatGuardBlock(block) ? [` return (`, block.conditions.map((part) => ` ${part}`).join(" &&\n"), ` )`] : [...renderGuardBlock(block, "return false", " ", "lines", unknownKeys), ` return true`];
|
|
1206
1282
|
return [
|
|
1207
1283
|
`export const ${name} = (input: unknown): ${returns} => {`,
|
|
1208
1284
|
// Same unused-local as the validator's hot guard: a node with no property
|
|
1209
1285
|
// to read guards on the shape alone and never touches the narrowing.
|
|
1210
|
-
...readsObjBinding(
|
|
1211
|
-
|
|
1212
|
-
parts.map((part) => ` ${part}`).join(" &&\n"),
|
|
1213
|
-
` )`,
|
|
1286
|
+
...readsObjBinding(body.join("\n")) ? [` const obj = input as Record<string, unknown>`] : [],
|
|
1287
|
+
...body,
|
|
1214
1288
|
`}`
|
|
1215
1289
|
].join("\n");
|
|
1216
1290
|
}
|
|
1217
|
-
const expr = booleanLeafExpr(rewritten, "input");
|
|
1291
|
+
const expr = booleanLeafExpr(rewritten, "input", ctx);
|
|
1218
1292
|
if (expr === null)
|
|
1219
1293
|
return fallback;
|
|
1220
1294
|
return `export const ${name} = (input: unknown): ${returns} => ${expr}`;
|
|
@@ -1472,7 +1546,7 @@ const rewriteNullable = (node) => {
|
|
|
1472
1546
|
return { anyOf: [{ type: "null" }, out] };
|
|
1473
1547
|
return out;
|
|
1474
1548
|
};
|
|
1475
|
-
const generateValidatorFunction = (schema, typeName, suffix = "", rootSchema) => {
|
|
1549
|
+
const generateValidatorFunction = (schema, typeName, suffix = "", rootSchema, unknownKeys = DEFAULT_UNKNOWN_KEYS) => {
|
|
1476
1550
|
assertGeneratableRefs(schema, typeName);
|
|
1477
1551
|
const rewritten = rewriteNullable(schema);
|
|
1478
1552
|
const document = rootSchema ?? schema;
|
|
@@ -1481,7 +1555,7 @@ const generateValidatorFunction = (schema, typeName, suffix = "", rootSchema) =>
|
|
|
1481
1555
|
return generateGeneralRootValidator(rewritten, typeName, suffix, document);
|
|
1482
1556
|
}
|
|
1483
1557
|
if (declaresObjectType(rewritten) && objectRootIsSelfContained(rewritten)) {
|
|
1484
|
-
return generateObjectValidator(rewritten, typeName, suffix, document);
|
|
1558
|
+
return generateObjectValidator(rewritten, typeName, suffix, document, unknownKeys);
|
|
1485
1559
|
}
|
|
1486
1560
|
return generateScalarValidator(rewritten, typeName, suffix, document);
|
|
1487
1561
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@amritk/generate-validators",
|
|
3
|
-
"version": "0.
|
|
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
|
-
"
|
|
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.
|
|
62
|
+
"@amritk/helpers": "^0.19.0"
|
|
59
63
|
},
|
|
60
64
|
"devDependencies": {
|
|
61
|
-
"@amritk/
|
|
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",
|