Repository: nzakas/eslint-plugin-typescript
Pull request #135 (2018-06-22 20:44:10)

Fix: Handle extends generics in no-unused-vars
See #33 

I believe this addresses the first and fourth snippets in https://github.com/nzakas/eslint-plugin-typescript/issues/33#issuecomment-399316751. Any thoughts on other test cases to add or things to consider in this fix?

[Here is a link](http://astexplorer.net/#/gist/2e4055c6371619d206d0f39184c6f835/efbf815025efdfdaa8d6edd3880c8c41b56d1d9a) to an AST Explorer of the test case, if that is helpful for review.


Patch:
diff --git a/README.md b/README.md
index 6a6d9ae..64af18d 100644
--- a/README.md
+++ b/README.md
@@ -55,6 +55,7 @@ Then configure the rules you want to use under the rules section.
 * [`typescript/class-name-casing`](./docs/rules/class-name-casing.md) — Require PascalCased class and interface names (`class-name` from TSLint)
 * [`typescript/explicit-function-return-type`](./docs/rules/explicit-function-return-type.md) — Require explicit return types on functions and class methods
 * [`typescript/explicit-member-accessibility`](./docs/rules/explicit-member-accessibility.md) — Require explicit accessibility modifiers on class properties and methods (`member-access` from TSLint)
+* [`typescript/generic-type-naming`](./docs/rules/generic-type-naming.md) — Enforces naming of generic type variables
 * [`typescript/interface-name-prefix`](./docs/rules/interface-name-prefix.md) — Require that interface names be prefixed with `I` (`interface-name` from TSLint)
 * [`typescript/member-delimiter-style`](./docs/rules/member-delimiter-style.md) — Require a specific member delimiter style for interfaces and type literals
 * [`typescript/member-naming`](./docs/rules/member-naming.md) — Enforces naming conventions for class members by visibility.
diff --git a/docs/rules/generic-type-naming.md b/docs/rules/generic-type-naming.md
new file mode 100644
index 0000000..a13513d
--- /dev/null
+++ b/docs/rules/generic-type-naming.md
@@ -0,0 +1,42 @@
+# Enforces naming of generic type variables (generic-type-naming)
+
+It can be helpful to enforce a consistent naming style for generic type variables used within a type.
+For example, prefixing them with `T` and ensuring a somewhat descriptive name, or enforcing Hungarian notation.
+
+## Rule Details
+
+This rule allows you to enforce conventions over type variables. By default, it does nothing.
+
+## Options
+
+The rule takes a single string option, which is a regular expression that type variables should match.
+
+Examples of **correct** code with a configuration of `'^T[A-Z][a-zA-Z]+$'`:
+
+```typescript
+type ReadOnly<TType extends object> = {
+  readonly [TKey in keyof TType]: TType[TKey];
+}
+
+interface SimpleMap<TValue> {
+  [key: string]: TValue;
+}
+```
+
+Examples of **incorrect** code with a configuration of `'^T[A-Z][a-zA-Z]+$'`:
+
+```typescript
+type ReadOnly<T extends object> = {
+  readonly [Key in keyof T]: T[Key];
+}
+
+interface SimpleMap<T> {
+  [key: string]: T;
+}
+```
+
+## When Not To Use It
+If you do not want to enforce a naming convention for type variables.
+
+## Further Reading
+- [TypeScript Generics](https://www.typescriptlang.org/docs/handbook/generics.html)
diff --git a/lib/rules/generic-type-naming.js b/lib/rules/generic-type-naming.js
new file mode 100644
index 0000000..10e66e5
--- /dev/null
+++ b/lib/rules/generic-type-naming.js
@@ -0,0 +1,71 @@
+/**
+ * @fileoverview Enforces naming of generic type variables.
+ */
+"use strict";
+
+/**
+ *
+ * @param {any} context ESLint context
+ * @param {string} rule Option
+ * @returns {Function} Node's visitor function
+ */
+function createTypeParameterChecker(context, rule) {
+    const regex = new RegExp(rule);
+
+    return function checkTypeParameters(pnode) {
+        const params = pnode.typeParameters && pnode.typeParameters.params;
+
+        if (!Array.isArray(params) || params.length === 0) {
+            return;
+        }
+        params.forEach(node => {
+            const type = node.type;
+
+            if (type === "TSTypeParameter" || type === "TypeParameter") {
+                const name = node.name;
+
+                if (name && !regex.test(name)) {
+                    const data = { name, rule };
+
+                    context.report({
+                        node,
+                        message:
+                            "Type parameter {{name}} does not match rule {{rule}}",
+                        data
+                    });
+                }
+            }
+        });
+    };
+}
+
+module.exports = {
+    meta: {
+        docs: {
+            description: "Enforces naming of generic type variables",
+            category: "TypeScript",
+            url:
+                "https://github.com/nzakas/eslint-plugin-typescript/blob/master/docs/rules/generic-type-naming.md"
+        }
+    },
+
+    create(context) {
+        const rule = context.options[0];
+
+        if (!rule) {
+            return {};
+        }
+
+        const checkTypeParameters = createTypeParameterChecker(context, rule);
+
+        return {
+            VariableDeclarator: checkTypeParameters,
+            ClassDeclaration: checkTypeParameters,
+            InterfaceDeclaration: checkTypeParameters,
+            TSInterfaceDeclaration: checkTypeParameters,
+            FunctionDeclaration: checkTypeParameters,
+            TSCallSignature: checkTypeParameters,
+            CallSignature: checkTypeParameters
+        };
+    }
+};
diff --git a/lib/rules/no-unused-vars.js b/lib/rules/no-unused-vars.js
index f940bfe..60093a8 100644
--- a/lib/rules/no-unused-vars.js
+++ b/lib/rules/no-unused-vars.js
@@ -122,6 +122,15 @@ module.exports = {
 
                     break;
 
+                case "TSTypeParameter": {
+                    markTypeAnnotationAsUsed(annotation.constraint);
+                    break;
+                }
+                case "TSMappedType": {
+                    markTypeAnnotationAsUsed(annotation.typeAnnotation);
+                    markTypeAnnotationAsUsed(annotation.typeParameter);
+                    break;
+                }
                 default:
                     break;
             }
@@ -247,6 +256,9 @@ module.exports = {
             if (node.decorators) {
                 node.decorators.forEach(markDecoratorAsUsed);
             }
+            if (node.typeParameters && node.typeParameters.params) {
+                node.typeParameters.params.forEach(markTypeAnnotationAsUsed);
+            }
         }
 
         //----------------------------------------------------------------------
diff --git a/lib/rules/prefer-namespace-keyword.js b/lib/rules/prefer-namespace-keyword.js
index fe0c5af..0c14aab 100644
--- a/lib/rules/prefer-namespace-keyword.js
+++ b/lib/rules/prefer-namespace-keyword.js
@@ -27,48 +27,32 @@ module.exports = {
     create(context) {
         const sourceCode = context.getSourceCode();
 
-        //----------------------------------------------------------------------
-        // Helpers
-        //----------------------------------------------------------------------
-
-        /**
-         * Determines if node is a TypeScript module declaration (instead of a namespace/module).
-         * @param {ASTNode} node the node to be evaluated.
-         * @returns {boolean} true when node is an external declaration.
-         * @private
-         */
-        function isTypeScriptModuleDeclaration(node) {
-            return node.id && node.id.type === "Literal";
-        }
-
-        /**
-         * Gets the start index of the keyword `module`.
-         * @param {TSNode} node the node to be evaluated.
-         * @returns {number} the start index.
-         * @private
-         */
-        function getStartIndex(node) {
-            if (
-                node.modifiers &&
-                node.modifiers.length > 0 &&
-                node.modifiers[0].type === "TSDeclareKeyword"
-            ) {
-                return node.range[0] + "declare".length + 1;
-            }
-            return node.range[0];
-        }
-
         //----------------------------------------------------------------------
         // Public
         //----------------------------------------------------------------------
         return {
             TSModuleDeclaration(node) {
-                const declaration = sourceCode.getText(node);
+                // Get tokens of the declaration header.
+                const firstToken = sourceCode.getFirstToken(node);
+                const tokens = [firstToken].concat(
+                    sourceCode.getTokensBetween(
+                        firstToken,
+                        sourceCode.getFirstToken(node.body)
+                    )
+                );
 
-                if (
-                    isTypeScriptModuleDeclaration(node) ||
-                    /\bnamespace\b/.test(declaration)
-                ) {
+                // Get 'module' token and the next one.
+                const moduleKeywordIndex = tokens.findIndex(
+                    t => t.type === "Identifier" && t.value === "module"
+                );
+                const moduleKeywordToken =
+                    moduleKeywordIndex === -1
+                        ? null
+                        : tokens[moduleKeywordIndex];
+                const moduleNameToken = tokens[moduleKeywordIndex + 1];
+
+                // Do nothing if the 'module' token was not found or the module name is a string.
+                if (!moduleKeywordToken || moduleNameToken.type === "String") {
                     return;
                 }
 
@@ -77,10 +61,8 @@ module.exports = {
                     message:
                         "Use 'namespace' instead of 'module' to declare custom TypeScript modules",
                     fix(fixer) {
-                        const start = getStartIndex(node);
-
-                        return fixer.replaceTextRange(
-                            [start, start + "module".length],
+                        return fixer.replaceText(
+                            moduleKeywordToken,
                             "namespace"
                         );
                     }


Test patch:
diff --git a/tests/lib/rules/generic-type-naming.js b/tests/lib/rules/generic-type-naming.js
new file mode 100644
index 0000000..7f1ed3a
--- /dev/null
+++ b/tests/lib/rules/generic-type-naming.js
@@ -0,0 +1,74 @@
+"use strict";
+
+//------------------------------------------------------------------------------
+// Requirements
+//------------------------------------------------------------------------------
+
+const rule = require("../../../lib/rules/generic-type-naming"),
+    RuleTester = require("eslint").RuleTester;
+
+//------------------------------------------------------------------------------
+// Tests
+//------------------------------------------------------------------------------
+
+const ruleTester = new RuleTester({
+    parser: "typescript-eslint-parser"
+});
+
+const messagePattern = "Type parameter {{name}} does not match rule {{rule}}";
+
+/**
+ * Creates error object
+ * @param {Object} data Data
+ * @returns {Object} Object with message property
+ */
+function error(data) {
+    let message = messagePattern;
+
+    Object.keys(data).forEach(key => {
+        message = message.replace(new RegExp(`{{${key}}}`, "g"), data[key]);
+    });
+    return { message };
+}
+
+ruleTester.run("generic-type-naming", rule, {
+    valid: [
+        { code: "class<T,U,V> { }", options: [] },
+        { code: "type ReadOnly<T extends object> = {}", options: [] },
+        { code: "interface SimpleMap<V> { }", options: [] },
+        { code: "function get<T>() {}", options: [] },
+        { code: "interface GenericIdentityFn { <T>(arg: T): T }", options: [] },
+        { code: "class<x> { }", options: ["^x+$"] },
+        {
+            code: "class CounterContainer extends Container<Counter> { }",
+            options: ["^T$"]
+        }
+    ],
+    invalid: [
+        {
+            code: "class<x> { }",
+            options: ["^[A-Z]+$"],
+            errors: [error({ name: "x", rule: "^[A-Z]+$" })]
+        },
+        {
+            code: "interface SimpleMap<x> { }",
+            options: ["^[A-Z]+$"],
+            errors: [error({ name: "x", rule: "^[A-Z]+$" })]
+        },
+        {
+            code: "type R<x> = {}",
+            options: ["^[A-Z]+$"],
+            errors: [error({ name: "x", rule: "^[A-Z]+$" })]
+        },
+        {
+            code: "function get<x>() {}",
+            options: ["^[A-Z]+$"],
+            errors: [error({ name: "x", rule: "^[A-Z]+$" })]
+        },
+        {
+            code: "interface GenericIdentityFn { <x>(arg: x): x }",
+            options: ["^[A-Z]+$"],
+            errors: [error({ name: "x", rule: "^[A-Z]+$" })]
+        }
+    ]
+});
diff --git a/tests/lib/rules/no-unused-vars.js b/tests/lib/rules/no-unused-vars.js
index c470d3c..ddcba30 100644
--- a/tests/lib/rules/no-unused-vars.js
+++ b/tests/lib/rules/no-unused-vars.js
@@ -315,6 +315,30 @@ import { SomeOther } from 'some';
 import { Another } from 'some';
 interface A extends Nullable<SomeOther> {
     do(a: Nullable<Another>);
+}
+        `,
+        `
+import { Foo } from './types';
+
+class Bar<T extends Foo> {}
+
+new Bar<number>()
+        `,
+        `
+import { Foo, Bar } from './types';
+
+class Baz<T extends Foo & Bar> {}
+
+new Baz<any>()
+        `,
+        `
+type Foo = "a" | "b" | "c"
+type Bar = number
+
+export const map: { [name in Foo]: Bar } = {
+    a: 1,
+    b: 2,
+    c: 3
 }
         `
     ],
diff --git a/tests/lib/rules/prefer-namespace-keyword.js b/tests/lib/rules/prefer-namespace-keyword.js
index fda02cc..b50bbd2 100644
--- a/tests/lib/rules/prefer-namespace-keyword.js
+++ b/tests/lib/rules/prefer-namespace-keyword.js
@@ -23,7 +23,8 @@ ruleTester.run("prefer-namespace-keyword", rule, {
     valid: [
         "declare module 'foo' { }",
         "namespace foo { }",
-        "declare namespace foo { }"
+        "declare namespace foo { }",
+        "declare global { }"
     ],
     invalid: [
         {