From fa521d629bb2c720e164f6ea0c624e5d0bbb2cc9 Mon Sep 17 00:00:00 2001 From: alexchexes Date: Mon, 14 Apr 2025 11:24:09 +0100 Subject: [PATCH 1/2] fix: fully unwrap union aliases in mapped keys to avoid generating incorrect additionalProperties (#2231) --- src/NodeParser/MappedTypeNodeParser.ts | 6 ++++- src/Utils/derefType.ts | 19 +++++++++++++++ .../type-mapped-pick-union-alias/main.ts | 20 ++++++++++++---- .../type-mapped-pick-union-alias/schema.json | 23 +++++++++++++++---- .../type-mapped-union-union/schema.json | 4 +--- 5 files changed, 59 insertions(+), 13 deletions(-) diff --git a/src/NodeParser/MappedTypeNodeParser.ts b/src/NodeParser/MappedTypeNodeParser.ts index 9fe6e2199..baa494f79 100644 --- a/src/NodeParser/MappedTypeNodeParser.ts +++ b/src/NodeParser/MappedTypeNodeParser.ts @@ -16,7 +16,7 @@ import { ObjectProperty, ObjectType } from "../Type/ObjectType.js"; import { StringType } from "../Type/StringType.js"; import { SymbolType } from "../Type/SymbolType.js"; import { UnionType } from "../Type/UnionType.js"; -import { derefAnnotatedType, derefType } from "../Utils/derefType.js"; +import { derefAnnotatedType, derefType, isDeepLiteralUnion } from "../Utils/derefType.js"; import { getKey } from "../Utils/nodeKey.js"; import { preserveAnnotation } from "../Utils/preserveAnnotation.js"; import { removeUndefined } from "../Utils/removeUndefined.js"; @@ -158,6 +158,10 @@ export class MappedTypeNodeParser implements SubNodeParser { keyListType: UnionType, context: Context, ): BaseType | boolean { + if (isDeepLiteralUnion(keyListType)) { + return this.additionalProperties; + } + const key = keyListType.getTypes().filter((type) => !(derefType(type) instanceof LiteralType))[0]; if (key) { diff --git a/src/Utils/derefType.ts b/src/Utils/derefType.ts index 606146cca..70d7f8745 100644 --- a/src/Utils/derefType.ts +++ b/src/Utils/derefType.ts @@ -3,8 +3,10 @@ import { AnnotatedType } from "../Type/AnnotatedType.js"; import type { BaseType } from "../Type/BaseType.js"; import { DefinitionType } from "../Type/DefinitionType.js"; import { HiddenType } from "../Type/HiddenType.js"; +import { LiteralType } from "../Type/LiteralType.js"; import { NeverType } from "../Type/NeverType.js"; import { ReferenceType } from "../Type/ReferenceType.js"; +import { UnionType } from "../Type/UnionType.js"; /** * Dereference the type as far as possible. @@ -38,6 +40,23 @@ export function isHiddenType(type: BaseType): boolean { return false; } +/** + * Recursively checks whether the given type is a union composed entirely of literal types. + */ +export function isDeepLiteralUnion(type: BaseType): boolean { + const resolved = derefType(type); + + if (resolved instanceof LiteralType) { + return true; + } + + if (resolved instanceof UnionType) { + return resolved.getTypes().every((t) => isDeepLiteralUnion(t)); + } + + return false; +} + export function derefAliasedType(type: BaseType): BaseType { if (type instanceof AliasType) { return derefAliasedType(type.getType()); diff --git a/test/valid-data/type-mapped-pick-union-alias/main.ts b/test/valid-data/type-mapped-pick-union-alias/main.ts index eae77833a..f42753479 100644 --- a/test/valid-data/type-mapped-pick-union-alias/main.ts +++ b/test/valid-data/type-mapped-pick-union-alias/main.ts @@ -1,9 +1,19 @@ interface SomeInterface { - foo: string; - bar: number; + a: number; + b: string; + c: boolean; + d: string[]; + e: null; } -type KeyFoo = "foo"; -type KeyBar = "bar"; +type A = "a"; +type B = "b"; +type C = "c"; +type D = "d"; +type E = "e"; -export type PickAliasedLiteralUnion = Pick; +type AB = A | B; +type ABC = AB | C; +type ABCD = ABC | D; + +export type PickAliasedLiteralUnion = Pick; diff --git a/test/valid-data/type-mapped-pick-union-alias/schema.json b/test/valid-data/type-mapped-pick-union-alias/schema.json index e04bcad63..aa45b60e9 100644 --- a/test/valid-data/type-mapped-pick-union-alias/schema.json +++ b/test/valid-data/type-mapped-pick-union-alias/schema.json @@ -5,16 +5,31 @@ "PickAliasedLiteralUnion": { "additionalProperties": false, "properties": { - "bar": { + "a": { "type": "number" }, - "foo": { + "b": { "type": "string" + }, + "c": { + "type": "boolean" + }, + "d": { + "items": { + "type": "string" + }, + "type": "array" + }, + "e": { + "type": "null" } }, "required": [ - "foo", - "bar" + "a", + "b", + "c", + "d", + "e" ], "type": "object" } diff --git a/test/valid-data/type-mapped-union-union/schema.json b/test/valid-data/type-mapped-union-union/schema.json index 8deb3bb8c..7e8197128 100644 --- a/test/valid-data/type-mapped-union-union/schema.json +++ b/test/valid-data/type-mapped-union-union/schema.json @@ -3,9 +3,7 @@ "$schema": "http://json-schema.org/draft-07/schema#", "definitions": { "MyType": { - "additionalProperties": { - "type": "string" - }, + "additionalProperties": false, "properties": { "s1": { "type": "string" From ce541dc0e919440f706e18c9f93ba4d83bba4c5b Mon Sep 17 00:00:00 2001 From: alexchexes Date: Mon, 14 Apr 2025 16:02:47 +0100 Subject: [PATCH 2/2] test: ensure exported alias unions preserve definitions and correct additionalProperties --- test/valid-data-type.test.ts | 1 + .../type-mapped-exported-aliases/main.ts | 19 ++++++ .../type-mapped-exported-aliases/schema.json | 65 +++++++++++++++++++ 3 files changed, 85 insertions(+) create mode 100644 test/valid-data/type-mapped-exported-aliases/main.ts create mode 100644 test/valid-data/type-mapped-exported-aliases/schema.json diff --git a/test/valid-data-type.test.ts b/test/valid-data-type.test.ts index 06878380d..9a3d882b3 100644 --- a/test/valid-data-type.test.ts +++ b/test/valid-data-type.test.ts @@ -89,6 +89,7 @@ describe("valid-data-type", () => { it("type-keyof-object", assertValidSchema("type-keyof-object", "MyType")); it("type-keyof-object-function", assertValidSchema("type-keyof-object-function", "MyType")); it("type-mapped-pick-union-alias", assertValidSchema("type-mapped-pick-union-alias", "PickAliasedLiteralUnion")); + it("type-mapped-exported-aliases", assertValidSchema("type-mapped-exported-aliases", "*")); it("type-mapped-simple", assertValidSchema("type-mapped-simple", "MyObject")); it("type-mapped-index", assertValidSchema("type-mapped-index", "MyObject")); it("type-mapped-index-as", assertValidSchema("type-mapped-index-as", "MyObject")); diff --git a/test/valid-data/type-mapped-exported-aliases/main.ts b/test/valid-data/type-mapped-exported-aliases/main.ts new file mode 100644 index 000000000..26b5099eb --- /dev/null +++ b/test/valid-data/type-mapped-exported-aliases/main.ts @@ -0,0 +1,19 @@ +interface SomeInterface { + a: number; + b: string; + c: boolean; + d: string[]; + e: null; +} + +type A = "a"; +type B = "b"; +type C = "c"; +type D = "d"; + +// Export the aliases individually to verify they're handled correctly +export type AB = A | B; +export type ABC = AB | C; +export type ABCD = ABC | D; + +export type ABCDE = Pick; diff --git a/test/valid-data/type-mapped-exported-aliases/schema.json b/test/valid-data/type-mapped-exported-aliases/schema.json new file mode 100644 index 000000000..a998f8653 --- /dev/null +++ b/test/valid-data/type-mapped-exported-aliases/schema.json @@ -0,0 +1,65 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "AB": { + "enum": [ + "a", + "b" + ], + "type": "string" + }, + "ABC": { + "anyOf": [ + { + "$ref": "#/definitions/AB" + }, + { + "const": "c", + "type": "string" + } + ] + }, + "ABCD": { + "anyOf": [ + { + "$ref": "#/definitions/ABC" + }, + { + "const": "d", + "type": "string" + } + ] + }, + "ABCDE": { + "additionalProperties": false, + "properties": { + "a": { + "type": "number" + }, + "b": { + "type": "string" + }, + "c": { + "type": "boolean" + }, + "d": { + "items": { + "type": "string" + }, + "type": "array" + }, + "e": { + "type": "null" + } + }, + "required": [ + "a", + "b", + "c", + "d", + "e" + ], + "type": "object" + } + } +}