@amritk/generate-parsers 0.16.0 → 0.16.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -156,6 +156,51 @@ The generator handles:
156
156
 
157
157
  ---
158
158
 
159
+ ## Benchmarks
160
+
161
+ Generated parsers are plain, straight-line TypeScript — no schema walking and no
162
+ generic dispatch at runtime, because the schema was already spent at build time.
163
+ Each parser reads the exact keys it declares, coerces or asserts them inline, and
164
+ returns a fresh typed object, so on valid input it runs several times faster than
165
+ libraries that interpret a schema (or a schema-shaped object graph) on every
166
+ call. The `bench/` suite replicates the `parseSafe` (assert + strip undeclared
167
+ keys) and `parseStrict` (assert + reject undeclared keys) halves of
168
+ [`moltar/typescript-runtime-type-benchmarks`](https://github.com/moltar/typescript-runtime-type-benchmarks)
169
+ against the other *pure* parsers — the ones that return a new typed value rather
170
+ than mutating in place — [zod](https://zod.dev) and
171
+ [TypeBox](https://github.com/sinclairzx81/typebox). Measured on Bun 1.3 (Linux
172
+ x64), parsing valid input at steady state:
173
+
174
+ | case | mode | mjst (generated) | zod | typebox |
175
+ |:--|:--|--:|--:|--:|
176
+ | user (4 fields) | parseSafe | **~16M** ops/s | ~3.3M ops/s | ~1.3M ops/s |
177
+ | order (nested + array) | parseSafe | **~5.8M** ops/s | ~0.6M ops/s | ~0.18M ops/s |
178
+ | user (4 fields) | parseStrict | **~13M** ops/s | ~1.8M ops/s | ~1.85M ops/s |
179
+ | order (nested + array) | parseStrict | **~7.5M** ops/s | ~0.36M ops/s | ~0.28M ops/s |
180
+
181
+ The upstream `assert` case (seven scalar roots plus a nested object) runs faster
182
+ still — tens of millions of ops/s — but at that size the numbers swing enough
183
+ run-to-run that the ratio, not the absolute, is the honest signal: mjst lands
184
+ ~5–30× ahead of zod across every case above.
185
+
186
+ The trade is a one-shot **prepare** cost that only mjst pays — generating the
187
+ parser source — which measures **~0.1–0.8 ms** per schema here (zod and TypeBox
188
+ author or interpret their parsers with no separate build step, so there is
189
+ nothing to time). That is trivially amortized: you generate once at build time
190
+ and run the emitted code forever.
191
+
192
+ Every library is checked for agreement — same stripped/rejected output, same
193
+ throws on bad input — before it is timed, and each is timed in its own isolated
194
+ process over a pool of distinct inputs, reporting the median of many trials, so
195
+ the optimiser can't hoist the work away. Micro-benchmark figures vary by machine
196
+ and runtime — reproduce with:
197
+
198
+ ```bash
199
+ bun run bench
200
+ ```
201
+
202
+ ---
203
+
159
204
  ## Related packages
160
205
 
161
206
  - [`@amritk/mjst`](../cli) — CLI wrapping this generator
@@ -0,0 +1,26 @@
1
+ import type { JSONSchema } from 'json-schema-typed/draft-2020-12';
2
+ /**
3
+ * Throws when a schema (anywhere in its subtree) uses a keyword the *strict*
4
+ * parser generator cannot enforce but which narrows the set of valid documents.
5
+ * Mirrors generate-validators' `assertNoUnsupportedKeywords`: failing loudly at
6
+ * generation time is strictly better than silently emitting a parser that
7
+ * accepts input the schema forbids — a strict parser promises to "throw on
8
+ * violations".
9
+ *
10
+ * Two families are rejected:
11
+ *
12
+ * - `unevaluatedProperties` / `unevaluatedItems` with a constraining value
13
+ * (`false` or a subschema). The generator has no support for the "which
14
+ * properties/items were evaluated" bookkeeping these require; only the
15
+ * runtime interpreter does. A `true` value permits everything and is allowed.
16
+ *
17
+ * - `contains`, `propertyNames`, or `dependentSchemas` whose subschema uses a
18
+ * form {@link canMatchSubschema} cannot prove inline (a `$ref`, a combinator,
19
+ * a schema-valued record, …). The parser enforces these keywords only for
20
+ * subschemas it can match exactly; anything else would be silently ignored.
21
+ *
22
+ * This runs for strict generation only — coercing parsers are documented to be
23
+ * permissive (they repair rather than reject), so ignoring these keywords there
24
+ * is the contract, not a lie.
25
+ */
26
+ export declare const assertNoUnsupportedKeywords: (schema: JSONSchema, typeName: string) => void;
@@ -0,0 +1,74 @@
1
+ import { isSchemaObject } from '@amritk/helpers/schema-guards';
2
+ import { canMatchSubschema } from './subschema-match.js';
3
+ /**
4
+ * Throws when a schema (anywhere in its subtree) uses a keyword the *strict*
5
+ * parser generator cannot enforce but which narrows the set of valid documents.
6
+ * Mirrors generate-validators' `assertNoUnsupportedKeywords`: failing loudly at
7
+ * generation time is strictly better than silently emitting a parser that
8
+ * accepts input the schema forbids — a strict parser promises to "throw on
9
+ * violations".
10
+ *
11
+ * Two families are rejected:
12
+ *
13
+ * - `unevaluatedProperties` / `unevaluatedItems` with a constraining value
14
+ * (`false` or a subschema). The generator has no support for the "which
15
+ * properties/items were evaluated" bookkeeping these require; only the
16
+ * runtime interpreter does. A `true` value permits everything and is allowed.
17
+ *
18
+ * - `contains`, `propertyNames`, or `dependentSchemas` whose subschema uses a
19
+ * form {@link canMatchSubschema} cannot prove inline (a `$ref`, a combinator,
20
+ * a schema-valued record, …). The parser enforces these keywords only for
21
+ * subschemas it can match exactly; anything else would be silently ignored.
22
+ *
23
+ * This runs for strict generation only — coercing parsers are documented to be
24
+ * permissive (they repair rather than reject), so ignoring these keywords there
25
+ * is the contract, not a lie.
26
+ */
27
+ export const assertNoUnsupportedKeywords = (schema, typeName) => {
28
+ const fail = (keyword, detail) => {
29
+ throw new Error(`[${typeName}] unsupported keyword "${keyword}": the strict parser generator ${detail}, and would ` +
30
+ `silently accept documents the schema rejects. Generate a coercing (non-strict) parser, validate this ` +
31
+ `schema with @amritk/generate-validators, or remove the keyword.`);
32
+ };
33
+ const visit = (node) => {
34
+ if (typeof node !== 'object' || node === null)
35
+ return;
36
+ if (Array.isArray(node)) {
37
+ for (const item of node)
38
+ visit(item);
39
+ return;
40
+ }
41
+ const record = node;
42
+ // `true` permits everything → no constraint → safe to ignore.
43
+ if ('unevaluatedProperties' in record && record['unevaluatedProperties'] !== true) {
44
+ fail('unevaluatedProperties', 'does not implement it');
45
+ }
46
+ if ('unevaluatedItems' in record && record['unevaluatedItems'] !== true) {
47
+ fail('unevaluatedItems', 'does not implement it');
48
+ }
49
+ if ('contains' in record && !canMatchSubschema(record['contains'])) {
50
+ fail('contains', 'cannot prove its subschema inline');
51
+ }
52
+ if ('propertyNames' in record && isSchemaObject(record['propertyNames'])) {
53
+ if (!canMatchSubschema(record['propertyNames'])) {
54
+ fail('propertyNames', 'cannot prove its subschema inline');
55
+ }
56
+ }
57
+ const dependentSchemas = record['dependentSchemas'];
58
+ if (typeof dependentSchemas === 'object' && dependentSchemas !== null && !Array.isArray(dependentSchemas)) {
59
+ for (const sub of Object.values(dependentSchemas)) {
60
+ // Boolean subschemas are enforced directly (see generateDependentSchemasChecks).
61
+ if (typeof sub === 'boolean')
62
+ continue;
63
+ if (!canMatchSubschema(sub)) {
64
+ fail('dependentSchemas', 'cannot prove one of its subschemas inline');
65
+ }
66
+ }
67
+ }
68
+ // `for..in` (not `Object.values`) to recurse without allocating an array per
69
+ // node — this walk runs for every strict parser generated.
70
+ for (const key in record)
71
+ visit(record[key]);
72
+ };
73
+ visit(schema);
74
+ };
@@ -37,18 +37,27 @@ export const generateEnumCheck = (accessor, enumValues) => {
37
37
  * keeps its plain fallback and emits no extra code.
38
38
  */
39
39
  export const generateEnumCaseInsensitiveCoercion = (accessor, enumValues, fallback) => {
40
- const byLower = {};
40
+ const entries = [];
41
+ const seen = new Set();
41
42
  for (const value of enumValues) {
42
43
  if (typeof value !== 'string')
43
44
  continue;
44
45
  const lower = value.toLowerCase();
45
46
  // First writer wins, so declaration order breaks ties between members that
46
- // fold to the same key (e.g. `"on"` and `"ON"`).
47
- if (!(lower in byLower))
48
- byLower[lower] = value;
47
+ // fold to the same key (e.g. `"on"` and `"ON"`). `Set` membership is used
48
+ // instead of `lower in obj` so a member folding to an inherited name like
49
+ // `constructor` or `toString` is not wrongly treated as already-present.
50
+ if (seen.has(lower))
51
+ continue;
52
+ seen.add(lower);
53
+ entries.push([lower, value]);
49
54
  }
50
- if (Object.keys(byLower).length === 0)
55
+ if (entries.length === 0)
51
56
  return null;
52
- const map = JSON.stringify(byLower);
53
- return `(typeof ${accessor} === "string" ? ((${map} as Record<string, string>)[(${accessor} as string).toLowerCase()] ?? ${fallback}) : ${fallback})`;
57
+ // A `Map` — not a plain object — so a folded key that collides with an
58
+ // inherited member (`constructor`, `toString`, `__proto__`, …) resolves to
59
+ // `undefined` (→ `fallback`) rather than an `Object.prototype` value. A
60
+ // plain-object lookup would return the inherited function/object for such a key.
61
+ const map = `new Map(${JSON.stringify(entries)})`;
62
+ return `(typeof ${accessor} === "string" ? (${map}.get((${accessor} as string).toLowerCase()) ?? ${fallback}) : ${fallback})`;
54
63
  };