From 9527aa5801643ad7389b41cd28940b06cc1fc906 Mon Sep 17 00:00:00 2001 From: Yuta Saito Date: Wed, 18 Feb 2026 20:50:24 +0900 Subject: [PATCH 1/2] TS2Swift: emit enums for string literal unions --- .../TS2Swift/JavaScript/src/processor.js | 92 +++++++++++++++++++ .../test/__snapshots__/ts2swift.test.js.snap | 27 ++++++ .../test/fixtures/StringLiteralUnion.d.ts | 3 + 3 files changed, 122 insertions(+) create mode 100644 Plugins/BridgeJS/Sources/TS2Swift/JavaScript/test/fixtures/StringLiteralUnion.d.ts diff --git a/Plugins/BridgeJS/Sources/TS2Swift/JavaScript/src/processor.js b/Plugins/BridgeJS/Sources/TS2Swift/JavaScript/src/processor.js index 53216a78..fd487375 100644 --- a/Plugins/BridgeJS/Sources/TS2Swift/JavaScript/src/processor.js +++ b/Plugins/BridgeJS/Sources/TS2Swift/JavaScript/src/processor.js @@ -54,6 +54,10 @@ export class TypeProcessor { this.emittedEnumNames = new Set(); /** @type {Set} */ this.emittedStructuredTypeNames = new Set(); + /** @type {Set} */ + this.emittedStringLiteralUnionNames = new Set(); + /** @type {Set} */ + this.emittedStringLiteralUnionNames = new Set(); /** @type {Set} */ this.visitedDeclarationKeys = new Set(); @@ -145,6 +149,11 @@ export class TypeProcessor { for (const [type, node] of this.seenTypes) { this.seenTypes.delete(type); + const stringLiteralUnion = this.getStringLiteralUnionLiterals(type); + if (stringLiteralUnion && stringLiteralUnion.length > 0) { + this.emitStringLiteralUnion(type, node); + continue; + } if (this.isEnumType(type)) { this.visitEnumType(type, node); continue; @@ -296,6 +305,73 @@ export class TypeProcessor { return (symbol.flags & ts.SymbolFlags.Enum) !== 0; } + dedupeSwiftEnumCaseNames(items) { + const seen = new Map(); + return items.map(item => { + const count = seen.get(item.name) ?? 0; + seen.set(item.name, count + 1); + if (count === 0) return item; + return { ...item, name: `${item.name}_${count + 1}` }; + }); + } + + /** + * Extract string literal values if the type is a union containing only string literals. + * Returns null when any member is not a string literal. + * @param {ts.Type} type + * @returns {string[] | null} + * @private + */ + getStringLiteralUnionLiterals(type) { + if ((type.flags & ts.TypeFlags.Union) === 0) return null; + /** @type {ts.UnionType} */ + // @ts-ignore + const unionType = type; + /** @type {string[]} */ + const literals = []; + const seen = new Set(); + for (const member of unionType.types) { + if ((member.flags & ts.TypeFlags.StringLiteral) === 0) { + return null; + } + // @ts-ignore value exists for string literal types + const value = String(member.value); + if (seen.has(value)) continue; + seen.add(value); + literals.push(value); + } + return literals; + } + + /** + * @param {ts.Type} type + * @param {ts.Node} diagnosticNode + * @private + */ + emitStringLiteralUnion(type, diagnosticNode) { + const typeName = this.deriveTypeName(type); + if (!typeName) return; + if (this.emittedStringLiteralUnionNames.has(typeName)) return; + this.emittedStringLiteralUnionNames.add(typeName); + + const literals = this.getStringLiteralUnionLiterals(type); + if (!literals || literals.length === 0) return; + + const swiftEnumName = this.renderTypeIdentifier(typeName); + /** @type {{ name: string, raw: string }[]} */ + const members = literals.map(raw => ({ name: makeValidSwiftIdentifier(String(raw), { emptyFallback: "_case" }), raw: String(raw) })); + const deduped = this.dedupeSwiftEnumCaseNames(members); + + this.emitDocComment(diagnosticNode, { indent: "" }); + this.swiftLines.push(`enum ${swiftEnumName}: String {`); + for (const { name, raw } of deduped) { + this.swiftLines.push(` case ${this.renderIdentifier(name)} = "${raw.replaceAll("\"", "\\\"")}"`); + } + this.swiftLines.push("}"); + this.swiftLines.push(`extension ${swiftEnumName}: _BridgedSwiftEnumNoPayload, _BridgedSwiftRawValueEnum {}`); + this.swiftLines.push(""); + } + /** * @param {ts.EnumDeclaration} node * @private @@ -841,6 +917,7 @@ export class TypeProcessor { * @returns {string} */ const convert = (type) => { + const originalType = type; // Handle nullable/undefined unions (e.g. T | null, T | undefined) const isUnionType = (type.flags & ts.TypeFlags.Union) !== 0; if (isUnionType) { @@ -863,6 +940,15 @@ export class TypeProcessor { } return `JSUndefinedOr<${wrapped}>`; } + + const stringLiteralUnion = this.getStringLiteralUnionLiterals(type); + if (stringLiteralUnion && stringLiteralUnion.length > 0) { + const typeName = this.deriveTypeName(originalType) ?? this.deriveTypeName(type); + if (typeName) { + this.seenTypes.set(originalType, node); + return this.renderTypeIdentifier(typeName); + } + } } /** @type {Record} */ @@ -892,6 +978,12 @@ export class TypeProcessor { return this.renderTypeIdentifier(typeName); } + const stringLiteralUnion = this.getStringLiteralUnionLiterals(type); + if (stringLiteralUnion && stringLiteralUnion.length > 0) { + this.seenTypes.set(type, node); + return this.renderTypeIdentifier(this.deriveTypeName(type) ?? this.checker.typeToString(type)); + } + if (this.checker.isTupleType(type) || type.getCallSignatures().length > 0) { return "JSObject"; } diff --git a/Plugins/BridgeJS/Sources/TS2Swift/JavaScript/test/__snapshots__/ts2swift.test.js.snap b/Plugins/BridgeJS/Sources/TS2Swift/JavaScript/test/__snapshots__/ts2swift.test.js.snap index 85d1da0d..a1354099 100644 --- a/Plugins/BridgeJS/Sources/TS2Swift/JavaScript/test/__snapshots__/ts2swift.test.js.snap +++ b/Plugins/BridgeJS/Sources/TS2Swift/JavaScript/test/__snapshots__/ts2swift.test.js.snap @@ -355,10 +355,37 @@ extension FeatureFlag: _BridgedSwiftEnumNoPayload, _BridgedSwiftRawValueEnum {} @JSFunction func takesFeatureFlag(_ flag: FeatureFlag) throws(JSException) -> Void +enum FeatureFlag: String { + case foo = "foo" + case bar = "bar" +} +extension FeatureFlag: _BridgedSwiftEnumNoPayload, _BridgedSwiftRawValueEnum {} + @JSFunction func returnsFeatureFlag() throws(JSException) -> FeatureFlag " `; +exports[`ts2swift > snapshots Swift output for StringLiteralUnion.d.ts > StringLiteralUnion 1`] = ` +"// NOTICE: This is auto-generated code by BridgeJS from JavaScriptKit, +// DO NOT EDIT. +// +// To update this file, just rebuild your project or run +// \`swift package bridge-js\`. + +@_spi(BridgeJS) import JavaScriptKit + +@JSFunction func move(_ direction: Direction) throws(JSException) -> Void + +enum Direction: String { + case up = "up" + case down = "down" + case left = "left" + case right = "right" +} +extension Direction: _BridgedSwiftEnumNoPayload, _BridgedSwiftRawValueEnum {} +" +`; + exports[`ts2swift > snapshots Swift output for StringParameter.d.ts > StringParameter 1`] = ` "// NOTICE: This is auto-generated code by BridgeJS from JavaScriptKit, // DO NOT EDIT. diff --git a/Plugins/BridgeJS/Sources/TS2Swift/JavaScript/test/fixtures/StringLiteralUnion.d.ts b/Plugins/BridgeJS/Sources/TS2Swift/JavaScript/test/fixtures/StringLiteralUnion.d.ts new file mode 100644 index 00000000..2c01a23f --- /dev/null +++ b/Plugins/BridgeJS/Sources/TS2Swift/JavaScript/test/fixtures/StringLiteralUnion.d.ts @@ -0,0 +1,3 @@ +export type Direction = "up" | "down" | "left" | "right"; + +export function move(direction: Direction): void; From 002ffd9e95e15b7e72b410fa4c3e38a22ce261fa Mon Sep 17 00:00:00 2001 From: Yuta Saito Date: Wed, 18 Feb 2026 20:57:15 +0900 Subject: [PATCH 2/2] TS2Swift: skip enum types when emitting string literal unions --- .../BridgeJS/Sources/TS2Swift/JavaScript/src/processor.js | 7 +++++-- .../JavaScript/test/__snapshots__/ts2swift.test.js.snap | 6 ------ 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/Plugins/BridgeJS/Sources/TS2Swift/JavaScript/src/processor.js b/Plugins/BridgeJS/Sources/TS2Swift/JavaScript/src/processor.js index fd487375..7d929486 100644 --- a/Plugins/BridgeJS/Sources/TS2Swift/JavaScript/src/processor.js +++ b/Plugins/BridgeJS/Sources/TS2Swift/JavaScript/src/processor.js @@ -56,8 +56,6 @@ export class TypeProcessor { this.emittedStructuredTypeNames = new Set(); /** @type {Set} */ this.emittedStringLiteralUnionNames = new Set(); - /** @type {Set} */ - this.emittedStringLiteralUnionNames = new Set(); /** @type {Set} */ this.visitedDeclarationKeys = new Set(); @@ -324,6 +322,11 @@ export class TypeProcessor { */ getStringLiteralUnionLiterals(type) { if ((type.flags & ts.TypeFlags.Union) === 0) return null; + const symbol = type.getSymbol() ?? type.aliasSymbol; + // Skip enums so we don't double-generate real enum declarations. + if (symbol && (symbol.flags & ts.SymbolFlags.Enum) !== 0) { + return null; + } /** @type {ts.UnionType} */ // @ts-ignore const unionType = type; diff --git a/Plugins/BridgeJS/Sources/TS2Swift/JavaScript/test/__snapshots__/ts2swift.test.js.snap b/Plugins/BridgeJS/Sources/TS2Swift/JavaScript/test/__snapshots__/ts2swift.test.js.snap index a1354099..9415728a 100644 --- a/Plugins/BridgeJS/Sources/TS2Swift/JavaScript/test/__snapshots__/ts2swift.test.js.snap +++ b/Plugins/BridgeJS/Sources/TS2Swift/JavaScript/test/__snapshots__/ts2swift.test.js.snap @@ -355,12 +355,6 @@ extension FeatureFlag: _BridgedSwiftEnumNoPayload, _BridgedSwiftRawValueEnum {} @JSFunction func takesFeatureFlag(_ flag: FeatureFlag) throws(JSException) -> Void -enum FeatureFlag: String { - case foo = "foo" - case bar = "bar" -} -extension FeatureFlag: _BridgedSwiftEnumNoPayload, _BridgedSwiftRawValueEnum {} - @JSFunction func returnsFeatureFlag() throws(JSException) -> FeatureFlag " `;