@amritk/generate-parsers 0.20.0 → 0.21.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
@@ -22,9 +22,11 @@ const files = await buildSchema(schema, 'Document')
22
22
 
23
23
  1. **`buildSchema` is `async` and takes POSITIONAL args, no options object.** The
24
24
  full signature is
25
- `buildSchema(rootSchema, rootTypeName, extensions?, typesOnly?, logWarnings?, strict?, helpersMode?, helpersImportPrefix?, readonly?, stripUnknown?, typeSuffix?, importExt?, caseInsensitive?)`.
25
+ `buildSchema(rootSchema, rootTypeName, extensions?, typesOnly?, logWarnings?, strict?, helpersMode?, helpersImportPrefix?, readonly?, stripUnknown?, typeSuffix?, importExt?, caseInsensitive?, schemas?, unknownKeys?)`.
26
26
  To set a later flag you must pass every intervening positional. (The README's
27
- short form omits the trailing seven.)
27
+ short form omits the trailing seven.) `unknownKeys` (`'count-keys'` by default,
28
+ `'count-enumerable'` for Node-only output) picks how a closed object's fast
29
+ path counts keys; nothing in the generated code detects its runtime.
28
30
  2. **It returns files in memory — it does NOT write to disk.** You write the
29
31
  `GeneratedFile[]` (`{ filename, content }`) yourself. `rootTypeName` becomes
30
32
  that filename (lowercased) as well as the `export type` name, so it must be a
package/README.md CHANGED
@@ -95,6 +95,7 @@ Every option after the first two is **positional** — pass them in this order:
95
95
  | 12 | `importExt` | `'js' \| 'ts'` | `'js'` | Extension emitted on relative import specifiers. `'js'` is the NodeNext form for output you compile; `'ts'` emits the literal on-disk paths so the sources run unbuilt. |
96
96
  | 13 | `caseInsensitive` | `boolean` | `false` | Normalize a mis-cased string to the exact casing of an enum/const member it matches case-insensitively. Coerce mode only. |
97
97
  | 14 | `schemas` | `Record<string, unknown>` | — | Documents you have **already loaded**, keyed by the absolute URI a `$ref` names them by. See below. |
98
+ | 15 | `unknownKeys` | `'count-keys' \| 'count-enumerable'` | `'count-keys'` | How a fast path proves a closed object (`additionalProperties: false`, or a `stripUnknown` build) has no undeclared key: a prototype-guarded `Object.keys(input).length` (fastest on Bun) or a `for…in` count (fastest on Node). See [Counting keys on the fast path](#counting-keys-on-the-fast-path). |
98
99
 
99
100
  Returns: `Promise<GeneratedFile[]>`.
100
101
 
@@ -184,6 +185,14 @@ nobody registered still stops the build, with a message naming the ref.
184
185
  <tr>
185
186
  <td colspan="3">Normalize a mis-cased string to the exact casing of a declared enum/const member it matches case-insensitively (e.g. hElLo → hello) instead of coercing to the default. Coerce mode only — strict parsers still reject a casing mismatch. Correctly-cased input keeps the exact-match fast path, so the hot path is unaffected.</td>
186
187
  </tr>
188
+ <tr>
189
+ <td>🔢 <code>unknownKeys</code></td>
190
+ <td><code>string</code></td>
191
+ <td align="center"><code>"count-keys"</code></td>
192
+ </tr>
193
+ <tr>
194
+ <td colspan="3">How a fast path proves a closed object (additionalProperties: false, or a stripUnknown build) carries no undeclared key. 'count-keys' is a prototype-guarded Object.keys(input).length comparison, the faster form on JavaScriptCore (Bun); 'count-enumerable' is a for…in count that allocates nothing; it was the faster form on Node 22, but on Node 26 'count-keys' ties or wins there too. Keep the default unless you are pinned to an older V8 and have measured your own shapes; nothing detects the runtime at runtime.<br><strong>Allowed:</strong> <code>"count-enumerable"</code>, <code>"count-keys"</code></td>
195
+ </tr>
187
196
  </tbody>
188
197
  </table>
189
198
  <!-- config-table-end -->
@@ -237,9 +246,10 @@ Three deliberate departures:
237
246
  Ajv accepts `"a string"` against `{ properties: { a: {} } }`; a parser has to
238
247
  return the type it declares, and the declared type is an object.
239
248
 
240
- If a strict parser cannot enforce something — a `$ref` cycle it would have to
241
- inline because the build emits a single file, a subschema using a keyword it
242
- cannot prove — generation **fails with an error naming the keyword** rather than
249
+ If a strict parser cannot enforce something — a `$ref` cycle inside a subschema it
250
+ has to match inline (`contains`, `not`, `propertyNames`, `dependentSchemas`, or an
251
+ `unevaluated*` backstop), which the inline matcher will not unroll; a subschema
252
+ using a keyword it cannot prove — generation **fails with an error naming the keyword** rather than
243
253
  emitting a parser that quietly accepts what the schema forbids. Coercing
244
254
  (non-strict) parsers are documented to repair rather than reject, so they ignore
245
255
  the rejecting keywords by design.
@@ -266,8 +276,9 @@ no I/O to answer the retrieval step. Everything else — applying the base URIs,
266
276
  walking anchors across documents, emitting a parser per definition and an import
267
277
  graph that links — the generator still has to do.
268
278
 
269
- Of the 41 that do not, **18** are the `ignores a non-object` cases that follow
270
- from the third departure above, and **12** are a keyword strict mode will not
279
+ Of the 41 that do not, **18** follow from the third departure above — the
280
+ `ignores a non-object` cases plus three `ref.json` cases where a recursive `$ref`
281
+ lands on a root that declares `properties` — and **12** are a keyword strict mode will not
271
282
  approximate (a cyclic `$ref` with siblings, a cyclic `unevaluatedProperties`) —
272
283
  those cost a build error naming the cause, never a wrong verdict. The rest: **8**
273
284
  `$dynamicRef`s whose binding depends on the evaluation path (a generator emits one
@@ -296,20 +307,34 @@ keys) and `parseStrict` (assert + reject undeclared keys) halves of
296
307
  [`moltar/typescript-runtime-type-benchmarks`](https://github.com/moltar/typescript-runtime-type-benchmarks)
297
308
  against the other *pure* parsers — the ones that return a new typed value rather
298
309
  than mutating in place — [zod](https://zod.dev) and
299
- [TypeBox](https://github.com/sinclairzx81/typebox). Measured on Bun 1.4 (Linux
300
- x64), parsing valid input at steady state:
301
-
302
- | case | mode | mjst (generated) | zod | typebox |
303
- |:--|:--|--:|--:|--:|
304
- | user (4 fields) | parseSafe | **~18M** ops/s | ~3.7M ops/s | ~1.6M ops/s |
305
- | order (nested + array) | parseSafe | **~6.9M** ops/s | ~0.71M ops/s | ~0.24M ops/s |
306
- | user (4 fields) | parseStrict | **~14M** ops/s | ~2.3M ops/s | ~2.04M ops/s |
307
- | order (nested + array) | parseStrict | **~7M** ops/s | ~0.43M ops/s | ~0.36M ops/s |
308
-
309
- The upstream `assert` case (seven scalar roots plus a nested object) runs faster
310
- still — ~51M ops/s strict, ~127M safe — but at that size the numbers swing enough
311
- run-to-run that the ratio, not the absolute, is the honest signal: mjst lands
312
- ~5–29× ahead of zod across every case above.
310
+ [TypeBox](https://github.com/sinclairzx81/typebox).
311
+
312
+ Both runtimes were measured together on one machine (Linux x64, a 4-vCPU cloud
313
+ box, Bun 1.4.0 and Node 26.8.1 — the same machine and runtimes as every table in
314
+ this repo), each cell the median of three separate runs of the whole suite.
315
+ Within one sitting a cell repeats to within a few percent; the same suite
316
+ measured hours later moved ~60% across every case at once, so read the ratios
317
+ and treat the absolutes as a property of that box.
318
+
319
+ Parsing valid input at steady state:
320
+
321
+ | case | mode | Bun: mjst | Bun: zod | Bun: typebox | Node: mjst | Node: zod | Node: typebox |
322
+ |:--|:--|--:|--:|--:|--:|--:|--:|
323
+ | user (4 fields) | parseSafe | **~163M** | ~3.3M | ~1.7M | **~88M** | ~4.0M | ~0.67M |
324
+ | order (nested + array) | parseSafe | **~7.7M** | ~0.60M | ~0.26M | **~8.2M** | ~0.64M | ~0.15M |
325
+ | assert (moltar shape) | parseSafe | **~120M** | ~3.5M | ~0.87M | **~66M** | ~5.3M | ~0.35M |
326
+ | user (4 fields) | parseStrict | **~43M** | ~1.9M | ~2.2M | **~55M** | ~2.7M | ~1.5M |
327
+ | order (nested + array) | parseStrict | **~13M** | ~0.36M | ~0.43M | **~8.7M** | ~0.55M | ~0.25M |
328
+ | assert (moltar shape) | parseStrict | **~44M** | ~1.4M | ~1.2M | **~34M** | ~3.5M | ~0.80M |
329
+
330
+ <sub>ops/s, higher is better.</sub>
331
+
332
+ Unlike the validator suite — where TypeBox's compiled checker takes the loose
333
+ moltar shape on V8 — the generated parser leads every case on both engines:
334
+ ~12–49× over zod on Bun and ~13–22× on Node. The two `user`/`assert` safe cells
335
+ are the ones to read as ratios rather than absolutes: stripping to four declared
336
+ keys builds one small object and nothing else, which is fast enough that the
337
+ engine's inlining, not the parser, sets the number.
313
338
 
314
339
  What is replicated upstream is the *cases* — the two parse modes and their
315
340
  shapes — not the harness. These numbers come from this repo's own measurement
@@ -321,9 +346,9 @@ reproducible run of both harnesses:
321
346
  [Against the moltar harness](../generate-validators#against-the-moltar-harness).
322
347
 
323
348
  The trade is a one-shot **prepare** cost that only mjst pays — generating the
324
- parser source — which measures **~0.2–0.8 ms** per schema here (zod and TypeBox
325
- author or interpret their parsers with no separate build step, so there is
326
- nothing to time). That is trivially amortized: you generate once at build time
349
+ parser source — which measures **~0.20–0.74 ms** per schema on Bun and
350
+ **~0.16–0.72 ms** on Node (zod and TypeBox author or interpret their parsers
351
+ with no separate build step, so there is nothing to time). That is trivially amortized: you generate once at build time
327
352
  and run the emitted code forever.
328
353
 
329
354
  Every library is checked for agreement — same stripped/rejected output, same
@@ -333,9 +358,48 @@ the optimiser can't hoist the work away. Micro-benchmark figures vary by machine
333
358
  and runtime — reproduce with:
334
359
 
335
360
  ```bash
336
- bun run bench
361
+ bun run bench # Bun / JavaScriptCore
362
+ bun run bench:node # Node / V8 (builds the package first, then runs it under node)
337
363
  ```
338
364
 
365
+ ### Counting keys on the fast path
366
+
367
+ A closed object whose declared properties are all required proves "no undeclared
368
+ key" by counting: the typed checks ahead of the count have already proven every
369
+ declared key present, so exactly *n* keys means no extra. `unknownKeys` (the 15th
370
+ positional argument, `--unknown-keys` on the CLI) picks how the count is spelled,
371
+ because the two spellings trade places between the engines:
372
+
373
+ - **`'count-keys'`** (default) — `Object.getPrototypeOf(input) === Object.prototype
374
+ && Object.keys(input).length === n`, one more term of the fast-path guard. The
375
+ prototype guard keeps an own-key count sound: a crafted prototype could satisfy
376
+ the typed checks through an inherited declared key while an own extra kept the
377
+ count at *n*, so a non-plain object takes the cold path instead, whose `for…in`
378
+ rejection sees the inherited key.
379
+ - **`'count-enumerable'`** — `let c = 0; for (const k in input) c++`, as
380
+ statements behind the guard. It allocates nothing and needs no prototype guard
381
+ (the count sees inherited keys too), and on V8 it is answered from the enum
382
+ cache. On JavaScriptCore a `for…in` over a non-extensible object is the engine's
383
+ slow path, and the strict parse runs at half speed.
384
+
385
+ A nested object's shape is proven on the parent's fast path by the same checks
386
+ its own shape validator makes, spelled out over the cached local rather than
387
+ called — `typeof _nested === "object" && … && Object.keys(_nested).length === n`
388
+ — one level deep and within a size budget, because a call was the one thing
389
+ left on the strict fast path that JavaScriptCore would not see through. Under
390
+ the moltar harness (each case alone in its own process) `parseStrict` reaches
391
+ the harness floor on Bun 1.4.0 (~360M ops/s, the call eliminated) with
392
+ `count-keys` against ~22M with `count-enumerable`.
393
+
394
+ On Node the answer has moved with the engine. On Node 22 the two spellings were
395
+ level (~14M either way, which is where the advice to flip it came from); on
396
+ Node 26.8.1 `count-keys` measures ~29M against `count-enumerable`'s ~26M, so the
397
+ default now ties or wins on both engines. Keep the default unless you are
398
+ pinned to an older V8 and have measured your own shapes. See
399
+ [Choosing how keys are counted](../generate-validators#choosing-how-keys-are-counted)
400
+ in the sister package for the validator numbers and the full trade-off. Whichever
401
+ you pick, every verdict on a value that could have come from JSON is the same.
402
+
339
403
  ---
340
404
 
341
405
  ## Related packages
@@ -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
  import type { HelpersMode } from '../helpers/collect-helpers.js';
3
4
  import type { ImportExtension } from '../helpers/collect-imports.js';
@@ -70,6 +71,11 @@ export type GeneratedFile = {
70
71
  * is yours to do (or `@amritk/resolve-refs`'), and a `$ref` to a URI nobody registered still
71
72
  * stops generation. Only the documents actually referenced get files, so registering more
72
73
  * than the schema uses costs nothing in the output.
74
+ * @param unknownKeys - How the generated fast paths prove a closed object
75
+ * (`additionalProperties: false`, or a `stripUnknown` build) carries no undeclared key:
76
+ * `'count-keys'` (the default) compares `Object.keys(input).length` behind a plain-prototype
77
+ * guard, `'count-enumerable'` counts with `for…in`. The first is the faster form on
78
+ * JavaScriptCore (Bun), the second on V8 (Node) — see the README for the measurements.
73
79
  * @returns An array of generated TypeScript files
74
80
  *
75
81
  * @example
@@ -102,4 +108,4 @@ export type GeneratedFile = {
102
108
  * });
103
109
  * ```
104
110
  */
105
- export declare const buildSchema: (rootSchema: JSONSchema, rootTypeName: string, extensions?: SchemaExtensions, typesOnly?: boolean, logWarnings?: boolean, strict?: boolean, helpersMode?: HelpersMode, helpersImportPrefix?: string, readonly?: boolean, stripUnknown?: boolean, typeSuffix?: string, importExt?: ImportExtension, caseInsensitive?: boolean, schemas?: Readonly<Record<string, unknown>>) => Promise<GeneratedFile[]>;
111
+ export declare const buildSchema: (rootSchema: JSONSchema, rootTypeName: string, extensions?: SchemaExtensions, typesOnly?: boolean, logWarnings?: boolean, strict?: boolean, helpersMode?: HelpersMode, helpersImportPrefix?: string, readonly?: boolean, stripUnknown?: boolean, typeSuffix?: string, importExt?: ImportExtension, caseInsensitive?: boolean, schemas?: Readonly<Record<string, unknown>>, unknownKeys?: UnknownKeysStrategy) => Promise<GeneratedFile[]>;
@@ -3,6 +3,7 @@ import { createRequire } from "node:module";
3
3
  import { dirname, resolve as resolvePath } from "node:path";
4
4
  import { foldNullable } from "@amritk/helpers/fold-nullable";
5
5
  import { generateIndexBarrel } from "@amritk/helpers/generate-index-barrel";
6
+ import { DEFAULT_UNKNOWN_KEYS } from "@amritk/helpers/unknown-keys-strategy";
6
7
  import { walkRefGraph } from "@amritk/helpers/walk-ref-graph";
7
8
  import { applySchemaExtensions } from "../helpers/apply-schema-extensions.js";
8
9
  import { generateFile } from "./generate-files.js";
@@ -26,7 +27,7 @@ const assertRootTypeName = (rootTypeName) => {
26
27
  return;
27
28
  throw new Error(`Invalid root type name ${JSON.stringify(rootTypeName)}: expected a TypeScript identifier. The name is emitted as \`export type <name>\` and, lowercased, used as the output filename, so anything else either fails to compile or escapes the output directory. Derive it with @amritk/helpers' \`deriveRootTypeName\`, which always produces one.`);
28
29
  };
29
- const buildSchema = async (rootSchema, rootTypeName, extensions, typesOnly, logWarnings, strict, helpersMode = "package", helpersImportPrefix = "./", readonly = false, stripUnknown = false, typeSuffix = "", importExt = "js", caseInsensitive = false, schemas) => {
30
+ const buildSchema = async (rootSchema, rootTypeName, extensions, typesOnly, logWarnings, strict, helpersMode = "package", helpersImportPrefix = "./", readonly = false, stripUnknown = false, typeSuffix = "", importExt = "js", caseInsensitive = false, schemas, unknownKeys = DEFAULT_UNKNOWN_KEYS) => {
30
31
  assertRootTypeName(rootTypeName);
31
32
  const files = [];
32
33
  const usedHelpers = /* @__PURE__ */ new Set();
@@ -49,7 +50,8 @@ const buildSchema = async (rootSchema, rootTypeName, extensions, typesOnly, logW
49
50
  ...logWarnings !== void 0 ? { logWarnings } : {},
50
51
  ...strict !== void 0 ? { strict } : {},
51
52
  ...stripUnknown ? { stripUnknown } : {},
52
- ...caseInsensitive ? { caseInsensitive } : {}
53
+ ...caseInsensitive ? { caseInsensitive } : {},
54
+ unknownKeys
53
55
  });
54
56
  files.push({ filename: `${node.filename}.ts`, content: result.content });
55
57
  for (const helper of result.usedHelpers)
@@ -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
  import { type HelpersMode, type RuntimeHelperName } from '../helpers/collect-helpers.js';
3
4
  import { type ImportExtension } from '../helpers/collect-imports.js';
@@ -92,6 +93,12 @@ type GenerateFileOptions = {
92
93
  * already-valid input keeps the exact-match fast path.
93
94
  */
94
95
  readonly caseInsensitive?: boolean;
96
+ /**
97
+ * How the generated fast paths prove a closed object carries no undeclared
98
+ * key — `Object.keys` (the default) or a `for…in` count. See
99
+ * {@link UnknownKeysStrategy} for the trade-off between the two.
100
+ */
101
+ readonly unknownKeys?: UnknownKeysStrategy;
95
102
  };
96
103
  /** Result of generating a single parser file. */
97
104
  export type GeneratedFileContent = {
@@ -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 { collectHelpers } from "../helpers/collect-helpers.js";
3
4
  import { collectImports, collectImportTypeNames } from "../helpers/collect-imports.js";
4
5
  import { generateParserFunction, generateShapeValidator } from "./generate-parser-function.js";
@@ -40,12 +41,14 @@ const generateFile = (schema, typeName, options) => {
40
41
  rootSchema,
41
42
  typeSuffix
42
43
  });
43
- const shapeValidator = generateShapeValidator(schema, typeName, true, typeSuffix, true, stripUnknown, reservedNames);
44
+ const unknownKeys = options?.unknownKeys ?? DEFAULT_UNKNOWN_KEYS;
45
+ const shapeValidator = generateShapeValidator(schema, typeName, true, typeSuffix, true, stripUnknown, reservedNames, unknownKeys);
44
46
  const parserFunction = generateParserFunction(schema, typeName, {
45
47
  useRefImports: true,
46
48
  typeSuffix,
47
49
  reservedNames,
48
50
  shapeValidatorSource: shapeValidator,
51
+ unknownKeys,
49
52
  ...options?.isRoot !== void 0 ? { isRoot: options.isRoot } : {},
50
53
  ...rootSchema !== void 0 ? { rootSchema } : {},
51
54
  ...options?.logWarnings !== void 0 ? { logWarnings: options.logWarnings } : {},
@@ -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 parser function generation behavior.
@@ -67,6 +68,13 @@ type GenerateParserOptions = {
67
68
  * exact `===` fast path and the hot path is unaffected.
68
69
  */
69
70
  readonly caseInsensitive?: boolean;
71
+ /**
72
+ * How the fast paths prove a closed object carries no undeclared key —
73
+ * `Object.getPrototypeOf(input) === Object.prototype && Object.keys(input).length`
74
+ * (the default) or a `for…in` count. See {@link UnknownKeysStrategy} for the
75
+ * trade-off, and {@link keyCountCheck} for what each form emits.
76
+ */
77
+ readonly unknownKeys?: UnknownKeysStrategy;
70
78
  /**
71
79
  * The exact source of the `validate{TypeName}Shape` predicate emitted into
72
80
  * the same file as this parser (generate-files produces it for the root
@@ -108,5 +116,5 @@ export declare const shapeValidatorFunctionName: (typeName: string) => string;
108
116
  * in additionalProperties). The stub is safe: parents calling it will fall
109
117
  * through to the slow path, matching the pre-deep-fast-path behavior.
110
118
  */
111
- export declare const generateShapeValidator: (schema: JSONSchema, typeName: string, useRefImports: boolean, suffix?: string, exported?: boolean, stripUnknown?: boolean, reservedNames?: ReadonlySet<string>) => string;
119
+ export declare const generateShapeValidator: (schema: JSONSchema, typeName: string, useRefImports: boolean, suffix?: string, exported?: boolean, stripUnknown?: boolean, reservedNames?: ReadonlySet<string>, unknownKeys?: UnknownKeysStrategy) => string;
112
120
  export {};