Repository: vega/ts-json-schema-generator (ts)

Issue:
`--additional-properties` leads to crash (UnknownType index) and property-loss with mapped/intersection helpers
### 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


```ts
type OptKeys<T> = {
    [K in keyof T]-?: {} extends Pick<T, K> ? K : never;
}[keyof T];

interface Foo {
    foo: null;
}

export type MyObject = OptKeys<Foo>;
```
**\+**
```ts
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

```diff
// 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

```ts
// 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:
```json
{
  "properties": {
    "foo": { "type": "string" },
    "bar": { "type": "number" }
  },
  "required": ["foo", "bar"],
  "type": "object"
}
```

Actual schema after the patch:

```json
{
  "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.


Pull request description:
Fix: crashes and incomplete schema generation when mapped/intersection helpers are used with `--additional-properties` option
Closes #2273

Patch:
diff --git a/src/NodeParser/MappedTypeNodeParser.ts b/src/NodeParser/MappedTypeNodeParser.ts
index baa494f..625cee6 100644
--- a/src/NodeParser/MappedTypeNodeParser.ts
+++ b/src/NodeParser/MappedTypeNodeParser.ts
@@ -65,7 +65,7 @@ export class MappedTypeNodeParser implements SubNodeParser {
                 return type instanceof NeverType ? new NeverType() : new ArrayType(type);
             }
             // Key type widens to `string`
-            const type = this.childNodeParser.createType(node.type!, context);
+            const type = this.childNodeParser.createType(node.type!, this.createSubContext(node, keyListType, context));
             // const resultType = type instanceof NeverType ? new NeverType() : new ObjectType(id, [], [], type);
             const resultType = new ObjectType(id, [], [], type);
             if (resultType) {
diff --git a/src/NodeParser/TypeOperatorNodeParser.ts b/src/NodeParser/TypeOperatorNodeParser.ts
index 5e4353d..84d8054 100644
--- a/src/NodeParser/TypeOperatorNodeParser.ts
+++ b/src/NodeParser/TypeOperatorNodeParser.ts
@@ -2,7 +2,7 @@ import ts from "typescript";
 import type { Context, NodeParser } from "../NodeParser.js";
 import type { SubNodeParser } from "../SubNodeParser.js";
 import { ArrayType } from "../Type/ArrayType.js";
-import type { BaseType } from "../Type/BaseType.js";
+import { BaseType } from "../Type/BaseType.js";
 import { NumberType } from "../Type/NumberType.js";
 import { ObjectType } from "../Type/ObjectType.js";
 import { StringType } from "../Type/StringType.js";
@@ -28,7 +28,7 @@ export class TypeOperatorNodeParser implements SubNodeParser {
             return new NumberType();
         }
         const keys = getTypeKeys(type);
-        if (derefed instanceof ObjectType && derefed.getAdditionalProperties()) {
+        if (derefed instanceof ObjectType && derefed.getAdditionalProperties() instanceof BaseType) {
             return new UnionType([...keys, new StringType()]);
         }
 


Test patch:
diff --git a/test/config.test.ts b/test/config.test.ts
index 5386a75..39aba68 100644
--- a/test/config.test.ts
+++ b/test/config.test.ts
@@ -390,6 +390,38 @@ describe("config", () => {
         }),
     );
 
+    it(
+        "mapped-intersection",
+        assertSchema("mapped-intersection", {
+            type: "MyObject",
+            additionalProperties: true,
+        }),
+    );
+
+    it(
+        "mapped-intersection-complex",
+        assertSchema("mapped-intersection-complex", {
+            type: "MyObject",
+            additionalProperties: true,
+        }),
+    );
+
+    it(
+        "mapped-intersection-index",
+        assertSchema("mapped-intersection-index", {
+            type: "MyObject",
+            additionalProperties: true,
+        }),
+    );
+
+    it(
+        "mapped-index-any",
+        assertSchema("mapped-index-any", {
+            type: "*",
+            additionalProperties: true,
+        }),
+    );
+
     it(
         "arrow-function-parameters",
         assertSchema("arrow-function-parameters", {
diff --git a/test/config/mapped-index-any/main.ts b/test/config/mapped-index-any/main.ts
new file mode 100644
index 0000000..95447b6
--- /dev/null
+++ b/test/config/mapped-index-any/main.ts
@@ -0,0 +1,15 @@
+interface BaseA {
+    foo: string;
+    [key: string]: any;
+}
+
+type Keep<B> = { [K in keyof B]: B[K] };
+
+export type MyObjectA = Keep<BaseA>;
+
+interface BaseB {
+    foo: string;
+    [key: string]: unknown;
+}
+
+export type MyObjectB = { [K in keyof BaseB]: any };
diff --git a/test/config/mapped-index-any/schema.json b/test/config/mapped-index-any/schema.json
new file mode 100644
index 0000000..e4fd7bd
--- /dev/null
+++ b/test/config/mapped-index-any/schema.json
@@ -0,0 +1,25 @@
+{
+  "$schema": "http://json-schema.org/draft-07/schema#",
+  "definitions": {
+    "MyObjectA": {
+      "properties": {
+        "foo": {
+          "type": "string"
+        }
+      },
+      "required": [
+        "foo"
+      ],
+      "type": "object"
+    },
+    "MyObjectB": {
+      "properties": {
+        "foo": {}
+      },
+      "required": [
+        "foo"
+      ],
+      "type": "object"
+    }
+  }
+}
diff --git a/test/config/mapped-intersection-complex/main.ts b/test/config/mapped-intersection-complex/main.ts
new file mode 100644
index 0000000..e3c3f88
--- /dev/null
+++ b/test/config/mapped-intersection-complex/main.ts
@@ -0,0 +1,20 @@
+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 }>;
diff --git a/test/config/mapped-intersection-complex/schema.json b/test/config/mapped-intersection-complex/schema.json
new file mode 100644
index 0000000..ec68db7
--- /dev/null
+++ b/test/config/mapped-intersection-complex/schema.json
@@ -0,0 +1,21 @@
+{
+  "$ref": "#/definitions/MyObject",
+  "$schema": "http://json-schema.org/draft-07/schema#",
+  "definitions": {
+    "MyObject": {
+      "properties": {
+        "bar": {
+          "type": "number"
+        },
+        "foo": {
+          "type": "string"
+        }
+      },
+      "required": [
+        "bar",
+        "foo"
+      ],
+      "type": "object"
+    }
+  }
+}
diff --git a/test/config/mapped-intersection-index/main.ts b/test/config/mapped-intersection-index/main.ts
new file mode 100644
index 0000000..378a232
--- /dev/null
+++ b/test/config/mapped-intersection-index/main.ts
@@ -0,0 +1,11 @@
+interface Base {
+    foo: string;
+    bar: null;
+    [key: string]: any;
+}
+
+type Keep<B, K> = { [P in Exclude<keyof B, K>]: B[P] };
+
+type Override<B, O extends Partial<Record<keyof B, any>>> = Keep<B, keyof O> & O;
+
+export type MyObject = Override<Base, { bar: number }>;
diff --git a/test/config/mapped-intersection-index/schema.json b/test/config/mapped-intersection-index/schema.json
new file mode 100644
index 0000000..ec68db7
--- /dev/null
+++ b/test/config/mapped-intersection-index/schema.json
@@ -0,0 +1,21 @@
+{
+  "$ref": "#/definitions/MyObject",
+  "$schema": "http://json-schema.org/draft-07/schema#",
+  "definitions": {
+    "MyObject": {
+      "properties": {
+        "bar": {
+          "type": "number"
+        },
+        "foo": {
+          "type": "string"
+        }
+      },
+      "required": [
+        "bar",
+        "foo"
+      ],
+      "type": "object"
+    }
+  }
+}
diff --git a/test/config/mapped-intersection/main.ts b/test/config/mapped-intersection/main.ts
new file mode 100644
index 0000000..7ee36b3
--- /dev/null
+++ b/test/config/mapped-intersection/main.ts
@@ -0,0 +1,8 @@
+interface Base {
+    foo: string;
+    bar: null;
+}
+
+type Keep<B, K extends keyof B> = { [P in Exclude<keyof B, K>]: B[P] };
+
+export type MyObject = Keep<Base, "bar"> & { bar: number };
diff --git a/test/config/mapped-intersection/schema.json b/test/config/mapped-intersection/schema.json
new file mode 100644
index 0000000..ec68db7
--- /dev/null
+++ b/test/config/mapped-intersection/schema.json
@@ -0,0 +1,21 @@
+{
+  "$ref": "#/definitions/MyObject",
+  "$schema": "http://json-schema.org/draft-07/schema#",
+  "definitions": {
+    "MyObject": {
+      "properties": {
+        "bar": {
+          "type": "number"
+        },
+        "foo": {
+          "type": "string"
+        }
+      },
+      "required": [
+        "bar",
+        "foo"
+      ],
+      "type": "object"
+    }
+  }
+}