@amritk/generate-validators 0.14.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/README.md +96 -2
  2. package/package.json +6 -4
package/README.md CHANGED
@@ -247,7 +247,7 @@ happy path it runs a single allocation-free boolean guard — a pure `&&` chain
247
247
  `typeof` checks (plus an `Object.keys().length` count when an object is closed
248
248
  with `additionalProperties: false`) — and `return true`s straight away, only
249
249
  calling a separate error-collecting function when something is actually wrong.
250
- Keeping the hot function tiny lets V8 optimise it aggressively, so a valid-input
250
+ Keeping the hot function tiny lets the JIT optimise it aggressively, so a valid-input
251
251
  check beats every other library measured — including the build-time transformer
252
252
  typia — while still emitting full JSON-Pointer errors for invalid input, and
253
253
  emitting the validator stays far cheaper than compiling a schema at startup.
@@ -260,7 +260,7 @@ Measured on Bun 1.4 (Linux x64), validating valid input at steady state:
260
260
  | assert-loose | **~189M** ops/s | ~170M ops/s | ~44M ops/s | ~78M ops/s | ~5M ops/s |
261
261
  | assert-strict | **~104M** ops/s | ~68M ops/s | ~21M ops/s | ~42M ops/s | ~1.8M ops/s |
262
262
 
263
- The `assert-loose` / `assert-strict` rows are the exact shape used by
263
+ The `assert-loose` / `assert-strict` rows use the same *shape* as
264
264
  [`moltar/typescript-runtime-type-benchmarks`](https://github.com/moltar/typescript-runtime-type-benchmarks)
265
265
  (seven scalar roots plus a nested object): the boolean guard keeps mjst ahead of
266
266
  typia on both, by ~11% on `assert-loose` — close enough that the two can trade
@@ -269,6 +269,12 @@ the lead run-to-run — and by ~52% on `assert-strict` (with
269
269
  (typia and TypeBox still win the *invalid* path, where they bail on the first
270
270
  error rather than collecting a full error list.)
271
271
 
272
+ They are **not** that project's numbers and they do not belong next to its
273
+ leaderboard: the shape is shared, the harness is not, and the harness is worth
274
+ an order of magnitude. See
275
+ [Against the moltar harness](#against-the-moltar-harness) for the same functions
276
+ measured under benny, the way the leaderboard measures them.
277
+
272
278
  Preparing a validator costs ~0.3–0.7 ms for mjst codegen and ~0.05–0.2 ms for a
273
279
  TypeBox `TypeCompiler` compile, versus ~13–17 ms for an Ajv compile. Every library
274
280
  agrees on every verdict; parity is asserted before timing.
@@ -289,6 +295,94 @@ reproduce with:
289
295
  bun run bench
290
296
  ```
291
297
 
298
+ ### Against the moltar harness
299
+
300
+ The table above is this package's own harness (`bench/measure.ts`): the
301
+ validator is called directly over a pool of 32 distinct inputs, its verdict is
302
+ folded into an escaping sink so nothing can be optimised away, and the median of
303
+ 21 timed trials is reported.
304
+
305
+ The public leaderboard measures differently. Every operation there goes through
306
+ [benny](https://github.com/caderek/benny) (benchmark.js) into moltar's
307
+ `Benchmark` class, so the timed work is a call inside benchmark.js's compiled
308
+ loop, then a second call through a class property, around a fixture that is one
309
+ shared frozen module-level constant whose verdict `run()` throws away. That
310
+ harness has a floor, and near the top of the range the floor is what gets
311
+ measured.
312
+
313
+ `bun run bench:moltar` runs exactly that harness over the same functions, always
314
+ alongside a **no-op** control — a "validator" that checks nothing, which is the
315
+ fastest number the harness can physically produce. One run on this machine
316
+ (Linux x64, Bun 1.3.11 / Node 22.22, valid input):
317
+
318
+ | harness | runtime | `assert-loose` | `assert-strict` |
319
+ |:--|:--|--:|--:|
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
328
+ validator measurement at all: above that floor a faster validator cannot show
329
+ 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
332
+ [Frozen inputs](#frozen-inputs).
333
+
334
+ The harness makes no difference to correctness and every difference to the
335
+ number, so quote the two separately or not at all. Both are reproducible here:
336
+
337
+ ```bash
338
+ bun run bench # this package's harness
339
+ bun run bench:moltar # benny, under the leaderboard's conditions
340
+ ```
341
+
342
+ ### Frozen inputs
343
+
344
+ Closing an object with `additionalProperties: false` means proving no
345
+ 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
353
+ 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.
385
+
292
386
  ---
293
387
 
294
388
  ## Related packages
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@amritk/generate-validators",
3
- "version": "0.14.0",
3
+ "version": "0.15.0",
4
4
  "description": "Generate TypeScript validation functions from JSON Schemas.",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.js",
@@ -41,7 +41,8 @@
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
- "bench": "bun --conditions development ./bench/run.ts"
44
+ "bench": "bun --conditions development ./bench/run.ts",
45
+ "bench:moltar": "bun --conditions development ./bench/moltar.ts"
45
46
  },
46
47
  "imports": {
47
48
  "#generators/*": "./src/generators/*.ts"
@@ -54,15 +55,16 @@
54
55
  },
55
56
  "dependencies": {
56
57
  "json-schema-typed": "^8.0.1",
57
- "@amritk/helpers": "^0.16.0"
58
+ "@amritk/helpers": "^0.17.0"
58
59
  },
59
60
  "devDependencies": {
60
- "@amritk/runtime-validators": "^0.12.0",
61
+ "@amritk/runtime-validators": "^0.12.1",
61
62
  "@ryoppippi/unplugin-typia": "^2.6.5",
62
63
  "@scalar/openapi-parser": "^0.26.1",
63
64
  "@sinclair/typebox": "^0.34.49",
64
65
  "ajv": "^8.17.1",
65
66
  "ajv-formats": "^3.0.1",
67
+ "benny": "^3.7.1",
66
68
  "typia": "^12.1.1",
67
69
  "zod": "^4.4.3"
68
70
  }