@amritk/generate-parsers 0.8.0 โ 0.10.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 +10 -0
- package/dist/generators/build-schema.d.ts +5 -1
- package/dist/generators/build-schema.js +6 -1
- package/dist/generators/generate-files.d.ts +9 -0
- package/dist/generators/generate-files.js +3 -1
- package/dist/generators/generate-parser-function.d.ts +11 -1
- package/dist/generators/generate-parser-function.js +147 -35
- package/package.json +8 -3
package/README.md
CHANGED
|
@@ -134,6 +134,16 @@ Returns: `Promise<GeneratedFile[]>`.
|
|
|
134
134
|
<tr>
|
|
135
135
|
<td colspan="5">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>
|
|
136
136
|
</tr>
|
|
137
|
+
<tr>
|
|
138
|
+
<td>๐งน <code>stripUnknown</code></td>
|
|
139
|
+
<td>โ</td>
|
|
140
|
+
<td><code>boolean</code></td>
|
|
141
|
+
<td align="center">โ</td>
|
|
142
|
+
<td align="center"><code>false</code></td>
|
|
143
|
+
</tr>
|
|
144
|
+
<tr>
|
|
145
|
+
<td colspan="5">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>
|
|
146
|
+
</tr>
|
|
137
147
|
</tbody>
|
|
138
148
|
</table>
|
|
139
149
|
<!-- config-table-end -->
|
|
@@ -38,6 +38,10 @@ export type GeneratedFile = {
|
|
|
38
38
|
* directory while the helper sources are emitted once at the output root.
|
|
39
39
|
* @param readonly - When true, every property, array, and record in the generated type
|
|
40
40
|
* definitions is emitted as `readonly`.
|
|
41
|
+
* @param stripUnknown - When true, the generated parsers build their result from declared
|
|
42
|
+
* properties only, silently dropping undeclared input keys at every nesting level (zod's
|
|
43
|
+
* `.strip()`) without treating extras as a validation error. Composes with `strict` and
|
|
44
|
+
* yields to `additionalProperties: false` (which rejects rather than strips in strict mode).
|
|
41
45
|
* @param typeSuffix - Suffix appended to every type/parser name derived from a `$ref`
|
|
42
46
|
* (e.g. `'Object'` โ `ContactObject`). Defaults to `''` (no suffix). The root type name
|
|
43
47
|
* is used verbatim and is not affected by this suffix.
|
|
@@ -73,4 +77,4 @@ export type GeneratedFile = {
|
|
|
73
77
|
* });
|
|
74
78
|
* ```
|
|
75
79
|
*/
|
|
76
|
-
export declare const buildSchema: (rootSchema: JSONSchema, rootTypeName: string, extensions?: SchemaExtensions, typesOnly?: boolean, logWarnings?: boolean, strict?: boolean, helpersMode?: HelpersMode, helpersImportPrefix?: string, readonly?: boolean, typeSuffix?: string) => Promise<GeneratedFile[]>;
|
|
80
|
+
export declare const buildSchema: (rootSchema: JSONSchema, rootTypeName: string, extensions?: SchemaExtensions, typesOnly?: boolean, logWarnings?: boolean, strict?: boolean, helpersMode?: HelpersMode, helpersImportPrefix?: string, readonly?: boolean, stripUnknown?: boolean, typeSuffix?: string) => Promise<GeneratedFile[]>;
|
|
@@ -43,6 +43,10 @@ const readHelperSource = async (helper) => {
|
|
|
43
43
|
* directory while the helper sources are emitted once at the output root.
|
|
44
44
|
* @param readonly - When true, every property, array, and record in the generated type
|
|
45
45
|
* definitions is emitted as `readonly`.
|
|
46
|
+
* @param stripUnknown - When true, the generated parsers build their result from declared
|
|
47
|
+
* properties only, silently dropping undeclared input keys at every nesting level (zod's
|
|
48
|
+
* `.strip()`) without treating extras as a validation error. Composes with `strict` and
|
|
49
|
+
* yields to `additionalProperties: false` (which rejects rather than strips in strict mode).
|
|
46
50
|
* @param typeSuffix - Suffix appended to every type/parser name derived from a `$ref`
|
|
47
51
|
* (e.g. `'Object'` โ `ContactObject`). Defaults to `''` (no suffix). The root type name
|
|
48
52
|
* is used verbatim and is not affected by this suffix.
|
|
@@ -78,7 +82,7 @@ const readHelperSource = async (helper) => {
|
|
|
78
82
|
* });
|
|
79
83
|
* ```
|
|
80
84
|
*/
|
|
81
|
-
export const buildSchema = async (rootSchema, rootTypeName, extensions, typesOnly, logWarnings, strict, helpersMode = 'package', helpersImportPrefix = './', readonly = false, typeSuffix = '') => {
|
|
85
|
+
export const buildSchema = async (rootSchema, rootTypeName, extensions, typesOnly, logWarnings, strict, helpersMode = 'package', helpersImportPrefix = './', readonly = false, stripUnknown = false, typeSuffix = '') => {
|
|
82
86
|
const files = [];
|
|
83
87
|
const usedHelpers = new Set();
|
|
84
88
|
walkRefGraph(rootSchema, rootTypeName, { typeSuffix }, (node) => {
|
|
@@ -99,6 +103,7 @@ export const buildSchema = async (rootSchema, rootTypeName, extensions, typesOnl
|
|
|
99
103
|
...(node.ref !== undefined ? { selfRef: node.ref } : {}),
|
|
100
104
|
...(logWarnings !== undefined ? { logWarnings } : {}),
|
|
101
105
|
...(strict !== undefined ? { strict } : {}),
|
|
106
|
+
...(stripUnknown ? { stripUnknown } : {}),
|
|
102
107
|
});
|
|
103
108
|
files.push({ filename: `${node.filename}.ts`, content: result.content });
|
|
104
109
|
for (const helper of result.usedHelpers)
|
|
@@ -30,6 +30,15 @@ type GenerateFileOptions = {
|
|
|
30
30
|
* of coercing invalid input to default values.
|
|
31
31
|
*/
|
|
32
32
|
readonly strict?: boolean;
|
|
33
|
+
/**
|
|
34
|
+
* When true, the generated parser builds its result from declared properties
|
|
35
|
+
* only, silently dropping undeclared input keys at every nesting level (zod's
|
|
36
|
+
* `.strip()`), without treating extras as a validation error. Composes with
|
|
37
|
+
* `strict` (still throws on wrong types / missing required properties) and
|
|
38
|
+
* yields to `additionalProperties: false` (which rejects rather than strips in
|
|
39
|
+
* strict mode).
|
|
40
|
+
*/
|
|
41
|
+
readonly stripUnknown?: boolean;
|
|
33
42
|
/**
|
|
34
43
|
* Controls how runtime helpers are referenced.
|
|
35
44
|
* - `'package'` (default): emit imports from `@amritk/helpers/...`.
|
|
@@ -51,13 +51,15 @@ export const generateFile = (schema, typeName, options) => {
|
|
|
51
51
|
}
|
|
52
52
|
return { content: result + typeDefinition, usedHelpers: new Set() };
|
|
53
53
|
}
|
|
54
|
+
const stripUnknown = options?.stripUnknown ?? false;
|
|
54
55
|
const parserFunction = generateParserFunction(schema, typeName, {
|
|
55
56
|
useRefImports: true,
|
|
56
57
|
typeSuffix,
|
|
57
58
|
...(options?.logWarnings !== undefined ? { logWarnings: options.logWarnings } : {}),
|
|
58
59
|
...(options?.strict !== undefined ? { strict: options.strict } : {}),
|
|
60
|
+
...(options?.stripUnknown !== undefined ? { stripUnknown: options.stripUnknown } : {}),
|
|
59
61
|
});
|
|
60
|
-
const shapeValidator = generateShapeValidator(schema, typeName, true, typeSuffix);
|
|
62
|
+
const shapeValidator = generateShapeValidator(schema, typeName, true, typeSuffix, true, stripUnknown);
|
|
61
63
|
const combinedFunctions = `${shapeValidator}\n\n${parserFunction}`;
|
|
62
64
|
const helpers = collectHelpers(combinedFunctions, helpersMode, options?.helpersImportPrefix);
|
|
63
65
|
const imports = [...collectImports(schema, { selfRef, rootSchema, typeSuffix }), ...helpers.imports];
|
|
@@ -21,6 +21,16 @@ type GenerateParserOptions = {
|
|
|
21
21
|
* undeclared keys throw too; otherwise they are still allowed.
|
|
22
22
|
*/
|
|
23
23
|
readonly strict?: boolean;
|
|
24
|
+
/**
|
|
25
|
+
* When true, the generated parser builds its result from the schema's declared
|
|
26
|
+
* properties only, silently dropping any undeclared input key at every nesting
|
|
27
|
+
* level (zod's `.strip()`). Unlike `additionalProperties: false`, extras are
|
|
28
|
+
* never a validation error โ this composes with `strict`: `strict + stripUnknown`
|
|
29
|
+
* still throws on wrong types and missing required properties but strips extras
|
|
30
|
+
* instead of throwing on them. When the schema *also* sets
|
|
31
|
+
* `additionalProperties: false`, rejecting wins over stripping in strict mode.
|
|
32
|
+
*/
|
|
33
|
+
readonly stripUnknown?: boolean;
|
|
24
34
|
/**
|
|
25
35
|
* Suffix appended to every type/parser name derived from a `$ref`. Must match
|
|
26
36
|
* the suffix used when generating the referenced files. Defaults to `''`.
|
|
@@ -55,5 +65,5 @@ export declare const shapeValidatorFunctionName: (typeName: string) => string;
|
|
|
55
65
|
* in additionalProperties). The stub is safe: parents calling it will fall
|
|
56
66
|
* through to the slow path, matching the pre-deep-fast-path behavior.
|
|
57
67
|
*/
|
|
58
|
-
export declare const generateShapeValidator: (schema: JSONSchema, typeName: string, useRefImports: boolean, suffix?: string, exported?: boolean) => string;
|
|
68
|
+
export declare const generateShapeValidator: (schema: JSONSchema, typeName: string, useRefImports: boolean, suffix?: string, exported?: boolean, stripUnknown?: boolean) => string;
|
|
59
69
|
export {};
|
|
@@ -2,6 +2,7 @@ import { escapeRegexPattern } from '@amritk/helpers/escape-regex-pattern';
|
|
|
2
2
|
import { refToName } from '@amritk/helpers/ref-to-name';
|
|
3
3
|
import { safeAccessor, safeKey } from '@amritk/helpers/safe-accessor';
|
|
4
4
|
import { hasAdditionalProperties, hasAllOf, hasAnyOf, hasConst, hasDefault, hasEnum, hasExamples, hasExclusiveMaximum, hasExclusiveMinimum, hasItems, hasMaxItems, hasMaximum, hasMaxLength, hasMinItems, hasMinimum, hasMinLength, hasMultipleOf, hasOneOf, hasPattern, hasProperties, hasRef, hasType, hasUniqueItems, isObjectSchema, isSchemaObject, } from '@amritk/helpers/schema-guards';
|
|
5
|
+
import { unknownKeyCheck } from '@amritk/helpers/unknown-key-check';
|
|
5
6
|
import { getDefaultValue } from '../helpers/get-default-value.js';
|
|
6
7
|
import { generateObjectStrictAssertion, generateScalarStrictAssertion } from './generate-strict-assertion.js';
|
|
7
8
|
import { generateValidationExpression } from './generate-validation-expression.js';
|
|
@@ -529,7 +530,7 @@ const shouldCacheVariable = (propSchema, _canFastPath, _useRefImports) => {
|
|
|
529
530
|
* - Fast path that returns input directly when all properties are already valid
|
|
530
531
|
* - Slow path with optimized expressions
|
|
531
532
|
*/
|
|
532
|
-
const generateObjectParser = (schema, typeName, useRefImports, suffix, logWarnings, strict, exported = true) => {
|
|
533
|
+
const generateObjectParser = (schema, typeName, useRefImports, suffix, logWarnings, strict, exported = true, stripUnknown = false) => {
|
|
533
534
|
const functionName = generateParserName(typeName);
|
|
534
535
|
const exportPrefix = exported ? 'export ' : '';
|
|
535
536
|
if (!hasProperties(schema)) {
|
|
@@ -548,16 +549,26 @@ const generateObjectParser = (schema, typeName, useRefImports, suffix, logWarnin
|
|
|
548
549
|
for (const [key, subName] of subTypeNames) {
|
|
549
550
|
const propSchema = schema.properties[key];
|
|
550
551
|
preamble.push(`type ${subName} = ${typeName}[${JSON.stringify(key)}];`);
|
|
551
|
-
preamble.push(generateShapeValidator(propSchema, subName, useRefImports, suffix, false));
|
|
552
|
-
preamble.push(generateObjectParser(propSchema, subName, useRefImports, suffix, logWarnings, strict, false));
|
|
553
|
-
}
|
|
554
|
-
// additionalProperties: false โ
|
|
555
|
-
//
|
|
556
|
-
//
|
|
552
|
+
preamble.push(generateShapeValidator(propSchema, subName, useRefImports, suffix, false, stripUnknown));
|
|
553
|
+
preamble.push(generateObjectParser(propSchema, subName, useRefImports, suffix, logWarnings, strict, false, stripUnknown));
|
|
554
|
+
}
|
|
555
|
+
// additionalProperties: false โ the per-key "is this undeclared" test inlines
|
|
556
|
+
// `!==` comparisons for a short key list and hoists a Set only for a long one
|
|
557
|
+
// (see unknownKeyCheck). The predicate form is shared by the fast path and the
|
|
558
|
+
// shape validator; strict mode throws with the offending key.
|
|
559
|
+
// `additionalProperties: false` strips *and rejects* extras; `stripUnknown`
|
|
560
|
+
// only strips them. Both build the result from declared properties alone, so
|
|
561
|
+
// they share the same machinery (`stripKeys`): drop the `...input` spread and
|
|
562
|
+
// gate the `{ ...input }` fast path on the known-keys predicate. Only the real
|
|
563
|
+
// `strictKeys` makes an extra a hard error (the throw block further down).
|
|
557
564
|
const strictKeys = hasStrictKeys(schema);
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
565
|
+
const stripKeys = strictKeys || stripUnknown;
|
|
566
|
+
const strictKeyCheck = unknownKeyCheck(properties.map(([key]) => key), `_knownKeys${typeName}`);
|
|
567
|
+
if (stripKeys) {
|
|
568
|
+
for (const declaration of strictKeyCheck.declarations) {
|
|
569
|
+
preamble.push(`${declaration};`);
|
|
570
|
+
}
|
|
571
|
+
preamble.push(`const _hasOnlyKnownKeys${typeName} = (input: Record<string, unknown>): boolean => {\n for (const _k in input) if (${strictKeyCheck.isUnknown('_k')}) return false;\n return true;\n};`);
|
|
561
572
|
}
|
|
562
573
|
const fallbackObject = generateFallbackObject(schema, useRefImports, typeName, suffix, subTypeNames);
|
|
563
574
|
const lines = [];
|
|
@@ -568,7 +579,7 @@ const generateObjectParser = (schema, typeName, useRefImports, suffix, logWarnin
|
|
|
568
579
|
}
|
|
569
580
|
if (strictKeys) {
|
|
570
581
|
lines.push(` for (const _k in input) {`);
|
|
571
|
-
lines.push(` if (
|
|
582
|
+
lines.push(` if (${strictKeyCheck.isUnknown('_k')}) throw new Error(\`[${typeName}] unknown property "\${_k}"\`);`);
|
|
572
583
|
lines.push(` }`);
|
|
573
584
|
}
|
|
574
585
|
}
|
|
@@ -617,10 +628,11 @@ const generateObjectParser = (schema, typeName, useRefImports, suffix, logWarnin
|
|
|
617
628
|
}
|
|
618
629
|
}
|
|
619
630
|
}
|
|
620
|
-
// The fast path returns `{ ...input }`, so
|
|
621
|
-
// it must only fire when no undeclared
|
|
622
|
-
//
|
|
623
|
-
|
|
631
|
+
// The fast path returns `{ ...input }`, which preserves extras โ so when we're
|
|
632
|
+
// stripping it must only fire when no undeclared key is present. The exception
|
|
633
|
+
// is strict + additionalProperties: false, where the throw block above already
|
|
634
|
+
// rejected any extra, so the fast path is safe to take unconditionally.
|
|
635
|
+
if (stripKeys && !(strict && strictKeys)) {
|
|
624
636
|
fastPathChecks.push(`_hasOnlyKnownKeys${typeName}(input)`);
|
|
625
637
|
}
|
|
626
638
|
// Second pass: generate variable declarations only for properties that need them
|
|
@@ -631,10 +643,12 @@ const generateObjectParser = (schema, typeName, useRefImports, suffix, logWarnin
|
|
|
631
643
|
}
|
|
632
644
|
// Emit a warning for any input key not declared in the schema's properties
|
|
633
645
|
if (logWarnings && propInfo.length > 0) {
|
|
634
|
-
const
|
|
635
|
-
|
|
646
|
+
const warnKeyCheck = unknownKeyCheck(propInfo.map(({ key }) => key), '_knownKeys');
|
|
647
|
+
for (const declaration of warnKeyCheck.declarations) {
|
|
648
|
+
lines.push(` ${declaration};`);
|
|
649
|
+
}
|
|
636
650
|
lines.push(` for (const _k in input) {`);
|
|
637
|
-
lines.push(` if (
|
|
651
|
+
lines.push(` if (${warnKeyCheck.isUnknown('_k')}) {`);
|
|
638
652
|
lines.push(` console.warn(\`[${typeName}] Unknown property "\${_k}"\`);`);
|
|
639
653
|
lines.push(` }`);
|
|
640
654
|
lines.push(` }`);
|
|
@@ -648,10 +662,11 @@ const generateObjectParser = (schema, typeName, useRefImports, suffix, logWarnin
|
|
|
648
662
|
lines.push(` if (${fastPathExpr}) return { ...input } as ${typeName};`);
|
|
649
663
|
}
|
|
650
664
|
// Generate slow-path object construction. The input spread preserves
|
|
651
|
-
// undeclared keys, so it is dropped
|
|
652
|
-
//
|
|
665
|
+
// undeclared keys, so it is dropped whenever we're stripping (either
|
|
666
|
+
// additionalProperties: false or stripUnknown) โ building from the declared
|
|
667
|
+
// properties alone strips the extras from the result.
|
|
653
668
|
const objectLines = [];
|
|
654
|
-
if (!
|
|
669
|
+
if (!stripKeys) {
|
|
655
670
|
objectLines.push(' ...input,');
|
|
656
671
|
}
|
|
657
672
|
// Spread allOf $ref parsers so their field coercions are applied before
|
|
@@ -747,11 +762,64 @@ const generateObjectParser = (schema, typeName, useRefImports, suffix, logWarnin
|
|
|
747
762
|
return fn;
|
|
748
763
|
return `${preamble.join('\n\n')}\n\n${fn}`;
|
|
749
764
|
};
|
|
765
|
+
/**
|
|
766
|
+
* Combined-parser variant for `properties` + `patternProperties` schemas that
|
|
767
|
+
* set `additionalProperties: false`. Declared properties are coerced as usual,
|
|
768
|
+
* keys matching any pattern are kept (the first `$ref` pattern is coerced via
|
|
769
|
+
* its imported parser when ref imports are on), and every other key is rejected
|
|
770
|
+
* in strict mode or stripped in coerce mode โ matching the interpreter.
|
|
771
|
+
*/
|
|
772
|
+
const generateStrictCombinedParser = (schema, typeName, functionName, propertyLines, patterns, useRefImports, suffix, strict) => {
|
|
773
|
+
const declaredKeys = hasProperties(schema) ? Object.keys(schema.properties) : [];
|
|
774
|
+
// Below the inline threshold a key === "a" || key === "b" chain skips declared
|
|
775
|
+
// keys without the per-call Set allocation; a wider list hoists the Set.
|
|
776
|
+
const knownKeyCheck = unknownKeyCheck(declaredKeys, '_knownKeys');
|
|
777
|
+
// The first $ref pattern (when ref imports are on) coerces its matching values
|
|
778
|
+
// through the imported parser; the remaining patterns keep the raw value.
|
|
779
|
+
const refPattern = useRefImports ? patterns.find(([, ps]) => isSchemaObject(ps) && hasRef(ps)) : undefined;
|
|
780
|
+
const loopLines = [` if (${knownKeyCheck.isKnown('key')}) continue;`];
|
|
781
|
+
if (refPattern) {
|
|
782
|
+
const parserName = generateParserName(refToName(refPattern[1].$ref, suffix));
|
|
783
|
+
loopLines.push(` if (/${escapeRegexPattern(refPattern[0])}/.test(key)) {`);
|
|
784
|
+
loopLines.push(` (result as Record<string, unknown>)[key] = ${parserName}(input[key]);`);
|
|
785
|
+
loopLines.push(` continue;`);
|
|
786
|
+
loopLines.push(` }`);
|
|
787
|
+
}
|
|
788
|
+
const keepConditions = patterns
|
|
789
|
+
.filter(([p]) => !(refPattern && p === refPattern[0]))
|
|
790
|
+
.map(([p]) => `/${escapeRegexPattern(p)}/.test(key)`);
|
|
791
|
+
if (keepConditions.length > 0) {
|
|
792
|
+
loopLines.push(` if (${keepConditions.join(' || ')}) {`);
|
|
793
|
+
loopLines.push(` (result as Record<string, unknown>)[key] = input[key];`);
|
|
794
|
+
loopLines.push(` continue;`);
|
|
795
|
+
loopLines.push(` }`);
|
|
796
|
+
}
|
|
797
|
+
// Unknown key: throw in strict mode, otherwise let it fall through (dropped,
|
|
798
|
+
// since it was never copied into `result`).
|
|
799
|
+
if (strict)
|
|
800
|
+
loopLines.push(` throw new Error(\`[${typeName}] unknown property "\${key}"\`);`);
|
|
801
|
+
const notObjectBranch = strict
|
|
802
|
+
? ` throw new Error(\`[${typeName}] expected object, got \${input === null ? "null" : typeof input}\`);`
|
|
803
|
+
: ` return {} as unknown as ${typeName};`;
|
|
804
|
+
const resultBody = propertyLines.length > 0 ? `{\n${propertyLines.join('\n')}\n }` : '{}';
|
|
805
|
+
// Only the Set form needs a declaration; the inline === chain is stateless.
|
|
806
|
+
const knownKeysDeclaration = knownKeyCheck.declarations.map((decl) => ` ${decl};\n`).join('');
|
|
807
|
+
return `export const ${functionName} = (input: unknown): ${typeName} => {
|
|
808
|
+
if (!isObject(input)) {
|
|
809
|
+
${notObjectBranch}
|
|
810
|
+
}
|
|
811
|
+
${knownKeysDeclaration} const result = ${resultBody} as unknown as ${typeName};
|
|
812
|
+
for (const key in input) {
|
|
813
|
+
${loopLines.join('\n')}
|
|
814
|
+
}
|
|
815
|
+
return result;
|
|
816
|
+
};`;
|
|
817
|
+
};
|
|
750
818
|
/**
|
|
751
819
|
* Generates a parser for schemas that have both properties AND patternProperties.
|
|
752
820
|
* Parses the known properties first, then iterates remaining keys to match patterns.
|
|
753
821
|
*/
|
|
754
|
-
const generateCombinedObjectParser = (schema, typeName, useRefImports, suffix, logWarnings, strict) => {
|
|
822
|
+
const generateCombinedObjectParser = (schema, typeName, useRefImports, suffix, logWarnings, strict, stripUnknown = false) => {
|
|
755
823
|
const functionName = generateParserName(typeName);
|
|
756
824
|
const entries = generatePropertyEntries(schema, useRefImports, suffix);
|
|
757
825
|
// Build the known property lines for the initial object
|
|
@@ -763,13 +831,21 @@ const generateCombinedObjectParser = (schema, typeName, useRefImports, suffix, l
|
|
|
763
831
|
});
|
|
764
832
|
// Find the first pattern with a $ref for parser delegation
|
|
765
833
|
if (!isSchemaObject(schema) || !('patternProperties' in schema)) {
|
|
766
|
-
return generateObjectParser(schema, typeName, useRefImports, suffix, logWarnings, strict);
|
|
834
|
+
return generateObjectParser(schema, typeName, useRefImports, suffix, logWarnings, strict, true, stripUnknown);
|
|
767
835
|
}
|
|
768
836
|
const patternProps = schema.patternProperties;
|
|
769
837
|
const patterns = Object.entries(patternProps);
|
|
770
838
|
const refPattern = patterns.find(([, ps]) => isSchemaObject(ps) && hasRef(ps));
|
|
839
|
+
const strictKeys = hasAdditionalProperties(schema) && schema.additionalProperties === false;
|
|
840
|
+
// With `additionalProperties: false`, keys matching neither a declared
|
|
841
|
+
// property nor any pattern must be rejected (strict) or stripped (coerce).
|
|
842
|
+
// The blanket `...input` spread used below cannot do that, so build a
|
|
843
|
+
// selective copy that mirrors the interpreter's undeclared-key handling.
|
|
844
|
+
if (strictKeys) {
|
|
845
|
+
return generateStrictCombinedParser(schema, typeName, functionName, propertyLines, patterns, useRefImports, suffix, strict);
|
|
846
|
+
}
|
|
771
847
|
if (!refPattern || !useRefImports) {
|
|
772
|
-
return generateObjectParser(schema, typeName, useRefImports, suffix, logWarnings, strict);
|
|
848
|
+
return generateObjectParser(schema, typeName, useRefImports, suffix, logWarnings, strict, true, stripUnknown);
|
|
773
849
|
}
|
|
774
850
|
const [pattern, patternSchema] = refPattern;
|
|
775
851
|
const ref = patternSchema.$ref;
|
|
@@ -894,6 +970,38 @@ const generatePatternPropertiesParser = (schema, typeName, useRefImports, suffix
|
|
|
894
970
|
const notObjectBranch = strict
|
|
895
971
|
? ` throw new Error(\`[${typeName}] expected object, got \${input === null ? "null" : typeof input}\`);`
|
|
896
972
|
: ` return {} as unknown as ${typeName};`;
|
|
973
|
+
// With `additionalProperties: false`, only keys matching a pattern survive:
|
|
974
|
+
// others are rejected (strict) or stripped (coerce). Start from an empty
|
|
975
|
+
// object rather than spreading every input key.
|
|
976
|
+
const strictKeys = hasAdditionalProperties(schema) && schema.additionalProperties === false;
|
|
977
|
+
if (strictKeys) {
|
|
978
|
+
const loopLines = [` if (/${escapedPattern}/.test(key)) {`];
|
|
979
|
+
if (patternAssignment) {
|
|
980
|
+
loopLines.push(` const value = input[key];`);
|
|
981
|
+
loopLines.push(` ${patternAssignment}`);
|
|
982
|
+
}
|
|
983
|
+
loopLines.push(` continue;`);
|
|
984
|
+
loopLines.push(` }`);
|
|
985
|
+
const keepConditions = patterns.slice(1).map(([p]) => `/${escapeRegexPattern(p)}/.test(key)`);
|
|
986
|
+
if (keepConditions.length > 0) {
|
|
987
|
+
loopLines.push(` if (${keepConditions.join(' || ')}) {`);
|
|
988
|
+
loopLines.push(` (result as Record<string, unknown>)[key] = input[key];`);
|
|
989
|
+
loopLines.push(` continue;`);
|
|
990
|
+
loopLines.push(` }`);
|
|
991
|
+
}
|
|
992
|
+
if (strict)
|
|
993
|
+
loopLines.push(` throw new Error(\`[${typeName}] unknown property "\${key}"\`);`);
|
|
994
|
+
return `export const ${functionName} = (input: unknown): ${typeName} => {
|
|
995
|
+
if (!isObject(input)) {
|
|
996
|
+
${notObjectBranch}
|
|
997
|
+
}
|
|
998
|
+
const result = {} as unknown as ${typeName};
|
|
999
|
+
for (const key in input) {
|
|
1000
|
+
${loopLines.join('\n')}
|
|
1001
|
+
}
|
|
1002
|
+
return result;
|
|
1003
|
+
};`;
|
|
1004
|
+
}
|
|
897
1005
|
// Generate a parser that handles both pattern matching and x- extensions
|
|
898
1006
|
return `export const ${functionName} = (input: unknown): ${typeName} => {
|
|
899
1007
|
if (!isObject(input)) {
|
|
@@ -916,7 +1024,7 @@ ${notObjectBranch}
|
|
|
916
1024
|
* This always generates parser calls regardless of useRefImports setting,
|
|
917
1025
|
* because conditional logic requires delegating to the appropriate parser.
|
|
918
1026
|
*/
|
|
919
|
-
const generateConditionalParser = (schema, typeName, suffix) => {
|
|
1027
|
+
const generateConditionalParser = (schema, typeName, suffix, stripUnknown = false) => {
|
|
920
1028
|
const functionName = generateParserName(typeName);
|
|
921
1029
|
// Extract the condition and branches
|
|
922
1030
|
const ifSchema = schema.if;
|
|
@@ -931,7 +1039,7 @@ const generateConditionalParser = (schema, typeName, suffix) => {
|
|
|
931
1039
|
// regular object parser from the merged property set.
|
|
932
1040
|
const mergedSchema = getConditionalObjectSchema(schema);
|
|
933
1041
|
if (mergedSchema) {
|
|
934
|
-
return generateObjectParser(mergedSchema, typeName, false, suffix);
|
|
1042
|
+
return generateObjectParser(mergedSchema, typeName, false, suffix, false, false, true, stripUnknown);
|
|
935
1043
|
}
|
|
936
1044
|
return generateEmptyObjectParser(typeName);
|
|
937
1045
|
}
|
|
@@ -945,7 +1053,7 @@ const generateConditionalParser = (schema, typeName, suffix) => {
|
|
|
945
1053
|
// Condition is not a $ref check โ flatten into an object parser
|
|
946
1054
|
const mergedSchema = getConditionalObjectSchema(schema);
|
|
947
1055
|
if (mergedSchema) {
|
|
948
|
-
return generateObjectParser(mergedSchema, typeName, false, suffix);
|
|
1056
|
+
return generateObjectParser(mergedSchema, typeName, false, suffix, false, false, true, stripUnknown);
|
|
949
1057
|
}
|
|
950
1058
|
return generateEmptyObjectParser(typeName);
|
|
951
1059
|
}
|
|
@@ -1040,6 +1148,7 @@ const selectParserStrategy = (schema, typeName, options) => {
|
|
|
1040
1148
|
const useRefImports = options?.useRefImports ?? false;
|
|
1041
1149
|
const logWarnings = options?.logWarnings ?? false;
|
|
1042
1150
|
const strict = options?.strict ?? false;
|
|
1151
|
+
const stripUnknown = options?.stripUnknown ?? false;
|
|
1043
1152
|
const suffix = options?.typeSuffix ?? '';
|
|
1044
1153
|
// Special case for the self-referential JSON Schema meta-schema type (e.g.
|
|
1045
1154
|
// `Schema` / `SchemaObject`) - it can be any JSON Schema.
|
|
@@ -1056,24 +1165,24 @@ const selectParserStrategy = (schema, typeName, options) => {
|
|
|
1056
1165
|
// This generates a parser that handles known properties and also iterates
|
|
1057
1166
|
// pattern-matched keys (e.g. responses with "default" + "200", "4XX").
|
|
1058
1167
|
if (hasProperties(schema) && isSchemaObject(schema) && 'patternProperties' in schema) {
|
|
1059
|
-
return generateCombinedObjectParser(schema, typeName, useRefImports, suffix, logWarnings, strict);
|
|
1168
|
+
return generateCombinedObjectParser(schema, typeName, useRefImports, suffix, logWarnings, strict, stripUnknown);
|
|
1060
1169
|
}
|
|
1061
1170
|
// Handle schemas that have explicit properties โ generate a full object parser.
|
|
1062
1171
|
// This intentionally runs before if/then checks so that schemas with both
|
|
1063
1172
|
// properties AND conditional keywords use all declared properties rather than
|
|
1064
1173
|
// only the if/then fragment.
|
|
1065
1174
|
if (hasProperties(schema)) {
|
|
1066
|
-
return generateObjectParser(schema, typeName, useRefImports, suffix, logWarnings, strict);
|
|
1175
|
+
return generateObjectParser(schema, typeName, useRefImports, suffix, logWarnings, strict, true, stripUnknown);
|
|
1067
1176
|
}
|
|
1068
1177
|
// Handle conditional schemas (if/then/else) for schemas without explicit properties.
|
|
1069
1178
|
if (isSchemaObject(schema) && 'if' in schema && 'then' in schema && 'else' in schema) {
|
|
1070
|
-
return generateConditionalParser(schema, typeName, suffix);
|
|
1179
|
+
return generateConditionalParser(schema, typeName, suffix, stripUnknown);
|
|
1071
1180
|
}
|
|
1072
1181
|
// Handle conditional schemas that only define if/then object fragments.
|
|
1073
1182
|
// We flatten the fragments into a regular object parser.
|
|
1074
1183
|
const conditionalObjectSchema = getConditionalObjectSchema(schema);
|
|
1075
1184
|
if (conditionalObjectSchema) {
|
|
1076
|
-
return generateObjectParser(conditionalObjectSchema, typeName, useRefImports, suffix, logWarnings, strict);
|
|
1185
|
+
return generateObjectParser(conditionalObjectSchema, typeName, useRefImports, suffix, logWarnings, strict, true, stripUnknown);
|
|
1077
1186
|
}
|
|
1078
1187
|
// Handle non-object schemas with type-appropriate validation (no properties, no conditionals)
|
|
1079
1188
|
if (!isObjectLikeSchema) {
|
|
@@ -1130,7 +1239,7 @@ export const shapeValidatorFunctionName = (typeName) => shapeValidatorName(typeN
|
|
|
1130
1239
|
* in additionalProperties). The stub is safe: parents calling it will fall
|
|
1131
1240
|
* through to the slow path, matching the pre-deep-fast-path behavior.
|
|
1132
1241
|
*/
|
|
1133
|
-
export const generateShapeValidator = (schema, typeName, useRefImports, suffix = '', exported = true) => {
|
|
1242
|
+
export const generateShapeValidator = (schema, typeName, useRefImports, suffix = '', exported = true, stripUnknown = false) => {
|
|
1134
1243
|
const fnName = shapeValidatorName(typeName);
|
|
1135
1244
|
const exportPrefix = exported ? 'export ' : '';
|
|
1136
1245
|
const stub = `${exportPrefix}const ${fnName} = (_input: unknown): boolean => false;`;
|
|
@@ -1153,9 +1262,12 @@ export const generateShapeValidator = (schema, typeName, useRefImports, suffix =
|
|
|
1153
1262
|
// Same deterministic naming as the parser's sub-parser generation, so the
|
|
1154
1263
|
// referenced private shape predicates exist in the same file.
|
|
1155
1264
|
const subTypeNames = collectInlineObjectProperties(schema, typeName);
|
|
1156
|
-
//
|
|
1157
|
-
//
|
|
1158
|
-
|
|
1265
|
+
// When the parser strips extras (additionalProperties: false or stripUnknown),
|
|
1266
|
+
// an input carrying an undeclared key is *not* fast-path eligible โ the parser
|
|
1267
|
+
// would strip it rather than return `{ ...input }` โ so the shape only matches
|
|
1268
|
+
// when every key is declared. (The `_hasOnlyKnownKeys` predicate this calls is
|
|
1269
|
+
// emitted by the parser under the same `stripKeys` condition.)
|
|
1270
|
+
const strictKeysGuard = hasStrictKeys(schema) || stripUnknown ? `\n if (!_hasOnlyKnownKeys${typeName}(input)) return false;` : '';
|
|
1159
1271
|
const checks = [];
|
|
1160
1272
|
for (const [key, propSchema] of properties) {
|
|
1161
1273
|
// Records of refs require iterating every value โ too expensive for the fast path.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@amritk/generate-parsers",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "Generate TypeScript parsers and type definitions from JSON Schemas.",
|
|
5
5
|
"module": "./dist/index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -32,7 +32,8 @@
|
|
|
32
32
|
"build": "tsgo -p tsconfig.build.json && tsc-alias -p tsconfig.build.json -f",
|
|
33
33
|
"types:check": "tsgo -p . --noEmit",
|
|
34
34
|
"test": "NODE_ENV=production vitest run --root ../.. generate-parsers",
|
|
35
|
-
"generate-readme": "bun run ./scripts/generate-readme.ts"
|
|
35
|
+
"generate-readme": "bun run ./scripts/generate-readme.ts",
|
|
36
|
+
"bench": "bun --conditions development ./bench/run.ts"
|
|
36
37
|
},
|
|
37
38
|
"imports": {
|
|
38
39
|
"#generators/*": "./src/generators/*.ts",
|
|
@@ -47,7 +48,11 @@
|
|
|
47
48
|
},
|
|
48
49
|
"dependencies": {
|
|
49
50
|
"json-schema-typed": "^8.0.1",
|
|
50
|
-
"@amritk/helpers": "0.
|
|
51
|
+
"@amritk/helpers": "0.10.0",
|
|
51
52
|
"@amritk/generate-markdown": "0.2.4"
|
|
53
|
+
},
|
|
54
|
+
"devDependencies": {
|
|
55
|
+
"@sinclair/typebox": "^0.34.49",
|
|
56
|
+
"zod": "^4.4.3"
|
|
52
57
|
}
|
|
53
58
|
}
|