@amritk/generate-parsers 0.16.1 → 0.16.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -173,10 +173,10 @@ x64), parsing valid input at steady state:
173
173
 
174
174
  | case | mode | mjst (generated) | zod | typebox |
175
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 |
176
+ | user (4 fields) | parseSafe | **~16M** ops/s | ~3.3M ops/s | ~1.3M ops/s |
177
+ | order (nested + array) | parseSafe | **~5.8M** ops/s | ~0.6M ops/s | ~0.18M ops/s |
178
+ | user (4 fields) | parseStrict | **~13M** ops/s | ~1.8M ops/s | ~1.85M ops/s |
179
+ | order (nested + array) | parseStrict | **~7.5M** ops/s | ~0.36M ops/s | ~0.28M ops/s |
180
180
 
181
181
  The upstream `assert` case (seven scalar roots plus a nested object) runs faster
182
182
  still — tens of millions of ops/s — but at that size the numbers swing enough
@@ -37,18 +37,27 @@ export const generateEnumCheck = (accessor, enumValues) => {
37
37
  * keeps its plain fallback and emits no extra code.
38
38
  */
39
39
  export const generateEnumCaseInsensitiveCoercion = (accessor, enumValues, fallback) => {
40
- const byLower = {};
40
+ const entries = [];
41
+ const seen = new Set();
41
42
  for (const value of enumValues) {
42
43
  if (typeof value !== 'string')
43
44
  continue;
44
45
  const lower = value.toLowerCase();
45
46
  // First writer wins, so declaration order breaks ties between members that
46
- // fold to the same key (e.g. `"on"` and `"ON"`).
47
- if (!(lower in byLower))
48
- byLower[lower] = value;
47
+ // fold to the same key (e.g. `"on"` and `"ON"`). `Set` membership is used
48
+ // instead of `lower in obj` so a member folding to an inherited name like
49
+ // `constructor` or `toString` is not wrongly treated as already-present.
50
+ if (seen.has(lower))
51
+ continue;
52
+ seen.add(lower);
53
+ entries.push([lower, value]);
49
54
  }
50
- if (Object.keys(byLower).length === 0)
55
+ if (entries.length === 0)
51
56
  return null;
52
- const map = JSON.stringify(byLower);
53
- return `(typeof ${accessor} === "string" ? ((${map} as Record<string, string>)[(${accessor} as string).toLowerCase()] ?? ${fallback}) : ${fallback})`;
57
+ // A `Map` — not a plain object — so a folded key that collides with an
58
+ // inherited member (`constructor`, `toString`, `__proto__`, …) resolves to
59
+ // `undefined` (→ `fallback`) rather than an `Object.prototype` value. A
60
+ // plain-object lookup would return the inherited function/object for such a key.
61
+ const map = `new Map(${JSON.stringify(entries)})`;
62
+ return `(typeof ${accessor} === "string" ? (${map}.get((${accessor} as string).toLowerCase()) ?? ${fallback}) : ${fallback})`;
54
63
  };
@@ -454,6 +454,18 @@ const generateNonObjectParser = (typeName, schema, strict, unionCtx) => {
454
454
  return `export const ${functionName} = (input: unknown): ${typeName} => input as ${typeName};`;
455
455
  }
456
456
  }
457
+ // Strict counterpart of the block above for a *type-less* `const`/`enum` root
458
+ // (`{ enum: [...] }` / `{ const: ... }` with no `type`): the no-`type` bail
459
+ // below would otherwise return a bare cast, so strict mode has to assert
460
+ // membership here. A typed `const`/`enum` (`{ type: 'string', enum: [...] }`)
461
+ // keeps flowing to the strict scalar path, which asserts it via
462
+ // generateScalarStrictAssertion.
463
+ if (strict && isSchemaObject(schema) && !hasType(schema) && (hasConst(schema) || hasEnum(schema))) {
464
+ const assertion = generateScalarStrictAssertion(schema, typeName, unionCtx?.rootSchema);
465
+ if (assertion !== null) {
466
+ return `export const ${functionName} = (input: unknown): ${typeName} => {\n${assertion}\n return input as ${typeName};\n};`;
467
+ }
468
+ }
457
469
  // Root-level arrays with rich item schemas delegate every element to a real
458
470
  // parser, so nested enums and $refs inside array items are validated instead
459
471
  // of spread through unchecked. $ref items call the imported parser; inline
@@ -1243,7 +1255,18 @@ const generateStrictCombinedParser = (schema, typeName, functionName, propertyLi
1243
1255
  const resultBody = propertyLines.length > 0 ? `{\n${propertyLines.join('\n')}\n }` : '{}';
1244
1256
  // Only the Set form needs a declaration; the inline === chain is stateless.
1245
1257
  const knownKeysDeclaration = knownKeyCheck.declarations.map((decl) => ` ${decl};\n`).join('');
1246
- const keywordPrelude = strict ? strictObjectKeywordPrelude(schema, typeName) : '';
1258
+ // In strict mode assert the declared properties (type, required, enum,
1259
+ // constraints) — the `result` below is built from the *coercing* property
1260
+ // lines, which silently repair a wrong type and default a missing required key
1261
+ // instead of throwing. `generateObjectStrictAssertion` throws first; the
1262
+ // coercing build then runs only on already-valid input (a no-op there). It also
1263
+ // covers the object-level keyword checks, so it replaces the keyword-only
1264
+ // prelude. `.slice(1)` drops its `isObject` check (emitted just below). Coerce
1265
+ // mode (stripUnknown) keeps its lighter prelude.
1266
+ const assertionPrelude = strict
1267
+ ? generateObjectStrictAssertion(schema, typeName, { useRefImports, suffix }).slice(1)
1268
+ : [];
1269
+ const keywordPrelude = assertionPrelude.length > 0 ? `\n${assertionPrelude.join('\n')}` : '';
1247
1270
  return `export const ${functionName} = (input: unknown): ${typeName} => {
1248
1271
  if (!isObject(input)) {
1249
1272
  ${notObjectBranch}
@@ -1284,6 +1307,14 @@ const generateCombinedObjectParser = (schema, typeName, useRefImports, suffix, l
1284
1307
  if (strictKeys) {
1285
1308
  return generateStrictCombinedParser(schema, typeName, functionName, propertyLines, patterns, useRefImports, suffix, strict);
1286
1309
  }
1310
+ // `stripUnknown` (coerce mode) must keep keys a `patternProperties` regex
1311
+ // declares and drop only the genuinely-undeclared ones. Delegating to
1312
+ // `generateObjectParser` below would strip the pattern-matching keys too — its
1313
+ // strip logic only knows the declared `properties`. The selective combined
1314
+ // copy keeps declared + pattern-matching keys, exactly like the interpreter.
1315
+ if (stripUnknown && !strict) {
1316
+ return generateStrictCombinedParser(schema, typeName, functionName, propertyLines, patterns, useRefImports, suffix, false);
1317
+ }
1287
1318
  if (!refPattern || !useRefImports) {
1288
1319
  return generateObjectParser(schema, typeName, useRefImports, suffix, logWarnings, strict, true, stripUnknown, undefined, NO_RESERVED_NAMES, caseInsensitive);
1289
1320
  }
@@ -1341,7 +1372,7 @@ const generateAdditionalPropertiesParser = (schema, typeName, useRefImports, suf
1341
1372
  }
1342
1373
  // Handle additionalProperties with a known type by generating inline validation
1343
1374
  if (isSchemaObject(additionalProps) && hasType(additionalProps)) {
1344
- const inlineParser = generateInlineValueParser(additionalProps);
1375
+ const inlineParser = generateInlineValueParser(additionalProps, strict, `[${typeName}]`);
1345
1376
  if (inlineParser) {
1346
1377
  if (strict) {
1347
1378
  return `export const ${functionName} = (input: unknown): ${typeName} => {\n if (!isObject(input)) throw new Error(\`[${typeName}] expected object, got \${input === null ? "null" : typeof input}\`);${keywordPrelude}\n return validateRecord(input, ${inlineParser}) as ${typeName};\n};`;
@@ -1362,10 +1393,30 @@ const generateAdditionalPropertiesParser = (schema, typeName, useRefImports, suf
1362
1393
  * Used to generate parsers for Record-like types where values have a known
1363
1394
  * primitive or array type (e.g. Record<string, string> or Record<string, string[]>).
1364
1395
  */
1365
- const generateInlineValueParser = (schema) => {
1396
+ const generateInlineValueParser = (schema, strict = false, label = '') => {
1366
1397
  if (!isSchemaObject(schema) || !hasType(schema)) {
1367
1398
  return null;
1368
1399
  }
1400
+ // Strict mode must *throw* on a wrong-typed record value, not repair it — the
1401
+ // coercing branch below would silently turn `{ a: 'x' }` into `{ a: 0 }` for a
1402
+ // `Record<string, number>`, which strict mode is documented never to do.
1403
+ if (strict) {
1404
+ const err = (t) => `throw new Error(${JSON.stringify(`${label} record value must be ${t}`)})`;
1405
+ switch (schema.type) {
1406
+ case 'string':
1407
+ return `(value: unknown) => { if (typeof value !== "string") ${err('string')}; return value; }`;
1408
+ case 'number':
1409
+ return `(value: unknown) => { if (typeof value !== "number") ${err('number')}; return value; }`;
1410
+ case 'integer':
1411
+ return `(value: unknown) => { if (typeof value !== "number" || !Number.isInteger(value)) ${err('integer')}; return value; }`;
1412
+ case 'boolean':
1413
+ return `(value: unknown) => { if (typeof value !== "boolean") ${err('boolean')}; return value; }`;
1414
+ case 'array':
1415
+ return `(value: unknown) => { if (!Array.isArray(value)) ${err('array')}; return value; }`;
1416
+ default:
1417
+ return null;
1418
+ }
1419
+ }
1369
1420
  switch (schema.type) {
1370
1421
  case 'string':
1371
1422
  return '(value: unknown) => typeof value === "string" ? value : ""';
@@ -4,7 +4,7 @@ import { multipleOfFailExpr } from '@amritk/helpers/multiple-of-check';
4
4
  import { quoteJsString } from '@amritk/helpers/quote-js-string';
5
5
  import { resolveRef } from '@amritk/helpers/resolve-ref';
6
6
  import { safeAccessor } from '@amritk/helpers/safe-accessor';
7
- import { hasDependentRequired, hasEnum, hasExclusiveMaximum, hasExclusiveMinimum, hasItems, hasMaxItems, hasMaximum, hasMaxLength, hasMinItems, hasMinimum, hasMinLength, hasMultipleOf, hasPattern, hasProperties, hasPropertyNames, hasRef, hasRequired, hasType, hasUniqueItems, isSchemaObject, } from '@amritk/helpers/schema-guards';
7
+ import { hasConst, hasDependentRequired, hasEnum, hasExclusiveMaximum, hasExclusiveMinimum, hasItems, hasMaxItems, hasMaximum, hasMaxLength, hasMinItems, hasMinimum, hasMinLength, hasMultipleOf, hasPattern, hasProperties, hasPropertyNames, hasRef, hasRequired, hasType, hasUniqueItems, isSchemaObject, } from '@amritk/helpers/schema-guards';
8
8
  import { generateEnumCheck } from './generate-enum-check.js';
9
9
  import { canEnforceUnion, generateUnionCheck, getUnionBranches, isInlineObjectProperty } from './generate-type-checks.js';
10
10
  import { getPrefixItems, prefixItemsCapsLength, scalarItemTypeCheck } from './generate-validation-expression.js';
@@ -57,12 +57,11 @@ const throwError = (message, suffixExpr) => {
57
57
  * Generates strict-mode constraint checks for a typed property
58
58
  * (pattern, length, min/max, multipleOf).
59
59
  */
60
- const generateConstraintChecks = (acc, propSchema, typeName, key, context = {}) => {
60
+ const generateConstraintChecks = (acc, propSchema, field, context = {}) => {
61
61
  if (!isSchemaObject(propSchema) || !hasType(propSchema))
62
62
  return [];
63
63
  const t = propSchema.type;
64
64
  const lines = [];
65
- const field = `[${typeName}] field '${key}'`;
66
65
  if (t === 'string') {
67
66
  if (hasPattern(propSchema)) {
68
67
  const pattern = escapeRegexPattern(propSchema.pattern);
@@ -375,7 +374,7 @@ const generatePropertyAssertion = (key, propSchema, isRequired, typeName, contex
375
374
  lines.push(` if (${acc} !== undefined && (${wrongType})) ${expected};`);
376
375
  }
377
376
  }
378
- lines.push(...generateConstraintChecks(acc, propSchema, typeName, key, context));
377
+ lines.push(...generateConstraintChecks(acc, propSchema, field, context));
379
378
  }
380
379
  // Type-independent constraints, each runtime-gated on the value's shape:
381
380
  // - `contains` asserts on arrays (no-op otherwise),
@@ -444,23 +443,45 @@ export const generateScalarStrictAssertion = (schema, typeName, rootSchema) => {
444
443
  if (primitive) {
445
444
  return ` if (typeof input !== "${primitive}") ${throwError(`[${typeName}] expected ${primitive}, got `, got)};`;
446
445
  }
447
- if (!isSchemaObject(schema) || !hasType(schema))
448
- return null;
449
- const t = schema.type;
450
- const wrongType = wrongTypeCondition('input', t);
451
- if (!wrongType)
446
+ if (!isSchemaObject(schema))
452
447
  return null;
453
- const lines = [` if (${wrongType}) ${throwError(`[${typeName}] expected ${typeLabel(t)}, got `, got)};`];
454
- // Root-level arrays enforce scalar/enum item types too — the same gap the
455
- // property path closes in generateConstraintChecks — plus `contains`.
456
- if (t === 'array') {
457
- const itemCheck = generateItemCheck(schema);
458
- if (itemCheck) {
459
- lines.push(` if (!(input as readonly unknown[]).every((_it) => ${itemCheck.check})) ${throwError(`[${typeName}] ${itemCheck.message}`)};`);
448
+ const label = `[${typeName}]`;
449
+ const lines = [];
450
+ // `const` / `enum` apply to a root value regardless of a declared `type` — a
451
+ // root `{ enum: [...] }` or `{ const: ... }` must reject a non-member, not
452
+ // silently coerce it. `const` is compared through `JSON.stringify` so it works
453
+ // for any JSON value; the value must match exactly.
454
+ if (hasConst(schema)) {
455
+ const wanted = JSON.stringify(JSON.stringify(schema.const));
456
+ lines.push(` if (JSON.stringify(input) !== ${wanted}) ${throwError(`${label} must be ${JSON.stringify(schema.const)}`)};`);
457
+ }
458
+ else if (hasEnum(schema)) {
459
+ const allowed = JSON.stringify(schema.enum);
460
+ const enumLabel = schema.enum.map((v) => JSON.stringify(v)).join(', ');
461
+ lines.push(` if (!(${allowed} as readonly unknown[]).includes(input)) ${throwError(`${label} must be one of: ${enumLabel}`)};`);
462
+ }
463
+ if (hasType(schema)) {
464
+ const t = schema.type;
465
+ const wrongType = wrongTypeCondition('input', t);
466
+ if (wrongType) {
467
+ lines.push(` if (${wrongType}) ${throwError(`${label} expected ${typeLabel(t)}, got `, got)};`);
460
468
  }
461
- lines.push(...generateContainsCheck('input', schema, `[${typeName}]`));
462
- // Tuple `prefixItems`: assert each position and cap length under items:false.
463
- lines.push(...generatePrefixItemsAssertion('input', `[${typeName}]`, schema, rootSchema));
469
+ // Root-level arrays enforce scalar/enum item types too — the same gap the
470
+ // property path closes in generateConstraintChecks — plus `contains`.
471
+ if (t === 'array') {
472
+ const itemCheck = generateItemCheck(schema);
473
+ if (itemCheck) {
474
+ lines.push(` if (!(input as readonly unknown[]).every((_it) => ${itemCheck.check})) ${throwError(`${label} ${itemCheck.message}`)};`);
475
+ }
476
+ lines.push(...generateContainsCheck('input', schema, label));
477
+ // Tuple `prefixItems`: assert each position and cap length under items:false.
478
+ lines.push(...generatePrefixItemsAssertion('input', label, schema, rootSchema));
479
+ }
480
+ // String (pattern, min/maxLength), number/integer (bounds, multipleOf) and
481
+ // array (length, uniqueItems) constraints — previously enforced only for
482
+ // named properties, so a root scalar accepted e.g. `{type:'string',minLength:5}`
483
+ // violations.
484
+ lines.push(...generateConstraintChecks('input', schema, label, rootSchema ? { rootSchema } : {}));
464
485
  }
465
- return lines.join('\n');
486
+ return lines.length > 0 ? lines.join('\n') : null;
466
487
  };
@@ -132,13 +132,20 @@ const getTypeCoercion = (accessor, schema, defaultValue) => {
132
132
  switch (schema.type) {
133
133
  case 'string':
134
134
  return `String(${accessor})`;
135
- case 'number':
136
- case 'integer': {
135
+ case 'number': {
137
136
  // Number() of a non-numeric string produces NaN, which silently poisons
138
137
  // arithmetic. Guard with Number.isFinite and fall back to the default.
139
138
  const n = `Number(${accessor})`;
140
139
  return `(Number.isFinite(${n}) ? ${n} : ${defaultValue})`;
141
140
  }
141
+ case 'integer': {
142
+ // An `integer` must coerce to a whole number, or the "repaired" value still
143
+ // fails validation (a bare `Number.isFinite` accepts `1.5`). Require
144
+ // integrality and fall back to the default otherwise — matching the
145
+ // root-level integer parser, which yields the default for a non-integer.
146
+ const n = `Number(${accessor})`;
147
+ return `(Number.isInteger(${n}) ? ${n} : ${defaultValue})`;
148
+ }
142
149
  case 'boolean':
143
150
  return `Boolean(${accessor})`;
144
151
  case 'array':
@@ -50,6 +50,14 @@ export const getDefaultValue = (schema) => {
50
50
  if (getMjstPrimitive(schema) === 'bigint') {
51
51
  return '0n';
52
52
  }
53
+ // Array-form `type` (e.g. `["string","null"]`): `hasType` is false for it, so
54
+ // without this a required-but-missing value would default to `undefined` —
55
+ // violating both `required` and the declared type. Derive the default from the
56
+ // first listed type so the fallback is a valid member.
57
+ const rawType = schema['type'];
58
+ if (Array.isArray(rawType) && rawType.length > 0 && typeof rawType[0] === 'string') {
59
+ return getDefaultValue({ ...schema, type: rawType[0] });
60
+ }
53
61
  if (!hasType(schema)) {
54
62
  return 'undefined';
55
63
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@amritk/generate-parsers",
3
- "version": "0.16.1",
3
+ "version": "0.16.2",
4
4
  "description": "Generate TypeScript parsers and type definitions from JSON Schemas.",
5
5
  "module": "./dist/index.js",
6
6
  "type": "module",
@@ -48,7 +48,7 @@
48
48
  },
49
49
  "dependencies": {
50
50
  "json-schema-typed": "^8.0.1",
51
- "@amritk/helpers": "0.13.0",
51
+ "@amritk/helpers": "0.13.1",
52
52
  "@amritk/generate-markdown": "0.4.1"
53
53
  },
54
54
  "devDependencies": {