@amritk/generate-parsers 0.23.0 → 0.25.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 (51) hide show
  1. package/README.md +37 -463
  2. package/dist/index.d.ts +22 -2
  3. package/dist/index.js +18 -1
  4. package/package.json +15 -40
  5. package/AI.md +0 -52
  6. package/dist/generators/assert-supported-keywords.d.ts +0 -30
  7. package/dist/generators/assert-supported-keywords.js +0 -87
  8. package/dist/generators/build-schema.d.ts +0 -111
  9. package/dist/generators/build-schema.js +0 -74
  10. package/dist/generators/generate-deep-equal-check.d.ts +0 -24
  11. package/dist/generators/generate-deep-equal-check.js +0 -35
  12. package/dist/generators/generate-default-from-pattern.d.ts +0 -26
  13. package/dist/generators/generate-default-from-pattern.js +0 -223
  14. package/dist/generators/generate-discriminated-union-validation.d.ts +0 -15
  15. package/dist/generators/generate-discriminated-union-validation.js +0 -28
  16. package/dist/generators/generate-enum-check.d.ts +0 -35
  17. package/dist/generators/generate-enum-check.js +0 -31
  18. package/dist/generators/generate-files.d.ts +0 -138
  19. package/dist/generators/generate-files.js +0 -79
  20. package/dist/generators/generate-non-discriminated-union-validation.d.ts +0 -6
  21. package/dist/generators/generate-non-discriminated-union-validation.js +0 -26
  22. package/dist/generators/generate-parser-function.d.ts +0 -120
  23. package/dist/generators/generate-parser-function.js +0 -1955
  24. package/dist/generators/generate-schema-checks.d.ts +0 -5
  25. package/dist/generators/generate-schema-checks.js +0 -260
  26. package/dist/generators/generate-strict-assertion.d.ts +0 -155
  27. package/dist/generators/generate-strict-assertion.js +0 -759
  28. package/dist/generators/generate-type-checks.d.ts +0 -148
  29. package/dist/generators/generate-type-checks.js +0 -454
  30. package/dist/generators/generate-unique-items-check.d.ts +0 -16
  31. package/dist/generators/generate-unique-items-check.js +0 -32
  32. package/dist/generators/generate-validation-expression.d.ts +0 -79
  33. package/dist/generators/generate-validation-expression.js +0 -416
  34. package/dist/generators/subschema-match.d.ts +0 -48
  35. package/dist/generators/subschema-match.js +0 -485
  36. package/dist/generators/unevaluated-match.d.ts +0 -19
  37. package/dist/generators/unevaluated-match.js +0 -206
  38. package/dist/helpers/apply-schema-extensions.d.ts +0 -35
  39. package/dist/helpers/apply-schema-extensions.js +0 -20
  40. package/dist/helpers/collect-helpers.d.ts +0 -15
  41. package/dist/helpers/collect-helpers.js +0 -64
  42. package/dist/helpers/collect-imports.d.ts +0 -93
  43. package/dist/helpers/collect-imports.js +0 -197
  44. package/dist/helpers/find-discriminator.d.ts +0 -6
  45. package/dist/helpers/find-discriminator.js +0 -27
  46. package/dist/helpers/get-default-value.d.ts +0 -7
  47. package/dist/helpers/get-default-value.js +0 -155
  48. package/dist/helpers/get-discriminator-value.d.ts +0 -6
  49. package/dist/helpers/get-discriminator-value.js +0 -18
  50. package/dist/types/schema-extensions.d.ts +0 -34
  51. package/dist/types/schema-extensions.js +0 -0
package/README.md CHANGED
@@ -1,483 +1,57 @@
1
- <div align="center">
2
-
3
1
  # @amritk/generate-parsers
4
2
 
5
- **Programmatic API for generating TypeScript parsers and type definitions from JSON Schemas.**
6
-
7
- ![status](https://img.shields.io/badge/status-pre--alpha-ef4444?style=flat-square)&nbsp;
8
- ![version](https://img.shields.io/npm/v/@amritk/generate-parsers?style=flat-square&logo=npm&logoColor=white&label=version&color=6366f1)&nbsp;
9
- ![license](https://img.shields.io/badge/license-MIT-22c55e?style=flat-square)&nbsp;
10
- ![JSON Schema](https://img.shields.io/badge/JSON%20Schema-2020--12-f97316?style=flat-square)&nbsp;
11
- ![node](https://img.shields.io/badge/node-%E2%89%A520-339933?style=flat-square&logo=node.js&logoColor=white)&nbsp;
12
- ![vibe coded](https://img.shields.io/badge/vibe-coded-a855f7?style=flat-square)
13
-
14
- </div>
15
-
16
- ---
17
-
18
- ## Overview
3
+ > **Deprecated.** Use [`@amritk/parsers`](https://www.npmjs.com/package/@amritk/parsers) instead.
4
+ >
5
+ > This release is a compatibility shim: `buildSchema` keeps its signature and emits
6
+ > byte-identical output, so upgrading to it breaks nothing. It is the last
7
+ > release of this package.
19
8
 
20
- `@amritk/generate-parsers` is the core code-generation engine behind [mjst](../../README.md). Given a JSON Schema (Draft 2020-12), it produces an array of `{ filename, content }` records — TypeScript type definitions plus optional runtime parser functions that validate and coerce unknown input.
9
+ ## What this was
21
10
 
22
- If you want a CLI, use [`@amritk/mjst`](../cli). Use this package directly when you want to embed schema-to-TypeScript generation inside another build step or tool.
11
+ The programmatic parser and type generator: given a JSON Schema (Draft 2020-12),
12
+ `buildSchema(rootSchema, rootTypeName, …options)` returned an array of
13
+ `{ filename, content }` records carrying a TypeScript type and, unless you asked
14
+ for types only, a parser function for it.
23
15
 
24
- ---
16
+ ## What replaces it
25
17
 
26
- ## Installation
18
+ The engine did not go away — it moved. It now lives inside
19
+ [`@amritk/parsers`](https://github.com/amritk/mjst/tree/main/packages/parsers),
20
+ behind a single `generate()` call that reaches every mode this package had plus
21
+ the others, over one shared type declaration:
27
22
 
28
- ```bash
29
- npm install @amritk/generate-parsers
30
- # or
31
- pnpm add @amritk/generate-parsers
32
- # or
33
- yarn add @amritk/generate-parsers
34
- # or
35
- bun add @amritk/generate-parsers
36
- ```
37
-
38
- ---
23
+ | mode | function | value back | tells you |
24
+ |:---|:---|:---|:---|
25
+ | `types` | — | — | — |
26
+ | `guard` | `isX` | — | a boolean, stopping at the first problem |
27
+ | `check` | `checkX` | — | the first error only |
28
+ | `validate` | `validateX` | — | every error |
29
+ | `coerce` | `coerceX` | coerced | every error |
30
+ | `repair` | `repairX` | repaired | the repairs it made, and any errors left |
31
+ | `parse` | `parseX` | repaired | nothing; never fails |
32
+ | `parseStrict` | `parseX` | as given | throws on the first problem |
39
33
 
40
- ## Usage
34
+ ## Before / after
41
35
 
42
36
  ```ts
37
+ // before
43
38
  import { buildSchema } from '@amritk/generate-parsers'
44
- import type { JSONSchema } from 'json-schema-typed/draft-2020-12'
45
-
46
- const schema: JSONSchema = {
47
- type: 'object',
48
- properties: {
49
- info: { $ref: '#/$defs/info' },
50
- },
51
- $defs: {
52
- info: {
53
- type: 'object',
54
- properties: { title: { type: 'string' } },
55
- },
56
- },
57
- }
58
39
 
59
40
  const files = await buildSchema(schema, 'Document')
60
- // → [{ filename: 'document.ts', content: '...' }, { filename: 'info.ts', content: '...' }, ...]
61
41
  ```
62
42
 
63
- Each entry in `files` is a `GeneratedFile`:
64
-
65
43
  ```ts
66
- type GeneratedFile = {
67
- filename: string
68
- content: string
69
- }
70
- ```
71
-
72
- Write them to disk however you like.
73
-
74
- ---
75
-
76
- ## API
77
-
78
- ### `buildSchema(rootSchema, rootTypeName, …options)`
79
-
80
- Every option after the first two is **positional** — pass them in this order:
81
-
82
- | # | Parameter | Type | Default | Description |
83
- |--:|:---|:---|:---|:---|
84
- | 1 | `rootSchema` | `JSONSchema` | — | The root schema to traverse. `$ref` and `$dynamicRef` are resolved recursively. |
85
- | 2 | `rootTypeName` | `string` | — | Name used for the root type (e.g. `"Document"`). Must be a TypeScript identifier: it is emitted verbatim as `export type <name>` and, lowercased, becomes the output filename, so a name derived from an untrusted document is refused rather than written outside your output directory. `@amritk/helpers`' `deriveRootTypeName` always produces one. |
86
- | 3 | `extensions` | `SchemaExtensions` | — | Map of definition name → extra optional properties to merge in before generation. |
87
- | 4 | `typesOnly` | `boolean` | — | Only emit `.ts` type definitions — skip parser functions and runtime helpers. |
88
- | 5 | `logWarnings` | `boolean` | — | Generated parsers emit a `console.warn` for every input key not declared in the schema's properties. |
89
- | 6 | `strict` | `boolean` | — | Generated parsers throw on type/shape mismatches (wrong type, missing required property, enum/pattern/min/max violations) instead of coercing invalid input to default values. |
90
- | 7 | `helpersMode` | `'package' \| 'embedded'` | `'package'` | `'package'` imports runtime helpers from `@amritk/helpers`; `'embedded'` emits them alongside the output so it is self-contained. |
91
- | 8 | `helpersImportPrefix` | `string` | `'./'` | Prefix the generated files use when importing embedded helpers. |
92
- | 9 | `readonly` | `boolean` | `false` | Emit every property, array, and record in the type definitions as `readonly`. |
93
- | 10 | `stripUnknown` | `boolean` | `false` | Build each result from the declared properties only, dropping undeclared input keys at every level (zod's `.strip()`). |
94
- | 11 | `typeSuffix` | `string` | `''` | Suffix appended to every `$ref`-derived type name (`'Object'` turns `Contact` into `ContactObject`). The root type name is unaffected. |
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
- | 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
- | 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). |
99
-
100
- Returns: `Promise<GeneratedFile[]>`.
101
-
102
- #### Referencing another document
103
-
104
- A `$ref` to a URI is resolvable once you hand over the document behind it — pass
105
- it as the 14th argument:
44
+ // after
45
+ import { generate } from '@amritk/parsers'
106
46
 
107
- ```typescript
108
- const files = await buildSchema(
109
- { $ref: 'https://example.com/user.json' },
110
- 'Document',
111
- undefined, false, false, true, 'embedded', './', false, false, '', 'js', false,
112
- { 'https://example.com/user.json': userSchema },
113
- )
47
+ const files = await generate(schema, 'Document', { modes: ['types', 'parse'] })
114
48
  ```
115
49
 
116
- Each registered document becomes a resource of the generated document: its `$id`,
117
- its `$anchor`s and `$dynamicAnchor`s and its own embedded resources all resolve, a
118
- `$ref` from one registered document into another resolves, and each definition
119
- reached gets a file, a type and a parser like any other. A document with no `$id`
120
- resolves its relative `$ref`s against the URI you registered it under; one whose
121
- `$id` disagrees answers to both.
122
-
123
- Nothing is fetched — you cannot pass a URL, only a document — so generation stays
124
- a pure function of its inputs. Loading is yours to do, or
125
- [`@amritk/resolve-refs`](../resolve-refs)'. Registering more than the schema uses
126
- costs nothing: only the documents actually reached are emitted. A `$ref` to a URI
127
- nobody registered still stops the build, with a message naming the ref.
128
-
129
- > [!NOTE]
130
- > `importExt` defaults to `'js'` here, whereas the [`mjst` CLI](../cli) defaults it to
131
- > `'ts'`. The example above therefore emits `./info.js` specifiers; pass `'ts'` for
132
- > output meant to run without a build step.
133
-
134
- ---
135
-
136
- ## Options
137
-
138
- <!-- config-table-start -->
139
- <table>
140
- <thead>
141
- <tr>
142
- <th>Property</th>
143
- <th>Type</th>
144
- <th align="center">Default</th>
145
- </tr>
146
- </thead>
147
- <tbody>
148
- <tr>
149
- <td>🏷️ <code>typesOnly</code></td>
150
- <td><code>boolean</code></td>
151
- <td align="center"><code>false</code></td>
152
- </tr>
153
- <tr>
154
- <td colspan="3">Generate only TypeScript type definitions without parser functions. Runtime helper files (validators, isObject) are also omitted since they are only needed for parsers.</td>
155
- </tr>
156
- <tr>
157
- <td>⚠️ <code>logWarnings</code></td>
158
- <td><code>boolean</code></td>
159
- <td align="center"><code>false</code></td>
160
- </tr>
161
- <tr>
162
- <td colspan="3">Emit a console.warn in the generated parsers for every input key that is not declared in the schema's properties. Useful for detecting schema drift or unexpected data shapes at runtime.</td>
163
- </tr>
164
- <tr>
165
- <td>🚫 <code>strict</code></td>
166
- <td><code>boolean</code></td>
167
- <td align="center"><code>false</code></td>
168
- </tr>
169
- <tr>
170
- <td colspan="3">Generate parsers that throw on type/shape mismatches (wrong type, missing required property, enum/pattern/min/max violations) instead of coercing invalid input to default values. When a schema sets additionalProperties: false, undeclared keys throw too; otherwise they are still allowed.</td>
171
- </tr>
172
- <tr>
173
- <td>🧹 <code>stripUnknown</code></td>
174
- <td><code>boolean</code></td>
175
- <td align="center"><code>false</code></td>
176
- </tr>
177
- <tr>
178
- <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>
179
- </tr>
180
- <tr>
181
- <td>🔡 <code>caseInsensitive</code></td>
182
- <td><code>boolean</code></td>
183
- <td align="center"><code>false</code></td>
184
- </tr>
185
- <tr>
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>
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>
196
- </tbody>
197
- </table>
198
- <!-- config-table-end -->
199
-
200
- The generator handles:
201
-
202
- - `$ref` and `$dynamicRef` resolution, including JSON Schema 2020-12 `$dynamicAnchor` and a recursive root (`$ref: "#"`)
203
- - Discriminated and non-discriminated unions (`oneOf` / `anyOf`)
204
- - Enums and `const` values
205
- - Nested objects, arrays, records, and tuples (`prefixItems`, and the draft-07 array form of `items`)
206
- - Multi-type schemas (`type: ["string", "null"]`) and the OpenAPI 3.0 `nullable: true` spelling
207
- - Composition alongside a declared shape — `allOf` members and sibling unions are intersected into the type, not dropped
208
- - Pattern-based default values
209
-
210
- ### Strict-mode validation coverage
211
-
212
- A `strict` parser throws on any document the schema rejects, and its accept /
213
- reject decision is held against [Ajv](https://ajv.js.org) (2020-12) by a set of
214
- differential fuzz suites. The whole Draft 2020-12 assertion vocabulary is
215
- enforced:
216
-
217
- | family | keywords |
218
- |:--|:--|
219
- | types | `type` (single and array form), `enum`, `const` (compared structurally) |
220
- | numbers | `minimum`, `maximum`, `exclusiveMinimum`, `exclusiveMaximum`, `multipleOf` |
221
- | strings | `minLength`, `maxLength` (counted in Unicode **code points**), `pattern` |
222
- | arrays | `items`, `prefixItems`, `additionalItems`, `minItems`, `maxItems`, `uniqueItems`, `contains`, `minContains`, `maxContains` |
223
- | objects | `properties`, `required`, `patternProperties`, `additionalProperties`, `propertyNames`, `minProperties`, `maxProperties`, `dependentRequired`, `dependentSchemas` |
224
- | composition | `allOf`, `anyOf`, `oneOf`, `not`, `if` / `then` / `else`, `$ref` (with its 2020-12 siblings), boolean schemas |
225
- | annotations | `unevaluatedProperties`, `unevaluatedItems` — including the keys and indices an `allOf` member, a `$ref` target, a matching `anyOf` / `oneOf` branch, an `if` / `then` / `else` arm, or a triggered `dependentSchemas` entry evaluated |
226
-
227
- Constraints bind their own family, with or without a declared `type`:
228
- `{ minimum: 5 }` rejects `4` and accepts `"anything"`, exactly as JSON Schema
229
- (and Ajv) specify.
230
-
231
- Three deliberate departures:
232
-
233
- - **`format` is an annotation**, not an assertion — matching Ajv's own default
234
- (`ajv-formats` is opt-in there). Use
235
- [`@amritk/runtime-validators`](../runtime-validators), whose `formats` option
236
- asserts them.
237
- - **`multipleOf` compares within a magnitude-scaled tolerance** for a fractional
238
- divisor, rather than Ajv's exact `x / m` integer test, so `0.3` satisfies
239
- `multipleOf: 0.1` here and fails there. IEEE-754 makes the exact test reject
240
- values every author intends as valid. An integer divisor takes the exact `%`
241
- path, which needs no tolerance. Both branches are emitted from
242
- `@amritk/helpers/multiple-of-check`, which mirrors the runtime interpreter's
243
- own check — so parsers, validators, and the interpreter cannot disagree about
244
- a document.
245
- - **A type-less schema that declares `properties` still requires an object.**
246
- Ajv accepts `"a string"` against `{ properties: { a: {} } }`; a parser has to
247
- return the type it declares, and the declared type is an object.
248
-
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
253
- emitting a parser that quietly accepts what the schema forbids. Coercing
254
- (non-strict) parsers are documented to repair rather than reject, so they ignore
255
- the rejecting keywords by design.
256
-
257
- ### How a coercing parser repairs a boolean
258
-
259
- `type: 'boolean'` is repaired from a table of spellings, not from JavaScript
260
- truthiness. `Boolean("false")` is `true` — as is `Boolean("no")`, `Boolean("0")`
261
- and `Boolean(2)` — so a truthiness test read every conventional way of writing
262
- *off* as *on*, which is precisely the input a coercing parser gets from
263
- environment variables, query strings and hand-written config.
264
-
265
- The accepted spellings are matched with surrounding whitespace trimmed and case
266
- folded, so `FALSE` and ` No ` land with `false` and `no`:
267
-
268
- | Input | Result |
269
- | --- | --- |
270
- | `true`, `yes`, `y`, `on`, `1`, the number `1` | `true` |
271
- | `false`, `no`, `n`, `off`, `0`, `""`, the number `0` | `false` |
272
- | anything else (`2`, `"maybe"`, an object, `null`) | the schema's `default`, else `false` |
273
-
274
- The last row is the same rule the other scalars follow: a value that does not
275
- denote a boolean is not repaired into a guess, it falls back to the default. This
276
- is wider than Ajv's `coerceTypes`, which takes `"true"` / `"false"`, the numbers
277
- `1` / `0`, and `null` (as `false`), and rejects the rest — including `"0"`,
278
- `"1"` and `""`. A coercing parser has no "reject", so its choice is between a
279
- default and a wrong answer. `null` is the one value both answer for and answer
280
- differently: Ajv says `false`, a coercing parser says whatever the schema's
281
- `default` is.
282
-
283
- ### How a coercing parser repairs a union
284
-
285
- A coercing parser's contract is that whatever it returns is a valid instance of
286
- the schema that produced it, and
287
- `src/generators/coerced-output-validity.differential.test.ts` fuzzes exactly that
288
- property against Ajv.
289
-
290
- A union is the hard case, because "repair this value" first has to answer "toward
291
- which branch?". The generated parser answers it in two steps:
292
-
293
- 1. **Recognition.** Every branch's shape predicate runs first. A value already in
294
- a branch's shape takes that branch's parser and comes back unchanged, so valid
295
- input costs one predicate call and is never rebuilt.
296
- 2. **Scoring.** A value matching no branch is scored against each branch and
297
- repaired toward the best fit. A `const` tag is near-decisive — a matching tag
298
- names the branch, a present-but-wrong one rules it out. Below that, a present
299
- required property is strong evidence and a present, well-typed declared
300
- property is weak evidence. Ties keep the earliest branch, which is `anyOf`'s
301
- own order.
302
-
303
- Scoring is why `{ name, folder }` is repaired toward the branch that declares
304
- `folder`, rather than toward whichever branch happens to be written first —
305
- which would invent a `sidebar` and discard the `folder` the author wrote. Every
306
- term is decided at build time, so the emitted code is a handful of property
307
- reads and no schema walking, and none of it runs for input that already matches.
308
-
309
- Every union position is dispatched: a definition, a `$ref`, an array's `items`
310
- (including a recursive one, where a branch's parser calls back into the
311
- dispatcher), and a union written directly as a property value. Measured over
312
- 4000 mutated documents of the published Scalar configuration schema, **every
313
- coerced output is a valid instance of its own schema**, and each of the 2514
314
- documents Ajv rejects is repaired into one Ajv accepts.
315
-
316
- Two limits are worth stating. A union carrying its *own* keywords alongside its
317
- branches (`{ anyOf: […], required: […] }`) keeps the general coercion path,
318
- because the dispatcher expresses the branches and not the siblings. And a union
319
- of bare scalars in property position is left to that same path, since scoring
320
- reads keys: with no object branch there is nothing to discriminate on, every
321
- branch ties, and the first one wins — which is what the general path already
322
- does.
323
-
324
- ### Conformance, measured
325
-
326
- The coverage above is not a claim — it is checked against the corpus every
327
- validator in every language is judged on.
328
- `src/generators/json-schema-conformance.test.ts` generates a strict parser for
329
- each schema in the official
330
- [JSON Schema Test Suite](https://github.com/json-schema-org/JSON-Schema-Test-Suite)
331
- (the required Draft 2020-12 tests — 1281 cases), links the emitted files in
332
- memory, and runs the suite's instances through the real generated code:
333
-
334
- **1242 / 1281 cases pass (97.0%).**
335
-
336
- A case passes only if the parser throws exactly when the spec says invalid *and*
337
- returns an accepted document unchanged — strict mode does not coerce, so a parser
338
- that quietly rewrites a valid document fails here too.
339
-
340
- The suite's `remotes/` documents and the 2020-12 dialect metaschema are supplied
341
- through the `schemas` option, which is how the suite intends a generator that does
342
- no I/O to answer the retrieval step. Everything else — applying the base URIs,
343
- walking anchors across documents, emitting a parser per definition and an import
344
- graph that links — the generator still has to do.
345
-
346
- Of the 39 that do not, **18** follow from the third departure above — the
347
- `ignores a non-object` cases plus three `ref.json` cases where a recursive `$ref`
348
- lands on a root that declares `properties` — and **12** are a keyword strict mode will not
349
- approximate (a cyclic `$ref` with siblings, a cyclic `unevaluatedProperties`) —
350
- those cost a build error naming the cause, never a wrong verdict. The rest: **8**
351
- `$dynamicRef`s whose binding depends on the evaluation path (a generator emits one
352
- function per definition, shared by every path that reaches it), and one
353
- `$vocabulary` case.
354
-
355
- Every one is listed in
356
- `src/generators/json-schema-conformance-expected-failures.test-utils.ts` with the
357
- reason, and the test fails if a case moves in *either* direction — a regression
358
- breaks the build, and so does a case that starts passing without its entry being
359
- removed. The corpus is vendored under
360
- [`fixtures/json-schema-test-suite`](../../fixtures/json-schema-test-suite); none
361
- of it is published.
362
-
363
- ---
364
-
365
- ## Benchmarks
366
-
367
- Generated parsers are plain, straight-line TypeScript — no schema walking and no
368
- generic dispatch at runtime, because the schema was already spent at build time.
369
- Each parser reads the exact keys it declares, coerces or asserts them inline, and
370
- returns a fresh typed object, so on valid input it runs several times faster than
371
- libraries that interpret a schema (or a schema-shaped object graph) on every
372
- call. The `bench/` suite replicates the `parseSafe` (assert + strip undeclared
373
- keys) and `parseStrict` (assert + reject undeclared keys) halves of
374
- [`moltar/typescript-runtime-type-benchmarks`](https://github.com/moltar/typescript-runtime-type-benchmarks)
375
- against the other *pure* parsers — the ones that return a new typed value rather
376
- than mutating in place — [zod](https://zod.dev) and
377
- [TypeBox](https://github.com/sinclairzx81/typebox).
378
-
379
- Both runtimes were measured together on one machine (Linux x64, a 4-vCPU cloud
380
- box, Bun 1.4.0 and Node 26.8.1 — the same machine and runtimes as every table in
381
- this repo), each cell the median of three separate runs of the whole suite.
382
- Within one sitting a cell repeats to within a few percent; the same suite
383
- measured hours later moved ~60% across every case at once, so read the ratios
384
- and treat the absolutes as a property of that box.
385
-
386
- Parsing valid input at steady state:
387
-
388
- | case | mode | Bun: mjst | Bun: zod | Bun: typebox | Node: mjst | Node: zod | Node: typebox |
389
- |:--|:--|--:|--:|--:|--:|--:|--:|
390
- | user (4 fields) | parseSafe | **~163M** | ~3.3M | ~1.7M | **~88M** | ~4.0M | ~0.67M |
391
- | order (nested + array) | parseSafe | **~7.7M** | ~0.60M | ~0.26M | **~8.2M** | ~0.64M | ~0.15M |
392
- | assert (moltar shape) | parseSafe | **~120M** | ~3.5M | ~0.87M | **~66M** | ~5.3M | ~0.35M |
393
- | user (4 fields) | parseStrict | **~43M** | ~1.9M | ~2.2M | **~55M** | ~2.7M | ~1.5M |
394
- | order (nested + array) | parseStrict | **~13M** | ~0.36M | ~0.43M | **~8.7M** | ~0.55M | ~0.25M |
395
- | assert (moltar shape) | parseStrict | **~44M** | ~1.4M | ~1.2M | **~34M** | ~3.5M | ~0.80M |
396
-
397
- <sub>ops/s, higher is better.</sub>
398
-
399
- Unlike the validator suite — where TypeBox's compiled checker takes the loose
400
- moltar shape on V8 — the generated parser leads every case on both engines:
401
- ~12–49× over zod on Bun and ~13–22× on Node. The two `user`/`assert` safe cells
402
- are the ones to read as ratios rather than absolutes: stripping to four declared
403
- keys builds one small object and nothing else, which is fast enough that the
404
- engine's inlining, not the parser, sets the number.
405
-
406
- What is replicated upstream is the *cases* — the two parse modes and their
407
- shapes — not the harness. These numbers come from this repo's own measurement
408
- core (direct calls over a pool of distinct inputs, median of many trials) and
409
- are not comparable with the public leaderboard, which measures every operation
410
- through benny and reports something an order of magnitude smaller for the same
411
- function. `@amritk/generate-validators` carries the side-by-side and a
412
- reproducible run of both harnesses:
413
- [Against the moltar harness](../generate-validators#against-the-moltar-harness).
414
-
415
- The trade is a one-shot **prepare** cost that only mjst pays — generating the
416
- parser source — which measures **~0.20–0.74 ms** per schema on Bun and
417
- **~0.16–0.72 ms** on Node (zod and TypeBox author or interpret their parsers
418
- with no separate build step, so there is nothing to time). That is trivially amortized: you generate once at build time
419
- and run the emitted code forever.
420
-
421
- Every library is checked for agreement — same stripped/rejected output, same
422
- throws on bad input — before it is timed, and each is timed in its own isolated
423
- process over a pool of distinct inputs, reporting the median of many trials, so
424
- the optimiser can't hoist the work away. Micro-benchmark figures vary by machine
425
- and runtime — reproduce with:
426
-
427
- ```bash
428
- bun run bench # Bun / JavaScriptCore
429
- bun run bench:node # Node / V8 (builds the package first, then runs it under node)
430
- ```
431
-
432
- ### Counting keys on the fast path
433
-
434
- A closed object whose declared properties are all required proves "no undeclared
435
- key" by counting: the typed checks ahead of the count have already proven every
436
- declared key present, so exactly *n* keys means no extra. `unknownKeys` (the 15th
437
- positional argument, `--unknown-keys` on the CLI) picks how the count is spelled,
438
- because the two spellings trade places between the engines:
439
-
440
- - **`'count-keys'`** (default) — `Object.getPrototypeOf(input) === Object.prototype
441
- && Object.keys(input).length === n`, one more term of the fast-path guard. The
442
- prototype guard keeps an own-key count sound: a crafted prototype could satisfy
443
- the typed checks through an inherited declared key while an own extra kept the
444
- count at *n*, so a non-plain object takes the cold path instead, whose `for…in`
445
- rejection sees the inherited key.
446
- - **`'count-enumerable'`** — `let c = 0; for (const k in input) c++`, as
447
- statements behind the guard. It allocates nothing and needs no prototype guard
448
- (the count sees inherited keys too), and on V8 it is answered from the enum
449
- cache. On JavaScriptCore a `for…in` over a non-extensible object is the engine's
450
- slow path, and the strict parse runs at half speed.
451
-
452
- A nested object's shape is proven on the parent's fast path by the same checks
453
- its own shape validator makes, spelled out over the cached local rather than
454
- called — `typeof _nested === "object" && … && Object.keys(_nested).length === n`
455
- — one level deep and within a size budget, because a call was the one thing
456
- left on the strict fast path that JavaScriptCore would not see through. Under
457
- the moltar harness (each case alone in its own process) `parseStrict` reaches
458
- the harness floor on Bun 1.4.0 (~360M ops/s, the call eliminated) with
459
- `count-keys` against ~22M with `count-enumerable`.
460
-
461
- On Node the answer has moved with the engine. On Node 22 the two spellings were
462
- level (~14M either way, which is where the advice to flip it came from); on
463
- Node 26.8.1 `count-keys` measures ~29M against `count-enumerable`'s ~26M, so the
464
- default now ties or wins on both engines. Keep the default unless you are
465
- pinned to an older V8 and have measured your own shapes. See
466
- [Choosing how keys are counted](../generate-validators#choosing-how-keys-are-counted)
467
- in the sister package for the validator numbers and the full trade-off. Whichever
468
- you pick, every verdict on a value that could have come from JSON is the same.
469
-
470
- ---
471
-
472
- ## Related packages
473
-
474
- - [`@amritk/mjst`](../cli) — CLI wrapping this generator
475
- - [`@amritk/generate-validators`](../generate-validators) — predicate-style validators (sister package)
476
- - [`@amritk/generate-markdown`](../generate-markdown) — markdown documentation generator
477
- - [`@amritk/helpers`](../helpers) — shared schema-traversal utilities
478
-
479
- ---
480
-
481
- ## License
50
+ Both hand back `{ filename, content }[]`. The positional arguments become named
51
+ options — see the
52
+ [`@amritk/parsers` README](https://github.com/amritk/mjst/tree/main/packages/parsers#readme)
53
+ for the full table.
482
54
 
483
- [MIT](../../LICENSE)
55
+ The three shapes `buildSchema` chose between are now modes: the default coercing
56
+ parser is `'parse'`, `strict: true` is `'parseStrict'`, and `typesOnly: true` is
57
+ `'types'` on its own.
package/dist/index.d.ts CHANGED
@@ -1,2 +1,22 @@
1
- export { buildSchema, type GeneratedFile } from './generators/build-schema.js';
2
- export type { ImportExtension } from './helpers/collect-imports.js';
1
+ import type { UnknownKeysStrategy } from '@amritk/helpers/unknown-keys-strategy';
2
+ import { type GeneratedFile, type GenerateOptions, type ImportExtension } from '@amritk/parsers';
3
+ import type { JSONSchema } from 'json-schema-typed/draft-2020-12';
4
+ export type { GeneratedFile, ImportExtension };
5
+ /**
6
+ * A compatibility shim, kept only so this package can be retired without
7
+ * breaking anyone mid-upgrade.
8
+ *
9
+ * The parser and type engine moved into `@amritk/parsers`, which reaches it —
10
+ * and every other mode — through one `generate()` call. Rather than re-export an
11
+ * internal module across the package boundary, this maps the old positional
12
+ * signature onto the public API, so `@amritk/parsers` keeps exactly one public
13
+ * entry point and the engine stays an implementation detail of it.
14
+ *
15
+ * The output is byte-identical to what this package emitted before, barrel
16
+ * included, which `index.test.ts` pins against the real pre-retirement engine
17
+ * rather than against this shim's own idea of the answer.
18
+ *
19
+ * @deprecated Use `generate` from `@amritk/parsers`:
20
+ * `generate(schema, name, { modes: ['types', 'parse'] })`.
21
+ */
22
+ export declare const buildSchema: (rootSchema: JSONSchema, rootTypeName: string, extensions?: GenerateOptions['extensions'], typesOnly?: boolean, logWarnings?: boolean, strict?: boolean, helpersMode?: 'package' | 'embedded', helpersImportPrefix?: string, readonly?: boolean, stripUnknown?: boolean, typeSuffix?: string, importExt?: ImportExtension, caseInsensitive?: boolean, schemas?: Readonly<Record<string, unknown>>, unknownKeys?: UnknownKeysStrategy) => Promise<GeneratedFile[]>;
package/dist/index.js CHANGED
@@ -1,4 +1,21 @@
1
- import { buildSchema } from "./generators/build-schema.js";
1
+ import { generate } from "@amritk/parsers";
2
+ const buildSchema = async (rootSchema, rootTypeName, extensions, typesOnly, logWarnings, strict, helpersMode = "package", helpersImportPrefix = "./", readonly = false, stripUnknown = false, typeSuffix = "", importExt = "js", caseInsensitive = false, schemas, unknownKeys) => {
3
+ const modes = typesOnly === true ? ["types"] : ["types", strict === true ? "parseStrict" : "parse"];
4
+ return generate(rootSchema, rootTypeName, {
5
+ modes,
6
+ helpersMode,
7
+ helpersImportPrefix,
8
+ readonly,
9
+ stripUnknown,
10
+ typeSuffix,
11
+ importExt,
12
+ caseInsensitive,
13
+ logWarnings: logWarnings === true,
14
+ ...extensions !== void 0 ? { extensions } : {},
15
+ ...schemas !== void 0 ? { schemas } : {},
16
+ ...unknownKeys !== void 0 ? { unknownKeys } : {}
17
+ });
18
+ };
2
19
  export {
3
20
  buildSchema
4
21
  };
package/package.json CHANGED
@@ -1,24 +1,9 @@
1
1
  {
2
2
  "name": "@amritk/generate-parsers",
3
- "version": "0.23.0",
4
- "description": "Generate TypeScript parsers and type definitions from JSON Schemas.",
5
- "main": "./dist/index.js",
6
- "module": "./dist/index.js",
7
- "types": "./dist/index.d.ts",
8
- "type": "module",
9
- "sideEffects": false,
10
- "engines": {
11
- "node": ">=20"
12
- },
3
+ "version": "0.25.0",
4
+ "description": "Deprecated — merged into @amritk/parsers. Kept as a compatibility shim; use `generate` instead.",
13
5
  "license": "MIT",
14
6
  "author": "amritk",
15
- "keywords": [
16
- "json-schema",
17
- "typescript",
18
- "parser",
19
- "code-generation",
20
- "mjst"
21
- ],
22
7
  "repository": {
23
8
  "type": "git",
24
9
  "url": "git+https://github.com/amritk/mjst.git",
@@ -28,9 +13,16 @@
28
13
  "bugs": {
29
14
  "url": "https://github.com/amritk/mjst/issues"
30
15
  },
16
+ "type": "module",
17
+ "sideEffects": false,
18
+ "main": "./dist/index.js",
19
+ "module": "./dist/index.js",
20
+ "types": "./dist/index.d.ts",
21
+ "engines": {
22
+ "node": ">=20"
23
+ },
31
24
  "files": [
32
- "dist",
33
- "AI.md"
25
+ "dist"
34
26
  ],
35
27
  "publishConfig": {
36
28
  "access": "public"
@@ -39,18 +31,7 @@
39
31
  "build": "tsgo -p tsconfig.build.json && tsc-alias -p tsconfig.build.json -f && node ../../scripts/strip-comments.mjs",
40
32
  "prepublishOnly": "node ../../scripts/check-publishable.mjs",
41
33
  "types:check": "tsgo -p . --noEmit",
42
- "test": "NODE_ENV=production vitest run --root ../.. generate-parsers",
43
- "generate-readme": "bun run ./scripts/generate-readme.ts",
44
- "prebench": "bun run --filter='@amritk/helpers' build && bun run build",
45
- "bench": "bun --conditions development ./bench/run.ts",
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
- },
50
- "imports": {
51
- "#generators/*": "./src/generators/*.ts",
52
- "#helpers/*": "./src/helpers/*.ts",
53
- "#types/*": "./src/types/*.ts"
34
+ "test": "NODE_ENV=production vitest run --root ../.. packages/generate-parsers/"
54
35
  },
55
36
  "exports": {
56
37
  ".": {
@@ -59,14 +40,8 @@
59
40
  }
60
41
  },
61
42
  "dependencies": {
62
- "json-schema-typed": "^8.0.1",
63
- "@amritk/helpers": "^0.21.0"
64
- },
65
- "devDependencies": {
66
- "@amritk/generate-markdown": "^0.7.0",
67
- "@sinclair/typebox": "^0.34.49",
68
- "ajv": "^8.17.1",
69
- "benny": "^3.7.1",
70
- "zod": "^4.4.3"
43
+ "@amritk/helpers": "^0.23.0",
44
+ "@amritk/parsers": "^0.2.0",
45
+ "json-schema-typed": "^8.0.1"
71
46
  }
72
47
  }