@amritk/generate-parsers 0.24.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.
- package/README.md +37 -463
- package/dist/index.d.ts +22 -2
- package/dist/index.js +18 -1
- package/package.json +15 -40
- package/AI.md +0 -52
- package/dist/generators/assert-supported-keywords.d.ts +0 -30
- package/dist/generators/assert-supported-keywords.js +0 -87
- package/dist/generators/build-schema.d.ts +0 -111
- package/dist/generators/build-schema.js +0 -74
- package/dist/generators/generate-deep-equal-check.d.ts +0 -24
- package/dist/generators/generate-deep-equal-check.js +0 -35
- package/dist/generators/generate-default-from-pattern.d.ts +0 -26
- package/dist/generators/generate-default-from-pattern.js +0 -223
- package/dist/generators/generate-discriminated-union-validation.d.ts +0 -15
- package/dist/generators/generate-discriminated-union-validation.js +0 -28
- package/dist/generators/generate-enum-check.d.ts +0 -35
- package/dist/generators/generate-enum-check.js +0 -31
- package/dist/generators/generate-files.d.ts +0 -138
- package/dist/generators/generate-files.js +0 -89
- package/dist/generators/generate-non-discriminated-union-validation.d.ts +0 -6
- package/dist/generators/generate-non-discriminated-union-validation.js +0 -26
- package/dist/generators/generate-parser-function.d.ts +0 -120
- package/dist/generators/generate-parser-function.js +0 -1973
- package/dist/generators/generate-schema-checks.d.ts +0 -5
- package/dist/generators/generate-schema-checks.js +0 -260
- package/dist/generators/generate-strict-assertion.d.ts +0 -155
- package/dist/generators/generate-strict-assertion.js +0 -759
- package/dist/generators/generate-type-checks.d.ts +0 -148
- package/dist/generators/generate-type-checks.js +0 -454
- package/dist/generators/generate-unique-items-check.d.ts +0 -16
- package/dist/generators/generate-unique-items-check.js +0 -32
- package/dist/generators/generate-validation-expression.d.ts +0 -79
- package/dist/generators/generate-validation-expression.js +0 -416
- package/dist/generators/subschema-match.d.ts +0 -48
- package/dist/generators/subschema-match.js +0 -485
- package/dist/generators/unevaluated-match.d.ts +0 -19
- package/dist/generators/unevaluated-match.js +0 -206
- package/dist/helpers/apply-schema-extensions.d.ts +0 -35
- package/dist/helpers/apply-schema-extensions.js +0 -20
- package/dist/helpers/collect-helpers.d.ts +0 -15
- package/dist/helpers/collect-helpers.js +0 -64
- package/dist/helpers/collect-imports.d.ts +0 -104
- package/dist/helpers/collect-imports.js +0 -227
- package/dist/helpers/find-discriminator.d.ts +0 -6
- package/dist/helpers/find-discriminator.js +0 -27
- package/dist/helpers/get-default-value.d.ts +0 -7
- package/dist/helpers/get-default-value.js +0 -155
- package/dist/helpers/get-discriminator-value.d.ts +0 -6
- package/dist/helpers/get-discriminator-value.js +0 -18
- package/dist/types/schema-extensions.d.ts +0 -34
- 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
|
-
**
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-

|
|
11
|
-

|
|
12
|
-

|
|
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
|
-
|
|
9
|
+
## What this was
|
|
21
10
|
|
|
22
|
-
|
|
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
|
-
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
##
|
|
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
|
-
|
|
67
|
-
|
|
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
|
-
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2
|
-
|
|
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 {
|
|
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.
|
|
4
|
-
"description": "
|
|
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
|
-
"
|
|
63
|
-
"@amritk/
|
|
64
|
-
|
|
65
|
-
"devDependencies": {
|
|
66
|
-
"@amritk/generate-markdown": "^0.8.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
|
}
|