Repository: eslint-stylistic/eslint-stylistic
Pull request #797 (2025-05-26 16:17:50)

fix(keyword-spacing): check spaces around the `type` keyword in `export` declarations
Currently, the `keyword-spacing` rule only checks spacing around the `type` keyword in `import type` declarations.  
It does not handle `export type {}` or `export type *` syntax, resulting in inconsistent spacing behavior.

This PR adds support for spacing checks around the `type` keyword in `export` declarations.


Patch:
diff --git a/packages/eslint-plugin/rules/keyword-spacing/keyword-spacing._ts_.ts b/packages/eslint-plugin/rules/keyword-spacing/keyword-spacing._ts_.ts
index 11e52b60..d41f74f3 100644
--- a/packages/eslint-plugin/rules/keyword-spacing/keyword-spacing._ts_.ts
+++ b/packages/eslint-plugin/rules/keyword-spacing/keyword-spacing._ts_.ts
@@ -5,6 +5,7 @@ import { isKeywordToken, isNotOpeningParenToken, isTokenOnSameLine } from '#util
 import { createRule } from '#utils/create-rule'
 import { KEYWORDS_JS } from '#utils/keywords'
 import { AST_NODE_TYPES } from '@typescript-eslint/utils'
+import { isTypeKeyword } from '@typescript-eslint/utils/ast-utils'
 
 const PREV_TOKEN = /^[)\]}>]$/u
 const NEXT_TOKEN = /^(?:[([{<~!]|\+\+?|--?)$/u
@@ -57,8 +58,7 @@ export default createRule<RuleOptions, MessageIds>({
     },
   },
   defaultOptions: [{}],
-  create(context, [options]) {
-    const { after, overrides } = options ?? {}
+  create(context) {
     const sourceCode = context.sourceCode
 
     const tokensToIgnore = new WeakSet()
@@ -375,6 +375,48 @@ export default createRule<RuleOptions, MessageIds>({
         checkSpacingBefore(fromToken, PREV_TOKEN_M)
         checkSpacingAfter(fromToken, NEXT_TOKEN_M)
       }
+
+      // ExportDefaultDeclaration never have a `type` keyword
+      if (node.type !== 'ExportDefaultDeclaration')
+        checkSpacingForTypeKeywordInImportExport(node)
+    }
+
+    /**
+     * Report the `type` keyword in `import type`, `export type`, and `export type *` declaration nodes
+     * if there is invalid use of spaces around the `type` keyword.
+     * @param node A node to report.
+     */
+    function checkSpacingForTypeKeywordInImportExport(
+      node:
+        | Tree.ExportNamedDeclaration
+        | Tree.ExportAllDeclaration
+        | Tree.ImportDeclaration,
+    ) {
+      let kind: undefined | string
+
+      switch (node.type) {
+        case AST_NODE_TYPES.ImportDeclaration: {
+          kind = node.importKind
+          break
+        }
+
+        case AST_NODE_TYPES.ExportAllDeclaration:
+        case AST_NODE_TYPES.ExportNamedDeclaration: {
+          kind = node.exportKind
+          break
+        }
+      }
+
+      if (kind !== 'type')
+        return
+
+      const typeToken = sourceCode.getFirstToken(node, { skip: 1 })!
+
+      if (!isTypeKeyword(typeToken))
+        return
+
+      checkSpacingBefore(typeToken, PREV_TOKEN_M)
+      checkSpacingAfter(typeToken, NEXT_TOKEN_M)
     }
 
     /**
@@ -487,51 +529,7 @@ export default createRule<RuleOptions, MessageIds>({
       ExportDefaultDeclaration: checkSpacingForModuleDeclaration,
       ExportAllDeclaration: checkSpacingForModuleDeclaration,
       FunctionDeclaration: checkSpacingForFunction,
-      ImportDeclaration(node) {
-        checkSpacingForModuleDeclaration(node)
-
-        if (node.importKind === 'type') {
-          const { type: typeOptionOverride = {} } = overrides ?? {}
-          const typeToken = sourceCode.getFirstToken(node, { skip: 1 })!
-          const punctuatorToken = sourceCode.getTokenAfter(typeToken)!
-          if (
-            node.specifiers?.[0]?.type === AST_NODE_TYPES.ImportDefaultSpecifier
-          )
-            return
-
-          const spacesBetweenTypeAndPunctuator
-            = punctuatorToken.range[0] - typeToken.range[1]
-          if (
-            (typeOptionOverride.after ?? after) === true
-            && spacesBetweenTypeAndPunctuator === 0
-          ) {
-            context.report({
-              loc: typeToken.loc,
-              messageId: 'expectedAfter',
-              data: { value: 'type' },
-              fix(fixer) {
-                return fixer.insertTextAfter(typeToken, ' ')
-              },
-            })
-          }
-          if (
-            (typeOptionOverride.after ?? after) === false
-            && spacesBetweenTypeAndPunctuator > 0
-          ) {
-            context.report({
-              loc: typeToken.loc,
-              messageId: 'unexpectedAfter',
-              data: { value: 'type' },
-              fix(fixer) {
-                return fixer.removeRange([
-                  typeToken.range[1],
-                  typeToken.range[1] + spacesBetweenTypeAndPunctuator,
-                ])
-              },
-            })
-          }
-        }
-      },
+      ImportDeclaration: checkSpacingForModuleDeclaration,
       VariableDeclaration: checkSpacingAroundFirstToken,
 
       // Expressions
diff --git a/packages/eslint-plugin/rules/keyword-spacing/types._ts_.d.ts b/packages/eslint-plugin/rules/keyword-spacing/types._ts_.d.ts
index 178a3ab5..ad01cc2e 100644
--- a/packages/eslint-plugin/rules/keyword-spacing/types._ts_.d.ts
+++ b/packages/eslint-plugin/rules/keyword-spacing/types._ts_.d.ts
@@ -10,18 +10,6 @@ export interface KeywordSpacingSchema0 {
       before?: boolean
       after?: boolean
     }
-    as?: {
-      before?: boolean
-      after?: boolean
-    }
-    async?: {
-      before?: boolean
-      after?: boolean
-    }
-    await?: {
-      before?: boolean
-      after?: boolean
-    }
     boolean?: {
       before?: boolean
       after?: boolean
@@ -114,18 +102,10 @@ export interface KeywordSpacingSchema0 {
       before?: boolean
       after?: boolean
     }
-    from?: {
-      before?: boolean
-      after?: boolean
-    }
     function?: {
       before?: boolean
       after?: boolean
     }
-    get?: {
-      before?: boolean
-      after?: boolean
-    }
     goto?: {
       before?: boolean
       after?: boolean
@@ -158,10 +138,6 @@ export interface KeywordSpacingSchema0 {
       before?: boolean
       after?: boolean
     }
-    let?: {
-      before?: boolean
-      after?: boolean
-    }
     long?: {
       before?: boolean
       after?: boolean
@@ -178,10 +154,6 @@ export interface KeywordSpacingSchema0 {
       before?: boolean
       after?: boolean
     }
-    of?: {
-      before?: boolean
-      after?: boolean
-    }
     package?: {
       before?: boolean
       after?: boolean
@@ -202,14 +174,6 @@ export interface KeywordSpacingSchema0 {
       before?: boolean
       after?: boolean
     }
-    satisfies?: {
-      before?: boolean
-      after?: boolean
-    }
-    set?: {
-      before?: boolean
-      after?: boolean
-    }
     short?: {
       before?: boolean
       after?: boolean
@@ -278,6 +242,42 @@ export interface KeywordSpacingSchema0 {
       before?: boolean
       after?: boolean
     }
+    as?: {
+      before?: boolean
+      after?: boolean
+    }
+    async?: {
+      before?: boolean
+      after?: boolean
+    }
+    await?: {
+      before?: boolean
+      after?: boolean
+    }
+    from?: {
+      before?: boolean
+      after?: boolean
+    }
+    get?: {
+      before?: boolean
+      after?: boolean
+    }
+    let?: {
+      before?: boolean
+      after?: boolean
+    }
+    of?: {
+      before?: boolean
+      after?: boolean
+    }
+    satisfies?: {
+      before?: boolean
+      after?: boolean
+    }
+    set?: {
+      before?: boolean
+      after?: boolean
+    }
     yield?: {
       before?: boolean
       after?: boolean
diff --git a/packages/eslint-plugin/rules/keyword-spacing/types.d.ts b/packages/eslint-plugin/rules/keyword-spacing/types.d.ts
index 178a3ab5..ad01cc2e 100644
--- a/packages/eslint-plugin/rules/keyword-spacing/types.d.ts
+++ b/packages/eslint-plugin/rules/keyword-spacing/types.d.ts
@@ -10,18 +10,6 @@ export interface KeywordSpacingSchema0 {
       before?: boolean
       after?: boolean
     }
-    as?: {
-      before?: boolean
-      after?: boolean
-    }
-    async?: {
-      before?: boolean
-      after?: boolean
-    }
-    await?: {
-      before?: boolean
-      after?: boolean
-    }
     boolean?: {
       before?: boolean
       after?: boolean
@@ -114,18 +102,10 @@ export interface KeywordSpacingSchema0 {
       before?: boolean
       after?: boolean
     }
-    from?: {
-      before?: boolean
-      after?: boolean
-    }
     function?: {
       before?: boolean
       after?: boolean
     }
-    get?: {
-      before?: boolean
-      after?: boolean
-    }
     goto?: {
       before?: boolean
       after?: boolean
@@ -158,10 +138,6 @@ export interface KeywordSpacingSchema0 {
       before?: boolean
       after?: boolean
     }
-    let?: {
-      before?: boolean
-      after?: boolean
-    }
     long?: {
       before?: boolean
       after?: boolean
@@ -178,10 +154,6 @@ export interface KeywordSpacingSchema0 {
       before?: boolean
       after?: boolean
     }
-    of?: {
-      before?: boolean
-      after?: boolean
-    }
     package?: {
       before?: boolean
       after?: boolean
@@ -202,14 +174,6 @@ export interface KeywordSpacingSchema0 {
       before?: boolean
       after?: boolean
     }
-    satisfies?: {
-      before?: boolean
-      after?: boolean
-    }
-    set?: {
-      before?: boolean
-      after?: boolean
-    }
     short?: {
       before?: boolean
       after?: boolean
@@ -278,6 +242,42 @@ export interface KeywordSpacingSchema0 {
       before?: boolean
       after?: boolean
     }
+    as?: {
+      before?: boolean
+      after?: boolean
+    }
+    async?: {
+      before?: boolean
+      after?: boolean
+    }
+    await?: {
+      before?: boolean
+      after?: boolean
+    }
+    from?: {
+      before?: boolean
+      after?: boolean
+    }
+    get?: {
+      before?: boolean
+      after?: boolean
+    }
+    let?: {
+      before?: boolean
+      after?: boolean
+    }
+    of?: {
+      before?: boolean
+      after?: boolean
+    }
+    satisfies?: {
+      before?: boolean
+      after?: boolean
+    }
+    set?: {
+      before?: boolean
+      after?: boolean
+    }
     yield?: {
       before?: boolean
       after?: boolean
diff --git a/packages/eslint-plugin/rules/type-generic-spacing/type-generic-spacing._plus_.ts b/packages/eslint-plugin/rules/type-generic-spacing/type-generic-spacing._plus_.ts
index 88fa5c75..561af120 100644
--- a/packages/eslint-plugin/rules/type-generic-spacing/type-generic-spacing._plus_.ts
+++ b/packages/eslint-plugin/rules/type-generic-spacing/type-generic-spacing._plus_.ts
@@ -1,3 +1,4 @@
+import type { Token } from '#types'
 import type { MessageIds, RuleOptions } from './types'
 import { createRule } from '#utils/create-rule'
 
@@ -26,47 +27,50 @@ export default createRule<RuleOptions, MessageIds>({
   create: (context) => {
     const sourceCode = context.sourceCode
 
-    return {
-      TSTypeParameterInstantiation: (node) => {
-        const params = node.params
+    function removeSpaceBetween(left: Token, right: Token) {
+      const textBetween = sourceCode.text.slice(left.range[1], right.range[0])
+
+      // raise error only if there is no newline
+      if (/\s/.test(textBetween) && !/^[\r\n]/.test(textBetween)) {
+        context.report({
+          loc: {
+            start: left.loc.end,
+            end: right.loc.start,
+          },
+          messageId: 'genericSpacingMismatch',
+          * fix(fixer) {
+            yield fixer.replaceTextRange([left.range[1], right.range[0]], '')
+          },
+        })
+      }
+    }
 
-        const openToken = sourceCode.getTokenBefore(params[0])
-        const firstToken = openToken ? sourceCode.getTokenAfter(openToken) : null
+    function checkBracketSpacing(openToken: Token | null, closeToken: Token | null) {
+      if (openToken) {
+        const firstToken = sourceCode.getTokenAfter(openToken)
 
-        const closeToken = sourceCode.getTokenAfter(params[params.length - 1])
-        const lastToken = closeToken ? sourceCode.getTokenBefore(closeToken) : null
+        if (firstToken) {
+          removeSpaceBetween(openToken, firstToken)
+        }
+      }
 
-        // remove spaces between "<" and the first type parameter
-        if (openToken && firstToken) {
-          const textBetween = sourceCode.text.slice(openToken.range[1], firstToken.range[0])
+      if (closeToken) {
+        const lastToken = sourceCode.getTokenBefore(closeToken)
 
-          // raise error only if there is no newline
-          if (/\s/.test(textBetween) && !/^[\r\n]/.test(textBetween)) {
-            context.report({
-              node,
-              messageId: 'genericSpacingMismatch',
-              * fix(fixer) {
-                yield fixer.replaceTextRange([openToken.range[1], firstToken.range[0]], '')
-              },
-            })
-          }
+        if (lastToken) {
+          removeSpaceBetween(lastToken, closeToken)
         }
+      }
+    }
 
-        // remove spaces between the last type parameter and ">"
-        if (closeToken && lastToken) {
-          const textBetween = sourceCode.text.slice(lastToken.range[1], closeToken.range[0])
+    return {
+      TSTypeParameterInstantiation: (node) => {
+        const params = node.params
 
-          // raise error only if there is no newline
-          if (/\s/.test(textBetween) && !/^[\r\n]/.test(textBetween)) {
-            context.report({
-              node,
-              messageId: 'genericSpacingMismatch',
-              * fix(fixer) {
-                yield fixer.replaceTextRange([lastToken.range[1], closeToken.range[0]], '')
-              },
-            })
-          }
-        }
+        const openToken = sourceCode.getTokenBefore(params[0])
+        const closeToken = sourceCode.getTokenAfter(params[params.length - 1])
+
+        checkBracketSpacing(openToken, closeToken)
       },
 
       TSTypeParameterDeclaration: (node) => {
@@ -89,42 +93,9 @@ export default createRule<RuleOptions, MessageIds>({
         const params = node.params
 
         const openToken = sourceCode.getTokenBefore(params[0])
-        const firstToken = openToken ? sourceCode.getTokenAfter(openToken) : null
-
         const closeToken = sourceCode.getTokenAfter(params[params.length - 1])
-        const lastToken = closeToken ? sourceCode.getTokenBefore(closeToken) : null
-
-        // remove spaces between "<" and the first type parameter
-        if (openToken && firstToken) {
-          const textBetween = sourceCode.text.slice(openToken.range[1], firstToken.range[0])
-
-          // raise error only if there is no newline
-          if (/\s/.test(textBetween) && !/^[\r\n]/.test(textBetween)) {
-            context.report({
-              node,
-              messageId: 'genericSpacingMismatch',
-              * fix(fixer) {
-                yield fixer.replaceTextRange([openToken.range[1], firstToken.range[0]], '')
-              },
-            })
-          }
-        }
 
-        // remove spaces between the last type parameter and ">"
-        if (closeToken && lastToken) {
-          const textBetween = sourceCode.text.slice(lastToken.range[1], closeToken.range[0])
-
-          // raise error only if there is no newline
-          if (/\s/.test(textBetween) && !/^[\r\n]/.test(textBetween)) {
-            context.report({
-              node,
-              messageId: 'genericSpacingMismatch',
-              * fix(fixer) {
-                yield fixer.replaceTextRange([lastToken.range[1], closeToken.range[0]], '')
-              },
-            })
-          }
-        }
+        checkBracketSpacing(openToken, closeToken)
 
         // add space between <T,K>
         for (let i = 1; i < params.length; i++) {


Test patch:
diff --git a/packages/eslint-plugin/rules/keyword-spacing/keyword-spacing._ts_.test.ts b/packages/eslint-plugin/rules/keyword-spacing/keyword-spacing._ts_.test.ts
index 7c4d1828..090efb73 100644
--- a/packages/eslint-plugin/rules/keyword-spacing/keyword-spacing._ts_.test.ts
+++ b/packages/eslint-plugin/rules/keyword-spacing/keyword-spacing._ts_.test.ts
@@ -272,6 +272,111 @@ run<RuleOptions, MessageIds>({
       options: [BOTH],
       parserOptions: { ecmaVersion: 6, sourceType: 'module' },
     },
+    {
+      code: 'export type { foo } from "foo";',
+      options: [BOTH],
+      parserOptions: { ecmaVersion: 6, sourceType: 'module' },
+    },
+    {
+      code: 'export type * as Foo from \'foo\'',
+      options: [BOTH],
+      parserOptions: { ecmaVersion: 6, sourceType: 'module' },
+    },
+    {
+      code: 'export type { SavedQueries } from "./SavedQueries.js";',
+      options: [
+        {
+          before: true,
+          after: false,
+          overrides: {
+            else: { after: true },
+            return: { after: true },
+            try: { after: true },
+            catch: { after: false },
+            case: { after: true },
+            const: { after: true },
+            throw: { after: true },
+            let: { after: true },
+            do: { after: true },
+            of: { after: true },
+            as: { after: true },
+            finally: { after: true },
+            from: { after: true },
+            import: { after: true },
+            export: { after: true },
+            default: { after: true },
+            // The new option:
+            type: { after: true },
+          },
+        },
+      ],
+      parserOptions: { ecmaVersion: 6, sourceType: 'module' },
+    },
+    {
+      // Space after export is not configurable from option since it's invalid syntax with export type
+      code: 'export type { SavedQueries } from "./SavedQueries.js";',
+      options: [
+        {
+          before: true,
+          after: true,
+          overrides: {
+            export: { after: false },
+          },
+        },
+      ],
+      parserOptions: { ecmaVersion: 6, sourceType: 'module' },
+    },
+    {
+      code: 'export type{SavedQueries} from \'./SavedQueries.js\';',
+      options: [
+        {
+          before: true,
+          after: false,
+          overrides: {
+            from: { after: true },
+          },
+        },
+      ],
+      parserOptions: { ecmaVersion: 6, sourceType: 'module' },
+    },
+    {
+      code: 'export type{SavedQueries} from\'./SavedQueries.js\';',
+      options: [
+        {
+          before: true,
+          after: false,
+        },
+      ],
+      parserOptions: { ecmaVersion: 6, sourceType: 'module' },
+    },
+    {
+      code: 'export type {} from "foo";',
+      options: [BOTH],
+      parserOptions: { ecmaVersion: 6, sourceType: 'module' },
+    },
+    {
+      code: 'export type { foo1, foo2 } from "foo";',
+      options: [BOTH],
+      parserOptions: { ecmaVersion: 6, sourceType: 'module' },
+    },
+    {
+      code: 'export type { foo1 as _foo1, foo2 as _foo2 } from "foo";',
+      options: [BOTH],
+      parserOptions: { ecmaVersion: 6, sourceType: 'module' },
+    },
+    {
+      code: 'export type { foo as bar } from "foo";',
+      options: [BOTH],
+      parserOptions: { ecmaVersion: 6, sourceType: 'module' },
+    },
+    {
+      code: 'import type{}from"foo";',
+      options: [NEITHER],
+    },
+    {
+      code: 'export type{}from"foo";',
+      options: [NEITHER],
+    },
   ],
   invalid: [
     // ----------------------------------------------------------------------
@@ -396,5 +501,83 @@ run<RuleOptions, MessageIds>({
         { messageId: 'unexpectedAfter', data: { value: 'from' } },
       ],
     },
+    {
+      code: 'export type{ foo } from "foo";',
+      output: 'export type { foo } from "foo";',
+      options: [{ after: true, before: true }],
+      parserOptions: { ecmaVersion: 6, sourceType: 'module' },
+      errors: expectedAfter('type'),
+    },
+    {
+      code: 'export type { foo } from"foo";',
+      output: 'export type{ foo } from"foo";',
+      options: [{ after: false, before: true }],
+      parserOptions: { ecmaVersion: 6, sourceType: 'module' },
+      errors: unexpectedAfter('type'),
+    },
+    {
+      code: 'export type* as foo from "foo";',
+      output: 'export type * as foo from "foo";',
+      options: [{ after: true, before: true }],
+      parserOptions: { ecmaVersion: 6, sourceType: 'module' },
+      errors: expectedAfter('type'),
+    },
+    {
+      code: 'export type * as foo from"foo";',
+      output: 'export type* as foo from"foo";',
+      options: [{ after: false, before: true }],
+      parserOptions: { ecmaVersion: 6, sourceType: 'module' },
+      errors: unexpectedAfter('type'),
+    },
+    {
+      code: 'export type {SavedQueries} from \'./SavedQueries.js\';',
+      output: 'export type{SavedQueries} from \'./SavedQueries.js\';',
+      options: [
+        {
+          before: true,
+          after: false,
+          overrides: {
+            from: { after: true },
+          },
+        },
+      ],
+      parserOptions: { ecmaVersion: 6, sourceType: 'module' },
+      errors: unexpectedAfter('type'),
+    },
+    {
+      code: 'export type {SavedQueries} from \'./SavedQueries.js\';',
+      output: 'export type{SavedQueries} from\'./SavedQueries.js\';',
+      options: [
+        {
+          before: true,
+          after: false,
+        },
+      ],
+      parserOptions: { ecmaVersion: 6, sourceType: 'module' },
+      errors: [
+        { messageId: 'unexpectedAfter', data: { value: 'type' } },
+        { messageId: 'unexpectedAfter', data: { value: 'from' } },
+      ],
+    },
+    {
+      code: 'import type {} from "foo";',
+      output: 'import type{}from"foo";',
+      options: [NEITHER],
+      errors: [
+        { messageId: 'unexpectedAfter', data: { value: 'type' } },
+        { messageId: 'unexpectedBefore', data: { value: 'from' } },
+        { messageId: 'unexpectedAfter', data: { value: 'from' } },
+      ],
+    },
+    {
+      code: 'export type {} from "foo";',
+      output: 'export type{}from"foo";',
+      options: [NEITHER],
+      errors: [
+        { messageId: 'unexpectedAfter', data: { value: 'type' } },
+        { messageId: 'unexpectedBefore', data: { value: 'from' } },
+        { messageId: 'unexpectedAfter', data: { value: 'from' } },
+      ],
+    },
   ],
 })