diff --git a/packages/comparisons/src/data.json b/packages/comparisons/src/data.json index 57fa601f6..3c1eff8d5 100644 --- a/packages/comparisons/src/data.json +++ b/packages/comparisons/src/data.json @@ -17357,7 +17357,8 @@ "flint": { "name": "typeExports", "plugin": "ts", - "preset": "stylistic" + "preset": "stylistic", + "status": "implemented" } }, { diff --git a/packages/site/src/content/docs/rules/ts/typeExports.mdx b/packages/site/src/content/docs/rules/ts/typeExports.mdx new file mode 100644 index 000000000..c07ab1042 --- /dev/null +++ b/packages/site/src/content/docs/rules/ts/typeExports.mdx @@ -0,0 +1,74 @@ +--- +description: "Reports exports that should use 'export type' syntax." +title: "typeExports" +topic: "rules" +--- + +import { Tabs, TabItem } from "@astrojs/starlight/components"; +import { RuleEquivalents } from "~/components/RuleEquivalents"; +import RuleSummary from "~/components/RuleSummary.astro"; + + + +Using `export type` for type-only exports improves tree-shaking and makes the intent explicit. +When exporting symbols that were imported using `import type`, use `export type` instead. + +## Examples + +### Type-Only Re-exports + + + + +```ts +import type { User } from "./types"; +export { User }; +``` + + + + +```ts +import type { User } from "./types"; +export type { User }; +``` + + + + +### Inline Type Imports + + + + +```ts +import { type Post } from "./types"; +export { Post }; +``` + + + + +```ts +import { type Post } from "./types"; +export type { Post }; +``` + + + + +## Options + +This rule is not configurable. + +## When Not To Use It + +If you prefer not to distinguish between type and value exports, you can disable this rule. + +## Further Reading + +- [TypeScript: Type-Only Imports and Exports](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-3-8.html#type-only-imports-and-export) + +## Equivalents in Other Linters + + diff --git a/packages/ts/src/plugin.ts b/packages/ts/src/plugin.ts index 0fd9c4c7e..9c95f0fc6 100644 --- a/packages/ts/src/plugin.ts +++ b/packages/ts/src/plugin.ts @@ -223,6 +223,7 @@ import sequences from "./rules/sequences.ts"; import shadowedRestrictedNames from "./rules/shadowedRestrictedNames.ts"; import sparseArrays from "./rules/sparseArrays.ts"; import symbolDescriptions from "./rules/symbolDescriptions.ts"; +import typeExports from "./rules/typeExports.ts"; import typeofComparisons from "./rules/typeofComparisons.ts"; import unassignedVariables from "./rules/unassignedVariables.ts"; import undefinedVariables from "./rules/undefinedVariables.ts"; @@ -465,6 +466,7 @@ export const ts = createPlugin({ shadowedRestrictedNames, sparseArrays, symbolDescriptions, + typeExports, typeofComparisons, unassignedVariables, undefinedVariables, diff --git a/packages/ts/src/rules/typeExports.test.ts b/packages/ts/src/rules/typeExports.test.ts new file mode 100644 index 000000000..1832ce759 --- /dev/null +++ b/packages/ts/src/rules/typeExports.test.ts @@ -0,0 +1,56 @@ +import rule from "./typeExports.ts"; +import { ruleTester } from "./ruleTester.ts"; + +ruleTester.describe(rule, { + invalid: [ + { + code: ` +import type { User } from "./types"; +export { User }; +`, + snapshot: ` +import type { User } from "./types"; +export { User }; +~~~~~~~~~~~~~~~~ +Use 'export type' for type-only exports. +`, + }, + { + code: ` +import type { User, Post } from "./types"; +export { User, Post }; +`, + snapshot: ` +import type { User, Post } from "./types"; +export { User, Post }; +~~~~~~~~~~~~~~~~~~~~~~ +Use 'export type' for type-only exports. +`, + }, + { + code: ` +import { type User } from "./types"; +export { User }; +`, + snapshot: ` +import { type User } from "./types"; +export { User }; +~~~~~~~~~~~~~~~~ +Use 'export type' for type-only exports. +`, + }, + ], + valid: [ + `import type { User } from "./types"; +export type { User };`, + `import { User } from "./types"; +export { User };`, + `import { value } from "./module"; +export { value };`, + `import type { User } from "./types"; +import { createUser } from "./module"; +export { createUser };`, + `export type { User } from "./types";`, + `export { value } from "./module";`, + ], +}); diff --git a/packages/ts/src/rules/typeExports.ts b/packages/ts/src/rules/typeExports.ts new file mode 100644 index 000000000..c1dc24222 --- /dev/null +++ b/packages/ts/src/rules/typeExports.ts @@ -0,0 +1,80 @@ +import { + getTSNodeRange, + typescriptLanguage, +} from "@flint.fyi/typescript-language"; +import ts from "typescript"; + +import { ruleCreator } from "./ruleCreator.ts"; + +export default ruleCreator.createRule(typescriptLanguage, { + about: { + description: "Reports exports that should use 'export type' syntax.", + id: "typeExports", + presets: ["stylistic"], + }, + messages: { + useExportType: { + primary: "Use 'export type' for type-only exports.", + secondary: [ + "This export only contains types, not runtime values.", + "Using 'export type' improves tree-shaking and makes intent clear.", + ], + suggestions: ["Change to 'export type { ... }'."], + }, + }, + setup(context) { + const typeOnlyImports = new Set(); + + return { + visitors: { + ImportDeclaration(node, { sourceFile }) { + if (node.importClause?.isTypeOnly) { + const namedBindings = node.importClause.namedBindings; + if (namedBindings && ts.isNamedImports(namedBindings)) { + for (const element of namedBindings.elements) { + typeOnlyImports.add(element.name.text); + } + } + if (node.importClause.name) { + typeOnlyImports.add(node.importClause.name.text); + } + } else if ( + node.importClause?.namedBindings && + ts.isNamedImports(node.importClause.namedBindings) + ) { + for (const element of node.importClause.namedBindings.elements) { + if (element.isTypeOnly) { + typeOnlyImports.add(element.name.text); + } + } + } + }, + ExportDeclaration(node, { sourceFile }) { + if (node.isTypeOnly) { + return; + } + + if (!node.exportClause || !ts.isNamedExports(node.exportClause)) { + return; + } + + const allTypeOnly = node.exportClause.elements.every((element) => { + if (element.isTypeOnly) { + return true; + } + const exportedName = + element.propertyName?.text ?? element.name.text; + return typeOnlyImports.has(exportedName); + }); + + if (allTypeOnly && node.exportClause.elements.length > 0) { + context.report({ + message: "useExportType", + range: getTSNodeRange(node, sourceFile), + }); + } + }, + }, + }; + }, +});