ts-json-schema-generator
ts-json-schema-generator copied to clipboard
`--additional-properties` leads to crash (UnknownType index) and property-loss with mapped/intersection helpers
TL;DR
- When
--additional-propertiesis true, any helper that containsTBase[K] /* where K ∈ keyof TBase */inside a mapped type crashes the generator with
LogicError: Unexpected type "unknown" ...
-
The one-line patch below removes the crash, but one half of an intersection loses its properties, so the final schema is still wrong.
-
Need input from the maintainers on the recommended course of action (see the questions below).
1 Minimal repro
type OptKeys<T> = {
[K in keyof T]-?: {} extends Pick<T, K> ? K : never;
}[keyof T];
interface Foo {
foo: null;
}
export type MyObject = OptKeys<Foo>;
+
config: { /*...*/ additionalProperties: true }
Error:
Unexpected type "unknown" (expected "LiteralType.js" or "StringType.js" or "NumberType.js")
at IndexedAccessTypeNodeParser.createType (src/NodeParser/IndexedAccessTypeNodeParser.ts:65:23)
2 Root cause
- The
additionalPropertiesflag makes every object behave as though it had[key:string]: unknown. keyof Footherefore widens tostring | "foo".- In the mapped type branch
K = string, the code path inMappedTypeNodeParsercallschildNodeParser.createType(node.type!, context)without passing a sub-context that bindsK.
The reference toKis unresolved and becomesUnknownType(true). IndexedAccessTypeNodeParseronly acceptsLiteralType,StringType,NumberTypetherefore it throws onUnknownType.
3 One-line crash fix
// src/NodeParser/MappedTypeNodeParser.ts
// MappedTypeNodeParser.createType
- const type = this.childNodeParser.createType(node.type!, context);
+ const type = this.childNodeParser.createType(
+ node.type!,
+ this.createSubContext(node, keyListType, context),
+ );
After this change the repro no longer crashes...
4 ...but schemas are still wrong for real helpers
// typical pattern: override properties but ensure no new properties added
type OptionalKeys<TBase> = {
[K in keyof TBase]-?: {} extends Pick<TBase, K> ? K : never;
}[keyof TBase];
type Override<TBase, TOverride extends { [K in keyof TOverride]: K extends keyof TBase ? unknown : never }> = {
[K in keyof TBase as K extends keyof TOverride ? never : K]: TBase[K]; // keep everything except overrides
} & {
[K in keyof TOverride]: K extends keyof TBase
? K extends OptionalKeys<TBase> // preserve optionality
? TOverride[K] | undefined
: TOverride[K]
: never;
};
interface Base { foo: string; bar: null; }
export type MyObject = Override<Base, { bar: number }>;
Expected:
{
"properties": {
"foo": { "type": "string" },
"bar": { "type": "number" }
},
"required": ["foo", "bar"],
"type": "object"
}
Actual schema after the patch:
{
"properties": {
"bar": { "type": "number" }
},
"type": "object"
}
foo is silently dropped because the two halves of the intersection have different additionalProperties values (one true, one false) and IntersectionTypeFormatter merges only when both are false.
And the required array is missing, too.
5 Questions / guidance needed
- Preffered fix location?
- Option A Make
IndexedAccessTypeNodeParsertreatUnknownTypelikeAnyType. - Option B Always pass a bound sub-context (one-liner above) and teach
MappedTypeNodeParserwhen to emitadditionalProperties: falseso intersections merge correctly. - Option C Relax
IntersectionTypeFormatterto merge when the two objects share identical sets of explicit properties, regardless of theiradditionalPropertiesvalues.
- Option A Make
I'm happy to prepare a PR once we agree what should be changed.