ts-json-schema-generator icon indicating copy to clipboard operation
ts-json-schema-generator copied to clipboard

`--additional-properties` leads to crash (UnknownType index) and property-loss with mapped/intersection helpers

Open alexchexes opened this issue 4 months ago • 3 comments

TL;DR

  • When --additional-properties is true, any helper that contains TBase[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

  1. The additionalProperties flag makes every object behave as though it had [key:string]: unknown.
  2. keyof Foo therefore widens to string | "foo".
  3. In the mapped type branch K = string, the code path in MappedTypeNodeParser calls childNodeParser.createType(node.type!, context) without passing a sub-context that binds K.
    The reference to K is unresolved and becomes UnknownType(true).
  4. IndexedAccessTypeNodeParser only accepts LiteralType, StringType, NumberType therefore it throws on UnknownType.

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 IndexedAccessTypeNodeParser treat UnknownType like AnyType.
    • Option B Always pass a bound sub-context (one-liner above) and teach MappedTypeNodeParser when to emit additionalProperties: false so intersections merge correctly.
    • Option C Relax IntersectionTypeFormatter to merge when the two objects share identical sets of explicit properties, regardless of their additionalProperties values.

I'm happy to prepare a PR once we agree what should be changed.

alexchexes avatar Jun 11 '25 20:06 alexchexes