@amritk/generate-parsers 0.15.0 → 0.16.1

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
@@ -134,6 +134,14 @@ Returns: `Promise<GeneratedFile[]>`.
134
134
  <tr>
135
135
  <td colspan="3">Build each parser's result from the schema's declared properties only, silently dropping undeclared input keys at every nesting level (zod's .strip()). Extras are never a validation error, so this composes with strict (which still throws on wrong types and missing required properties) and yields to additionalProperties: false, which rejects rather than strips in strict mode.</td>
136
136
  </tr>
137
+ <tr>
138
+ <td>🔡 <code>caseInsensitive</code></td>
139
+ <td><code>boolean</code></td>
140
+ <td align="center"><code>false</code></td>
141
+ </tr>
142
+ <tr>
143
+ <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>
144
+ </tr>
137
145
  </tbody>
138
146
  </table>
139
147
  <!-- config-table-end -->
@@ -148,6 +156,51 @@ The generator handles:
148
156
 
149
157
  ---
150
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 | **~11M** ops/s | ~2.5M ops/s | ~1.0M ops/s |
177
+ | order (nested + array) | parseSafe | **~3.9M** ops/s | ~0.46M ops/s | ~0.13M ops/s |
178
+ | user (4 fields) | parseStrict | **~9M** ops/s | ~1.4M ops/s | ~1.3M ops/s |
179
+ | order (nested + array) | parseStrict | **~5.4M** ops/s | ~0.27M ops/s | ~0.20M 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
+
151
204
  ## Related packages
152
205
 
153
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
+ };
@@ -53,6 +53,12 @@ export type GeneratedFile = {
53
53
  * stripping. Note the `mjst` CLI defaults this to `'ts'` (falling back to `'js'` only
54
54
  * under `--build`, where tsc must compile the sources) so generated output runs under
55
55
  * Node without a build step; direct callers of `buildSchema` opt in explicitly.
56
+ * @param caseInsensitive - When true, the generated coercing parsers normalize a mis-cased
57
+ * string to the exact casing of a declared `enum`/`const` member it matches
58
+ * case-insensitively (e.g. `hElLo` → `hello`) instead of coercing to the default. Coerce
59
+ * mode only — strict parsers still reject a casing mismatch. The normalization lives on
60
+ * the coercion failure branch, so correctly-cased input keeps the exact-match fast path
61
+ * and the hot path is unaffected.
56
62
  * @returns An array of generated TypeScript files
57
63
  *
58
64
  * @example
@@ -85,4 +91,4 @@ export type GeneratedFile = {
85
91
  * });
86
92
  * ```
87
93
  */
88
- 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) => Promise<GeneratedFile[]>;
94
+ 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) => Promise<GeneratedFile[]>;
@@ -89,6 +89,12 @@ const readHelperSource = async (helper, importExt) => {
89
89
  * stripping. Note the `mjst` CLI defaults this to `'ts'` (falling back to `'js'` only
90
90
  * under `--build`, where tsc must compile the sources) so generated output runs under
91
91
  * Node without a build step; direct callers of `buildSchema` opt in explicitly.
92
+ * @param caseInsensitive - When true, the generated coercing parsers normalize a mis-cased
93
+ * string to the exact casing of a declared `enum`/`const` member it matches
94
+ * case-insensitively (e.g. `hElLo` → `hello`) instead of coercing to the default. Coerce
95
+ * mode only — strict parsers still reject a casing mismatch. The normalization lives on
96
+ * the coercion failure branch, so correctly-cased input keeps the exact-match fast path
97
+ * and the hot path is unaffected.
92
98
  * @returns An array of generated TypeScript files
93
99
  *
94
100
  * @example
@@ -121,7 +127,7 @@ const readHelperSource = async (helper, importExt) => {
121
127
  * });
122
128
  * ```
123
129
  */
124
- export const buildSchema = async (rootSchema, rootTypeName, extensions, typesOnly, logWarnings, strict, helpersMode = 'package', helpersImportPrefix = './', readonly = false, stripUnknown = false, typeSuffix = '', importExt = 'js') => {
130
+ export const buildSchema = async (rootSchema, rootTypeName, extensions, typesOnly, logWarnings, strict, helpersMode = 'package', helpersImportPrefix = './', readonly = false, stripUnknown = false, typeSuffix = '', importExt = 'js', caseInsensitive = false) => {
125
131
  const files = [];
126
132
  const usedHelpers = new Set();
127
133
  walkRefGraph(rootSchema, rootTypeName, { typeSuffix }, (node) => {
@@ -145,6 +151,7 @@ export const buildSchema = async (rootSchema, rootTypeName, extensions, typesOnl
145
151
  ...(logWarnings !== undefined ? { logWarnings } : {}),
146
152
  ...(strict !== undefined ? { strict } : {}),
147
153
  ...(stripUnknown ? { stripUnknown } : {}),
154
+ ...(caseInsensitive ? { caseInsensitive } : {}),
148
155
  });
149
156
  files.push({ filename: `${node.filename}.ts`, content: result.content });
150
157
  for (const helper of result.usedHelpers)
@@ -11,3 +11,20 @@
11
11
  * differs from `===`), so the verdict is unchanged.
12
12
  */
13
13
  export declare const generateEnumCheck: (accessor: string, enumValues: readonly unknown[]) => string;
14
+ /**
15
+ * Builds a case-insensitive coercion expression for a string `enum`/`const`
16
+ * value whose exact-match check has already failed. Only the *string* members
17
+ * participate (a number/boolean/null member has no case to fold): a value that
18
+ * matches one case-insensitively normalizes to that member's exact casing, and
19
+ * everything else yields `fallback`.
20
+ *
21
+ * This is emitted ONLY on the already-invalid branch of the coercion ternary, so
22
+ * a correctly-cased value never reaches it — the exact `===` fast path (and the
23
+ * shape validators / deep guards built on it) is unchanged, so the hot path pays
24
+ * nothing. Only a mis-cased value, which today coerces straight to the default,
25
+ * now takes a single `toLowerCase()` and an O(1) map lookup instead.
26
+ *
27
+ * Returns `null` when no member is a string (nothing to fold), so the caller
28
+ * keeps its plain fallback and emits no extra code.
29
+ */
30
+ export declare const generateEnumCaseInsensitiveCoercion: (accessor: string, enumValues: readonly unknown[], fallback: string) => string | null;
@@ -20,3 +20,35 @@ export const generateEnumCheck = (accessor, enumValues) => {
20
20
  const serializedEnum = JSON.stringify(enumValues);
21
21
  return `${serializedEnum}.includes(${accessor} as never)`;
22
22
  };
23
+ /**
24
+ * Builds a case-insensitive coercion expression for a string `enum`/`const`
25
+ * value whose exact-match check has already failed. Only the *string* members
26
+ * participate (a number/boolean/null member has no case to fold): a value that
27
+ * matches one case-insensitively normalizes to that member's exact casing, and
28
+ * everything else yields `fallback`.
29
+ *
30
+ * This is emitted ONLY on the already-invalid branch of the coercion ternary, so
31
+ * a correctly-cased value never reaches it — the exact `===` fast path (and the
32
+ * shape validators / deep guards built on it) is unchanged, so the hot path pays
33
+ * nothing. Only a mis-cased value, which today coerces straight to the default,
34
+ * now takes a single `toLowerCase()` and an O(1) map lookup instead.
35
+ *
36
+ * Returns `null` when no member is a string (nothing to fold), so the caller
37
+ * keeps its plain fallback and emits no extra code.
38
+ */
39
+ export const generateEnumCaseInsensitiveCoercion = (accessor, enumValues, fallback) => {
40
+ const byLower = {};
41
+ for (const value of enumValues) {
42
+ if (typeof value !== 'string')
43
+ continue;
44
+ const lower = value.toLowerCase();
45
+ // 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;
49
+ }
50
+ if (Object.keys(byLower).length === 0)
51
+ return null;
52
+ const map = JSON.stringify(byLower);
53
+ return `(typeof ${accessor} === "string" ? ((${map} as Record<string, string>)[(${accessor} as string).toLowerCase()] ?? ${fallback}) : ${fallback})`;
54
+ };
@@ -78,6 +78,13 @@ type GenerateFileOptions = {
78
78
  * user-named root.
79
79
  */
80
80
  readonly isRoot?: boolean;
81
+ /**
82
+ * When true, a mis-cased string that matches a declared `enum`/`const` member
83
+ * case-insensitively is normalized to that member's exact casing (coerce mode
84
+ * only). The normalization is emitted on the coercion failure branch, so
85
+ * already-valid input keeps the exact-match fast path.
86
+ */
87
+ readonly caseInsensitive?: boolean;
81
88
  };
82
89
  /** Result of generating a single parser file. */
83
90
  export type GeneratedFileContent = {
@@ -67,6 +67,7 @@ export const generateFile = (schema, typeName, options) => {
67
67
  ...(options?.logWarnings !== undefined ? { logWarnings: options.logWarnings } : {}),
68
68
  ...(options?.strict !== undefined ? { strict: options.strict } : {}),
69
69
  ...(options?.stripUnknown !== undefined ? { stripUnknown: options.stripUnknown } : {}),
70
+ ...(options?.caseInsensitive !== undefined ? { caseInsensitive: options.caseInsensitive } : {}),
70
71
  });
71
72
  const shapeValidator = generateShapeValidator(schema, typeName, true, typeSuffix, true, stripUnknown, reservedNames);
72
73
  const combinedFunctions = `${shapeValidator}\n\n${parserFunction}`;
@@ -58,6 +58,15 @@ type GenerateParserOptions = {
58
58
  * generate a validation-free pass-through parser.
59
59
  */
60
60
  readonly isRoot?: boolean;
61
+ /**
62
+ * When true, a mis-cased string that matches a declared `enum`/`const` member
63
+ * case-insensitively is normalized to that member's exact casing (e.g. `hElLo`
64
+ * → `hello`) instead of coercing to the default. Coerce mode only — strict
65
+ * parsers still reject a casing mismatch. The normalization lives on the
66
+ * failure branch of the coercion ternary, so a correctly-cased value keeps the
67
+ * exact `===` fast path and the hot path is unaffected.
68
+ */
69
+ readonly caseInsensitive?: boolean;
61
70
  };
62
71
  /**
63
72
  * Generates a safe parser as an arrow function that never throws.