diff --git a/src/compiler/diagnosticMessages.json b/src/compiler/diagnosticMessages.json
index 02620ab7620b1..f582f36df63ac 100644
--- a/src/compiler/diagnosticMessages.json
+++ b/src/compiler/diagnosticMessages.json
@@ -6694,7 +6694,15 @@
"category": "Suggestion",
"code": 80008
},
-
+ "JSDoc typedef may be converted to TypeScript type.": {
+ "category": "Suggestion",
+ "code": 80009
+ },
+ "JSDoc typedefs may be converted to TypeScript types.": {
+ "category": "Suggestion",
+ "code": 80010
+ },
+
"Add missing 'super()' call": {
"category": "Message",
"code": 90001
@@ -7552,6 +7560,14 @@
"category": "Message",
"code": 95175
},
+ "Convert typedef to TypeScript type.": {
+ "category": "Message",
+ "code": 95176
+ },
+ "Convert all typedef to TypeScript types.": {
+ "category": "Message",
+ "code": 95177
+ },
"No value exists in scope for the shorthand property '{0}'. Either declare one or provide an initializer.": {
"category": "Error",
diff --git a/src/services/_namespaces/ts.codefix.ts b/src/services/_namespaces/ts.codefix.ts
index 5270a0c664e26..3bf0c18cb9e5a 100644
--- a/src/services/_namespaces/ts.codefix.ts
+++ b/src/services/_namespaces/ts.codefix.ts
@@ -17,6 +17,7 @@ export * from "../codefixes/convertToEsModule";
export * from "../codefixes/correctQualifiedNameToIndexedAccessType";
export * from "../codefixes/convertToTypeOnlyExport";
export * from "../codefixes/convertToTypeOnlyImport";
+export * from "../codefixes/convertTypedefToType";
export * from "../codefixes/convertLiteralTypeToMappedType";
export * from "../codefixes/fixClassIncorrectlyImplementsInterface";
export * from "../codefixes/importFixes";
diff --git a/src/services/codefixes/convertTypedefToType.ts b/src/services/codefixes/convertTypedefToType.ts
new file mode 100644
index 0000000000000..5cf04b1aaa82f
--- /dev/null
+++ b/src/services/codefixes/convertTypedefToType.ts
@@ -0,0 +1,178 @@
+import {
+ Diagnostics,
+ factory,
+ forEach,
+ getSynthesizedDeepClone,
+ getTokenAtPosition,
+ hasJSDocNodes,
+ InterfaceDeclaration,
+ isJSDocTypedefTag,
+ isJSDocTypeLiteral,
+ JSDocPropertyLikeTag,
+ JSDocTypedefTag,
+ JSDocTypeExpression,
+ JSDocTypeLiteral,
+ mapDefined,
+ Node,
+ PropertySignature,
+ some,
+ SourceFile,
+ SyntaxKind,
+ textChanges,
+ TypeAliasDeclaration,
+} from "../_namespaces/ts";
+import { codeFixAll, createCodeFixAction, registerCodeFix } from "../_namespaces/ts.codefix";
+
+const fixId = "convertTypedefToType";
+const errorCodes = [Diagnostics.JSDoc_typedef_may_be_converted_to_TypeScript_type.code];
+registerCodeFix({
+ fixIds: [fixId],
+ errorCodes,
+ getCodeActions(context) {
+ const node = getTokenAtPosition(
+ context.sourceFile,
+ context.span.start
+ );
+ if (!node) return;
+ const changes = textChanges.ChangeTracker.with(context, t => doChange(t, node, context.sourceFile));
+
+ if (changes.length > 0) {
+ return [
+ createCodeFixAction(
+ fixId,
+ changes,
+ Diagnostics.Convert_typedef_to_TypeScript_type,
+ fixId,
+ Diagnostics.Convert_all_typedef_to_TypeScript_types,
+ ),
+ ];
+ }
+ },
+ getAllCodeActions: context => codeFixAll(context, errorCodes, (changes, diag) => {
+ const node = getTokenAtPosition(diag.file, diag.start);
+ if (node) doChange(changes, node, diag.file);
+ })
+});
+
+function doChange(changes: textChanges.ChangeTracker, node: Node, sourceFile: SourceFile) {
+ if (isJSDocTypedefTag(node)) {
+ fixSingleTypeDef(changes, node, sourceFile);
+ }
+}
+
+function fixSingleTypeDef(
+ changes: textChanges.ChangeTracker,
+ typeDefNode: JSDocTypedefTag | undefined,
+ sourceFile: SourceFile,
+) {
+ if (!typeDefNode) return;
+
+ const declaration = createDeclaration(typeDefNode);
+ if (!declaration) return;
+
+ const comment = typeDefNode.parent;
+
+ changes.replaceNode(
+ sourceFile,
+ comment,
+ declaration
+ );
+}
+
+function createDeclaration(tag: JSDocTypedefTag): InterfaceDeclaration | TypeAliasDeclaration | undefined {
+ const { typeExpression } = tag;
+ if (!typeExpression) return;
+ const typeName = tag.name?.getText();
+ if (!typeName) return;
+
+ // For use case @typedef {object}Foo @property{bar}number
+ // But object type can be nested, meaning the value in the k/v pair can be object itself
+ if (typeExpression.kind === SyntaxKind.JSDocTypeLiteral) {
+ return createInterfaceForTypeLiteral(typeName, typeExpression);
+ }
+ // for use case @typedef {(number|string|undefined)} Foo or @typedef {number|string|undefined} Foo
+ // In this case, we reach the leaf node of AST.
+ if (typeExpression.kind === SyntaxKind.JSDocTypeExpression) {
+ return createTypeAliasForTypeExpression(typeName, typeExpression);
+ }
+}
+
+function createInterfaceForTypeLiteral(
+ typeName: string,
+ typeLiteral: JSDocTypeLiteral
+): InterfaceDeclaration | undefined {
+ const propertySignatures = createSignatureFromTypeLiteral(typeLiteral);
+ if (!some(propertySignatures)) return;
+ const interfaceDeclaration = factory.createInterfaceDeclaration(
+ /*modifiers*/ undefined,
+ typeName,
+ /*typeParameters*/ undefined,
+ /*heritageClauses*/ undefined,
+ propertySignatures,
+ );
+ return interfaceDeclaration;
+}
+
+function createTypeAliasForTypeExpression(
+ typeName: string,
+ typeExpression: JSDocTypeExpression
+): TypeAliasDeclaration | undefined {
+ const typeReference = getSynthesizedDeepClone(typeExpression.type);
+ if (!typeReference) return;
+ const declaration = factory.createTypeAliasDeclaration(
+ /*modifiers*/ undefined,
+ factory.createIdentifier(typeName),
+ /*typeParameters*/ undefined,
+ typeReference
+ );
+ return declaration;
+}
+
+function createSignatureFromTypeLiteral(typeLiteral: JSDocTypeLiteral): PropertySignature[] | undefined {
+ const propertyTags = typeLiteral.jsDocPropertyTags;
+ if (!some(propertyTags)) return;
+
+ const getSignature = (tag: JSDocPropertyLikeTag) => {
+ const name = getPropertyName(tag);
+ const type = tag.typeExpression?.type;
+ const isOptional = tag.isBracketed;
+ let typeReference;
+
+ // Recursively handle nested object type
+ if (type && isJSDocTypeLiteral(type)) {
+ const signatures = createSignatureFromTypeLiteral(type);
+ typeReference = factory.createTypeLiteralNode(signatures);
+ }
+ // Leaf node, where type.kind === SyntaxKind.JSDocTypeExpression
+ else if (type) {
+ typeReference = getSynthesizedDeepClone(type);
+ }
+
+ if (typeReference && name) {
+ const questionToken = isOptional ? factory.createToken(SyntaxKind.QuestionToken) : undefined;
+ const prop = factory.createPropertySignature(
+ /*modifiers*/ undefined,
+ name,
+ questionToken,
+ typeReference
+ );
+
+ return prop;
+ }
+ };
+
+ const props = mapDefined(propertyTags, getSignature);
+ return props;
+}
+
+function getPropertyName(tag: JSDocPropertyLikeTag): string | undefined {
+ return tag.name.kind === SyntaxKind.Identifier ? tag.name.text : tag.name.right.text;
+}
+
+/** @internal */
+export function getJSDocTypedefNode(node: Node): JSDocTypedefTag | undefined {
+ if (hasJSDocNodes(node)) {
+ return forEach(node.jsDoc, (node) => node.tags?.find(isJSDocTypedefTag));
+ }
+ return undefined;
+}
diff --git a/src/services/suggestionDiagnostics.ts b/src/services/suggestionDiagnostics.ts
index 87cf371041f37..3b277ed4f5e91 100644
--- a/src/services/suggestionDiagnostics.ts
+++ b/src/services/suggestionDiagnostics.ts
@@ -117,6 +117,11 @@ export function computeSuggestionDiagnostics(sourceFile: SourceFile, program: Pr
}
}
+ const jsdocTypedefNode = codefix.getJSDocTypedefNode(node);
+ if (jsdocTypedefNode) {
+ diags.push(createDiagnosticForNode(jsdocTypedefNode, Diagnostics.JSDoc_typedef_may_be_converted_to_TypeScript_type));
+ }
+
if (codefix.parameterShouldGetTypeFromJSDoc(node)) {
diags.push(createDiagnosticForNode(node.name || node, Diagnostics.JSDoc_types_may_be_moved_to_TypeScript_types));
}
diff --git a/tests/baselines/reference/tsserver/plugins/getSupportedCodeFixes-can-be-proxied.js b/tests/baselines/reference/tsserver/plugins/getSupportedCodeFixes-can-be-proxied.js
index 7382a77537341..fae6f950a030e 100644
--- a/tests/baselines/reference/tsserver/plugins/getSupportedCodeFixes-can-be-proxied.js
+++ b/tests/baselines/reference/tsserver/plugins/getSupportedCodeFixes-can-be-proxied.js
@@ -309,6 +309,7 @@ Info 32 [00:01:13.000] response:
"2713",
"1205",
"1371",
+ "80009",
"2690",
"2420",
"2720",
diff --git a/tests/cases/fourslash/codeFixConvertTypedefToType1.ts b/tests/cases/fourslash/codeFixConvertTypedefToType1.ts
new file mode 100644
index 0000000000000..453986a6b882f
--- /dev/null
+++ b/tests/cases/fourslash/codeFixConvertTypedefToType1.ts
@@ -0,0 +1,18 @@
+///
+
+////
+//// /**
+//// * @typedef {Object}Foo
+//// * @property {number}bar
+//// */
+////
+
+verify.codeFix({
+ description: ts.Diagnostics.Convert_typedef_to_TypeScript_type.message,
+ index: 0,
+ newFileContent: `
+interface Foo {
+ bar: number;
+}
+`,
+});
\ No newline at end of file
diff --git a/tests/cases/fourslash/codeFixConvertTypedefToType2.ts b/tests/cases/fourslash/codeFixConvertTypedefToType2.ts
new file mode 100644
index 0000000000000..e0746b69052de
--- /dev/null
+++ b/tests/cases/fourslash/codeFixConvertTypedefToType2.ts
@@ -0,0 +1,15 @@
+///
+
+////
+//// /**
+//// * @typedef {(number|string|undefined)} Foo
+//// */
+////
+
+verify.codeFix({
+ description: ts.Diagnostics.Convert_typedef_to_TypeScript_type.message,
+ index: 0,
+ newFileContent: `
+type Foo = (number | string | undefined);
+`,
+});
\ No newline at end of file
diff --git a/tests/cases/fourslash/codeFixConvertTypedefToType3.ts b/tests/cases/fourslash/codeFixConvertTypedefToType3.ts
new file mode 100644
index 0000000000000..cbab2306424bf
--- /dev/null
+++ b/tests/cases/fourslash/codeFixConvertTypedefToType3.ts
@@ -0,0 +1,23 @@
+///
+
+////
+//// /**
+//// * @typedef Foo
+//// * type {object}
+//// * @property {string} id - person's ID
+//// * @property name {string} // person's name
+//// * @property {number|undefined} age - person's age
+//// */
+////
+
+verify.codeFix({
+ description: ts.Diagnostics.Convert_typedef_to_TypeScript_type.message,
+ index: 0,
+ newFileContent: `
+interface Foo {
+ id: string;
+ name: string;
+ age: number | undefined;
+}
+`,
+});
\ No newline at end of file
diff --git a/tests/cases/fourslash/codeFixConvertTypedefToType4.ts b/tests/cases/fourslash/codeFixConvertTypedefToType4.ts
new file mode 100644
index 0000000000000..a84c1f9fda9dd
--- /dev/null
+++ b/tests/cases/fourslash/codeFixConvertTypedefToType4.ts
@@ -0,0 +1,30 @@
+///
+
+////
+//// /**
+//// * @typedef {object} Person
+//// * @property {object} data
+//// * @property {string} data.name
+//// * @property {number} data.age
+//// * @property {object} data.contact
+//// * @property {string} data.contact.address
+//// * @property {string} [data.contact.phone]
+//// */
+////
+
+verify.codeFix({
+ description: ts.Diagnostics.Convert_typedef_to_TypeScript_type.message,
+ index: 0,
+ newFileContent: `
+interface Person {
+ data: {
+ name: string;
+ age: number;
+ contact: {
+ address: string;
+ phone?: string;
+ };
+ };
+}
+`,
+});
\ No newline at end of file
diff --git a/tests/cases/fourslash/codeFixConvertTypedefToType5.ts b/tests/cases/fourslash/codeFixConvertTypedefToType5.ts
new file mode 100644
index 0000000000000..08d68d9aef729
--- /dev/null
+++ b/tests/cases/fourslash/codeFixConvertTypedefToType5.ts
@@ -0,0 +1,15 @@
+///
+
+////
+//// /**
+//// * @typedef {number} Foo
+//// */
+////
+
+verify.codeFix({
+ description: ts.Diagnostics.Convert_typedef_to_TypeScript_type.message,
+ index: 0,
+ newFileContent: `
+type Foo = number;
+`,
+});
\ No newline at end of file