diff --git a/src/compiler/diagnosticMessages.json b/src/compiler/diagnosticMessages.json index 59007e610b4ad..be96d3de07dd8 100644 --- a/src/compiler/diagnosticMessages.json +++ b/src/compiler/diagnosticMessages.json @@ -4280,5 +4280,9 @@ "Remove all unused labels": { "category": "Message", "code": 95054 + }, + "Convert '{0}' to mapped object type": { + "category": "Message", + "code": 95055 } } diff --git a/src/harness/tsconfig.json b/src/harness/tsconfig.json index 359701a02cb3a..08b8acadc9374 100644 --- a/src/harness/tsconfig.json +++ b/src/harness/tsconfig.json @@ -118,6 +118,7 @@ "../services/codefixes/requireInTs.ts", "../services/codefixes/useDefaultImport.ts", "../services/codefixes/fixAddModuleReferTypeMissingTypeof.ts", + "../services/codefixes/convertToMappedObjectType.ts", "../services/refactors/extractSymbol.ts", "../services/refactors/generateGetAccessorAndSetAccessor.ts", "../services/refactors/moveToNewFile.ts", diff --git a/src/server/tsconfig.json b/src/server/tsconfig.json index 8ae6974baf062..806721c9c56f3 100644 --- a/src/server/tsconfig.json +++ b/src/server/tsconfig.json @@ -114,6 +114,7 @@ "../services/codefixes/requireInTs.ts", "../services/codefixes/useDefaultImport.ts", "../services/codefixes/fixAddModuleReferTypeMissingTypeof.ts", + "../services/codefixes/convertToMappedObjectType.ts", "../services/refactors/extractSymbol.ts", "../services/refactors/generateGetAccessorAndSetAccessor.ts", "../services/refactors/moveToNewFile.ts", diff --git a/src/server/tsconfig.library.json b/src/server/tsconfig.library.json index 922af11e87963..cabcd8ff104ed 100644 --- a/src/server/tsconfig.library.json +++ b/src/server/tsconfig.library.json @@ -120,6 +120,7 @@ "../services/codefixes/requireInTs.ts", "../services/codefixes/useDefaultImport.ts", "../services/codefixes/fixAddModuleReferTypeMissingTypeof.ts", + "../services/codefixes/convertToMappedObjectType.ts", "../services/refactors/extractSymbol.ts", "../services/refactors/generateGetAccessorAndSetAccessor.ts", "../services/refactors/moveToNewFile.ts", diff --git a/src/services/codefixes/convertToMappedObjectType.ts b/src/services/codefixes/convertToMappedObjectType.ts new file mode 100644 index 0000000000000..b8315ce6d96da --- /dev/null +++ b/src/services/codefixes/convertToMappedObjectType.ts @@ -0,0 +1,94 @@ +/* @internal */ +namespace ts.codefix { + const fixIdAddMissingTypeof = "fixConvertToMappedObjectType"; + const fixId = fixIdAddMissingTypeof; + const errorCodes = [Diagnostics.An_index_signature_parameter_type_cannot_be_a_union_type_Consider_using_a_mapped_object_type_instead.code]; + + type FixableDeclaration = InterfaceDeclaration | TypeAliasDeclaration; + + interface Info { + indexSignature: IndexSignatureDeclaration; + container: FixableDeclaration; + otherMembers: ReadonlyArray; + parameterName: Identifier; + parameterType: TypeNode; + } + + registerCodeFix({ + errorCodes, + getCodeActions: context => { + const { sourceFile, span } = context; + const info = getFixableSignatureAtPosition(sourceFile, span.start); + if (!info) return; + const { indexSignature, container, otherMembers, parameterName, parameterType } = info; + + const changes = textChanges.ChangeTracker.with(context, t => doChange(t, sourceFile, indexSignature, container, otherMembers, parameterName, parameterType)); + return [createCodeFixAction(fixId, changes, [Diagnostics.Convert_0_to_mapped_object_type, idText(container.name)], fixId, [Diagnostics.Convert_0_to_mapped_object_type, idText(container.name)])]; + }, + fixIds: [fixId], + getAllCodeActions: context => codeFixAll(context, errorCodes, (changes, diag) => { + const info = getFixableSignatureAtPosition(diag.file, diag.start); + if (!info) return; + const { indexSignature, container, otherMembers, parameterName, parameterType } = info; + + doChange(changes, context.sourceFile, indexSignature, container, otherMembers, parameterName, parameterType); + }) + }); + + function isFixableParameterName(node: Node): boolean { + return node && node.parent && node.parent.parent && node.parent.parent.parent && !isClassDeclaration(node.parent.parent.parent); + } + + function getFixableSignatureAtPosition(sourceFile: SourceFile, pos: number): Info | undefined { + const token = getTokenAtPosition(sourceFile, pos, /*includeJsDocComment*/ false); + if (!isFixableParameterName(token)) return undefined; + + const indexSignature = token.parent.parent; + const container = isInterfaceDeclaration(indexSignature.parent) ? indexSignature.parent : indexSignature.parent.parent; + const members = isInterfaceDeclaration(container) ? container.members : (container.type).members; + const otherMembers = filter(members, member => !isIndexSignatureDeclaration(member)); + const parameter = first(indexSignature.parameters); + + return { + indexSignature, + container, + otherMembers, + parameterName: parameter.name, + parameterType: parameter.type! + }; + } + + function getInterfaceHeritageClauses(declaration: FixableDeclaration): NodeArray | undefined { + if (!isInterfaceDeclaration(declaration)) return undefined; + + const heritageClause = getHeritageClause(declaration.heritageClauses, SyntaxKind.ExtendsKeyword); + return heritageClause && heritageClause.types; + } + + function createTypeAliasFromInterface(indexSignature: IndexSignatureDeclaration, declaration: FixableDeclaration, otherMembers: ReadonlyArray, parameterName: Identifier, parameterType: TypeNode) { + const heritageClauses = getInterfaceHeritageClauses(declaration); + const mappedTypeParameter = createTypeParameterDeclaration(parameterName, parameterType); + const mappedIntersectionType = createMappedTypeNode( + hasReadonlyModifier(indexSignature) ? createModifier(SyntaxKind.ReadonlyKeyword) : undefined, + mappedTypeParameter, + indexSignature.questionToken, + indexSignature.type); + + return createTypeAliasDeclaration( + declaration.decorators, + declaration.modifiers, + declaration.name, + declaration.typeParameters, + createIntersectionTypeNode( + concatenate( + heritageClauses, + append([mappedIntersectionType], otherMembers.length ? createTypeLiteralNode(otherMembers) : undefined) + ) + ) + ); + } + + function doChange(changes: textChanges.ChangeTracker, sourceFile: SourceFile, indexSignature: IndexSignatureDeclaration, declaration: FixableDeclaration, otherMembers: ReadonlyArray, parameterName: Identifier, parameterType: TypeNode) { + changes.replaceNode(sourceFile, declaration, createTypeAliasFromInterface(indexSignature, declaration, otherMembers, parameterName, parameterType)); + } +} diff --git a/src/services/refactors/generateGetAccessorAndSetAccessor.ts b/src/services/refactors/generateGetAccessorAndSetAccessor.ts index 6b49b84d4de3d..07104f8e6204c 100644 --- a/src/services/refactors/generateGetAccessorAndSetAccessor.ts +++ b/src/services/refactors/generateGetAccessorAndSetAccessor.ts @@ -88,7 +88,7 @@ namespace ts.refactor.generateGetAccessorAndSetAccessor { return { renameFilename, renameLocation, edits }; } - function isConvertableName (name: DeclarationName): name is AcceptedNameType { + function isConvertibleName (name: DeclarationName): name is AcceptedNameType { return isIdentifier(name) || isStringLiteral(name); } @@ -125,7 +125,7 @@ namespace ts.refactor.generateGetAccessorAndSetAccessor { // make sure declaration have AccessibilityModifier or Static Modifier or Readonly Modifier const meaning = ModifierFlags.AccessibilityModifier | ModifierFlags.Static | ModifierFlags.Readonly; if (!declaration || !rangeOverlapsWithStartEnd(declaration.name, startPosition, endPosition!) // TODO: GH#18217 - || !isConvertableName(declaration.name) || (getModifierFlags(declaration) | meaning) !== meaning) return undefined; + || !isConvertibleName(declaration.name) || (getModifierFlags(declaration) | meaning) !== meaning) return undefined; const name = declaration.name.text; const startWithUnderscore = startsWithUnderscore(name); diff --git a/src/services/tsconfig.json b/src/services/tsconfig.json index 7e1ccc9c3afc0..960e46fb367a1 100644 --- a/src/services/tsconfig.json +++ b/src/services/tsconfig.json @@ -111,6 +111,7 @@ "codefixes/requireInTs.ts", "codefixes/useDefaultImport.ts", "codefixes/fixAddModuleReferTypeMissingTypeof.ts", + "codefixes/convertToMappedObjectType.ts", "refactors/extractSymbol.ts", "refactors/generateGetAccessorAndSetAccessor.ts", "refactors/moveToNewFile.ts", diff --git a/tests/cases/fourslash/codeFixConvertToMappedObjectType1.ts b/tests/cases/fourslash/codeFixConvertToMappedObjectType1.ts new file mode 100644 index 0000000000000..f00702e7bdc04 --- /dev/null +++ b/tests/cases/fourslash/codeFixConvertToMappedObjectType1.ts @@ -0,0 +1,17 @@ +/// + +//// type K = "foo" | "bar"; +//// interface SomeType { +//// a: string; +//// [prop: K]: any; +//// } + +verify.codeFix({ + description: `Convert 'SomeType' to mapped object type`, + newFileContent: `type K = "foo" | "bar"; +type SomeType = { + [prop in K]: any; +} & { + a: string; +};` +}) diff --git a/tests/cases/fourslash/codeFixConvertToMappedObjectType10.ts b/tests/cases/fourslash/codeFixConvertToMappedObjectType10.ts new file mode 100644 index 0000000000000..073b20d91608f --- /dev/null +++ b/tests/cases/fourslash/codeFixConvertToMappedObjectType10.ts @@ -0,0 +1,23 @@ +/// + +//// type K = "foo" | "bar"; +//// interface Foo { } +//// interface Bar { bar: T; } +//// interface SomeType extends Foo, Bar { +//// a: number; +//// b: T; +//// [prop: K]: any; +//// } + +verify.codeFix({ + description: `Convert 'SomeType' to mapped object type`, + newFileContent: `type K = "foo" | "bar"; +interface Foo { } +interface Bar { bar: T; } +type SomeType = Foo & Bar & { + [prop in K]: any; +} & { + a: number; + b: T; +};` +}) diff --git a/tests/cases/fourslash/codeFixConvertToMappedObjectType11.ts b/tests/cases/fourslash/codeFixConvertToMappedObjectType11.ts new file mode 100644 index 0000000000000..b82ee1ac179e7 --- /dev/null +++ b/tests/cases/fourslash/codeFixConvertToMappedObjectType11.ts @@ -0,0 +1,23 @@ +/// + +//// type K = "foo" | "bar"; +//// interface Foo { } +//// interface Bar { bar: T; } +//// interface SomeType extends Foo, Bar { +//// a: number; +//// b: T; +//// readonly [prop: K]: any; +//// } + +verify.codeFix({ + description: `Convert 'SomeType' to mapped object type`, + newFileContent: `type K = "foo" | "bar"; +interface Foo { } +interface Bar { bar: T; } +type SomeType = Foo & Bar & { + readonly [prop in K]: any; +} & { + a: number; + b: T; +};` +}) diff --git a/tests/cases/fourslash/codeFixConvertToMappedObjectType12.ts b/tests/cases/fourslash/codeFixConvertToMappedObjectType12.ts new file mode 100644 index 0000000000000..6374dc43dacb4 --- /dev/null +++ b/tests/cases/fourslash/codeFixConvertToMappedObjectType12.ts @@ -0,0 +1,12 @@ +/// + +//// type K = "foo" | "bar"; +//// interface Foo { } +//// interface Bar { bar: T; } +//// interface SomeType extends Foo, Bar { +//// a: number; +//// b: T; +//// readonly [prop: K]?: any; +//// } + +verify.not.codeFixAvailable() diff --git a/tests/cases/fourslash/codeFixConvertToMappedObjectType2.ts b/tests/cases/fourslash/codeFixConvertToMappedObjectType2.ts new file mode 100644 index 0000000000000..27dfc7cab0d56 --- /dev/null +++ b/tests/cases/fourslash/codeFixConvertToMappedObjectType2.ts @@ -0,0 +1,17 @@ +/// + +//// type K = "foo" | "bar"; +//// type SomeType = { +//// a: string; +//// [prop: K]: any; +//// } + +verify.codeFix({ + description: `Convert 'SomeType' to mapped object type`, + newFileContent: `type K = "foo" | "bar"; +type SomeType = { + [prop in K]: any; +} & { + a: string; +};` +}) diff --git a/tests/cases/fourslash/codeFixConvertToMappedObjectType3.ts b/tests/cases/fourslash/codeFixConvertToMappedObjectType3.ts new file mode 100644 index 0000000000000..b916eaca9b62f --- /dev/null +++ b/tests/cases/fourslash/codeFixConvertToMappedObjectType3.ts @@ -0,0 +1,14 @@ +/// + +//// type K = "foo" | "bar"; +//// type SomeType = { +//// [prop: K]: any; +//// } + +verify.codeFix({ + description: `Convert 'SomeType' to mapped object type`, + newFileContent: `type K = "foo" | "bar"; +type SomeType = { + [prop in K]: any; +};` +}) diff --git a/tests/cases/fourslash/codeFixConvertToMappedObjectType4.ts b/tests/cases/fourslash/codeFixConvertToMappedObjectType4.ts new file mode 100644 index 0000000000000..4077a7c04af28 --- /dev/null +++ b/tests/cases/fourslash/codeFixConvertToMappedObjectType4.ts @@ -0,0 +1,14 @@ +/// + +//// type K = "foo" | "bar"; +//// interface SomeType { +//// [prop: K]: any; +//// } + +verify.codeFix({ + description: `Convert 'SomeType' to mapped object type`, + newFileContent: `type K = "foo" | "bar"; +type SomeType = { + [prop in K]: any; +};` +}) diff --git a/tests/cases/fourslash/codeFixConvertToMappedObjectType5.ts b/tests/cases/fourslash/codeFixConvertToMappedObjectType5.ts new file mode 100644 index 0000000000000..869974f9ad2ff --- /dev/null +++ b/tests/cases/fourslash/codeFixConvertToMappedObjectType5.ts @@ -0,0 +1,8 @@ +/// + +//// type K = "foo" | "bar"; +//// class SomeType { +//// [prop: K]: any; +//// } + +verify.not.codeFixAvailable() diff --git a/tests/cases/fourslash/codeFixConvertToMappedObjectType6.ts b/tests/cases/fourslash/codeFixConvertToMappedObjectType6.ts new file mode 100644 index 0000000000000..b00f356b48b45 --- /dev/null +++ b/tests/cases/fourslash/codeFixConvertToMappedObjectType6.ts @@ -0,0 +1,16 @@ +/// + +//// type K = "foo" | "bar"; +//// interface Foo { } +//// interface SomeType extends Foo { +//// [prop: K]: any; +//// } + +verify.codeFix({ + description: `Convert 'SomeType' to mapped object type`, + newFileContent: `type K = "foo" | "bar"; +interface Foo { } +type SomeType = Foo & { + [prop in K]: any; +};` +}) diff --git a/tests/cases/fourslash/codeFixConvertToMappedObjectType7.ts b/tests/cases/fourslash/codeFixConvertToMappedObjectType7.ts new file mode 100644 index 0000000000000..a0a1e4eeccb69 --- /dev/null +++ b/tests/cases/fourslash/codeFixConvertToMappedObjectType7.ts @@ -0,0 +1,18 @@ +/// + +//// type K = "foo" | "bar"; +//// interface Foo { } +//// interface Bar { } +//// interface SomeType extends Foo, Bar { +//// [prop: K]: any; +//// } + +verify.codeFix({ + description: `Convert 'SomeType' to mapped object type`, + newFileContent: `type K = "foo" | "bar"; +interface Foo { } +interface Bar { } +type SomeType = Foo & Bar & { + [prop in K]: any; +};` +}) diff --git a/tests/cases/fourslash/codeFixConvertToMappedObjectType8.ts b/tests/cases/fourslash/codeFixConvertToMappedObjectType8.ts new file mode 100644 index 0000000000000..6ee6c9f0aadde --- /dev/null +++ b/tests/cases/fourslash/codeFixConvertToMappedObjectType8.ts @@ -0,0 +1,21 @@ +/// + +//// type K = "foo" | "bar"; +//// interface Foo { } +//// interface Bar { } +//// interface SomeType extends Foo, Bar { +//// a: number; +//// [prop: K]: any; +//// } + +verify.codeFix({ + description: `Convert 'SomeType' to mapped object type`, + newFileContent: `type K = "foo" | "bar"; +interface Foo { } +interface Bar { } +type SomeType = Foo & Bar & { + [prop in K]: any; +} & { + a: number; +};` +}) diff --git a/tests/cases/fourslash/codeFixConvertToMappedObjectType9.ts b/tests/cases/fourslash/codeFixConvertToMappedObjectType9.ts new file mode 100644 index 0000000000000..095296481f57d --- /dev/null +++ b/tests/cases/fourslash/codeFixConvertToMappedObjectType9.ts @@ -0,0 +1,21 @@ +/// + +//// type K = "foo" | "bar"; +//// interface Foo { } +//// interface Bar { bar: T; } +//// interface SomeType extends Foo, Bar { +//// a: number; +//// [prop: K]: any; +//// } + +verify.codeFix({ + description: `Convert 'SomeType' to mapped object type`, + newFileContent: `type K = "foo" | "bar"; +interface Foo { } +interface Bar { bar: T; } +type SomeType = Foo & Bar & { + [prop in K]: any; +} & { + a: number; +};` +})