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),
+ });
+ }
+ },
+ },
+ };
+ },
+});