diff --git a/packages/eslint-plugin/src/rules/index.ts b/packages/eslint-plugin/src/rules/index.ts index 1b6fa0f6ea12..787eb42c3c81 100644 --- a/packages/eslint-plugin/src/rules/index.ts +++ b/packages/eslint-plugin/src/rules/index.ts @@ -64,6 +64,7 @@ import noThrowLiteral from './no-throw-literal'; import noTypeAlias from './no-type-alias'; import noUnnecessaryBooleanLiteralCompare from './no-unnecessary-boolean-literal-compare'; import noUnnecessaryCondition from './no-unnecessary-condition'; +import noUnnecessaryGenerics from './no-unnecessary-generics'; import noUnnecessaryQualifier from './no-unnecessary-qualifier'; import noUnnecessaryTypeArguments from './no-unnecessary-type-arguments'; import noUnnecessaryTypeAssertion from './no-unnecessary-type-assertion'; @@ -183,6 +184,7 @@ export default { 'no-type-alias': noTypeAlias, 'no-unnecessary-boolean-literal-compare': noUnnecessaryBooleanLiteralCompare, 'no-unnecessary-condition': noUnnecessaryCondition, + 'no-unnecessary-generics': noUnnecessaryGenerics, 'no-unnecessary-qualifier': noUnnecessaryQualifier, 'no-unnecessary-type-arguments': noUnnecessaryTypeArguments, 'no-unnecessary-type-assertion': noUnnecessaryTypeAssertion, diff --git a/packages/eslint-plugin/src/rules/no-unnecessary-generics.ts b/packages/eslint-plugin/src/rules/no-unnecessary-generics.ts new file mode 100644 index 000000000000..3e04a7936c03 --- /dev/null +++ b/packages/eslint-plugin/src/rules/no-unnecessary-generics.ts @@ -0,0 +1,315 @@ +import { TSESTree } from '@typescript-eslint/experimental-utils'; +import * as util from '../util'; +import * as ts from 'typescript'; + +export default util.createRule({ + name: 'no-unnecessary-generics', + meta: { + docs: { + description: 'Forbids signatures using a generic parameter only once', + category: 'Best Practices', + recommended: false, + }, + messages: { + failureString: 'Type parameter {{typeParameter}} is used only once.', + failureStringNever: 'Type parameter {{typeParameter}} is never used', + }, + schema: [], + type: 'suggestion', + }, + defaultOptions: [], + create(context) { + const parserServices = util.getParserServices(context); + const checker = parserServices.program.getTypeChecker(); + + return { + 'FunctionDeclaration, FunctionExpression, ArrowFunctionExpression, TSCallSignatureDeclaration, TSMethodSignature, TSFunctionType, TSConstructorType, TSDeclareFunction'( + node: // eslint-disable-next-line @typescript-eslint/no-explicit-any + any, + // | TSESTree.FunctionDeclaration + // | TSESTree.FunctionExpression + // | TSESTree.ArrowFunctionExpression + // | TSESTree.TSCallSignatureDeclaration + // | TSESTree.TSMethodSignature + // | TSESTree.TSFunctionType + // | TSESTree.TSConstructorType + // | TSESTree.TSDeclareFunction, + ): void { + const tsNode = parserServices.esTreeNodeToTSNodeMap.get(node); + if (tsNode && ts.isFunctionLike(tsNode)) { + checkSignature(tsNode); + } + + // copied from https://github.com/microsoft/dtslint/blob/88b6f8975bb3ae9091c7150ddd52a11732324009/src/rules/noUnnecessaryGenericsRule.ts#L42 + function checkSignature(sig: ts.SignatureDeclaration): void { + if (!sig.typeParameters) { + return; + } + + for (const tp of sig.typeParameters) { + const typeParameter = tp.name.text; + const res = getSoleUse( + sig, + assertDefined(checker.getSymbolAtLocation(tp.name)), + checker, + ); + switch (res.type) { + case 'ok': + break; + case 'sole': + context.report({ + node: parserServices.tsNodeToESTreeNodeMap.get(res.soleUse), + messageId: 'failureString', + data: { + typeParameter, + }, + }); + break; + case 'never': + context.report({ + node: parserServices.tsNodeToESTreeNodeMap.get(tp), + messageId: 'failureString', + data: { + typeParameter, + }, + }); + break; + default: + assertNever(res); + } + } + } + }, + }; + }, +}); + +type Result = + | { type: 'ok' | 'never' } + | { type: 'sole'; soleUse: ts.Identifier }; +function getSoleUse( + sig: ts.SignatureDeclaration, + typeParameterSymbol: ts.Symbol, + checker: ts.TypeChecker, +): Result { + const exit = {}; + let soleUse: ts.Identifier | undefined; + const seenTypes = new Set(); + + try { + if (sig.typeParameters) { + for (const tp of sig.typeParameters) { + if (tp.constraint) { + recur(tp.constraint); + } + } + } + for (const param of sig.parameters) { + if (param.type) { + recur(param.type); + } + } + if (sig.type) { + recur(sig.type); + } else if (sig.kind !== ts.SyntaxKind.Constructor && 'body' in sig) { + // if body is missing, there is no point inferring return type. + const sigType = checker.getSignatureFromDeclaration(sig); + if (!sigType) { + return { type: 'ok' }; + } + + const returnType = checker.getReturnTypeOfSignature(sigType); + try { + forEachType( + returnType, + type => { + if (type.getSymbol() === typeParameterSymbol) { + throw exit; + } + }, + checker, + ); + } catch (error) { + if (error === exit) { + return { type: 'ok' }; + } else { + throw error; + } + } + } + } catch (err) { + if (err === exit) { + return { type: 'ok' }; + } + throw err; + } + + return soleUse ? { type: 'sole', soleUse } : { type: 'never' }; + + function recur(node: ts.TypeNode): void { + if (ts.isIdentifier(node)) { + if (checker.getSymbolAtLocation(node) === typeParameterSymbol) { + if (soleUse === undefined) { + soleUse = node; + } else { + throw exit; + } + } + } else { + node.forEachChild(recur as (node: ts.Node) => void); + } + } + + function forEachType( + type: ts.Type, + cb: (type: ts.Type) => void, + checker: ts.TypeChecker, + ): void { + // prevent infinite recursion + if (seenTypes.has(type)) { + return; + } else { + seenTypes.add(type); + } + + cb(type); + + // T | P + if (type.flags & ts.TypeFlags.UnionOrIntersection) { + const types = (type as ts.UnionOrIntersectionType).types; + types.forEach(type => { + forEachType(type, cb, checker); + }); + return; + } + + // a simple type + if (!(type.flags & ts.TypeFlags.StructuredOrInstantiable)) { + return; + } + + // keyof T + if (type.flags & ts.TypeFlags.Index) { + forEachType((type as ts.IndexType).type, cb, checker); + return; + } + + // Indexed access types (TypeFlags.IndexedAccess) + // Possible forms are T[xxx], xxx[T], or xxx[keyof T], where T is a type variable + if (type.flags & ts.TypeFlags.IndexedAccess) { + forEachType((type as ts.IndexedAccessType).objectType, cb, checker); + forEachType((type as ts.IndexedAccessType).indexType, cb, checker); + return; + } + + // TODO: true type and false type are not exposed. https://github.com/microsoft/TypeScript/issues/45537 + if (type.flags & ts.TypeFlags.Conditional) { + throw exit; + } + + // TODO: MappedType is not exposed. + if (getObjectFlags(type) & ts.ObjectFlags.Mapped) { + throw exit; + } + + if (type.flags & ts.TypeFlags.TemplateLiteral) { + (type as ts.TemplateLiteralType).types.forEach(type => + forEachType(type, cb, checker), + ); + return; + } + + if (type.flags & ts.TypeFlags.Object) { + type.aliasTypeArguments?.forEach(arg => { + forEachType(arg, cb, checker); + }); + + const properties = checker.getPropertiesOfType(type); + properties.forEach(prop => { + const declType = checker.getTypeOfSymbolAtLocation(prop, sig); + forEachType(declType, cb, checker); + }); + + const visitSignature = (signature: ts.Signature): void => { + signature.parameters.forEach(param => { + const decl = param.getDeclarations()?.[0]; + if (decl) { + const declType = checker.getTypeOfSymbolAtLocation(param, decl); + forEachType(declType, cb, checker); + } + }); + + signature.getTypeParameters()?.forEach(typeParam => { + const decl = typeParam.symbol.getDeclarations()?.[0]; + if (decl?.kind === ts.SyntaxKind.TypeParameter) { + const constrain = (decl as ts.TypeParameterDeclaration).constraint; + if (constrain) { + forEachType(checker.getTypeFromTypeNode(constrain), cb, checker); + } + } + }); + + forEachType(signature.getReturnType(), cb, checker); + + // const predicate = checker.getTypePredicateOfSignature(); + const decl = signature?.getDeclaration(); + if (decl.type?.kind === ts.SyntaxKind.TypePredicate) { + const typeNode = (decl.type as ts.TypePredicateNode).type; + if (typeNode) { + forEachType(checker.getTypeAtLocation(typeNode), cb, checker); + } + } + }; + + const calls = type.getCallSignatures(); + calls.forEach(signature => { + visitSignature(signature); + }); + const constructors = type.getConstructSignatures(); + constructors.forEach(signature => { + visitSignature(signature); + }); + + // TODO: `checker.getIndexInfosOfType()` might be better + const numberIndexType = type.getNumberIndexType(); + if (numberIndexType) { + forEachType(numberIndexType, cb, checker); + } + const stringIndexType = type.getStringIndexType(); + if (stringIndexType) { + forEachType(stringIndexType, cb, checker); + } + + return; + } + + // TODO: unknown type, abort? + throw exit; + } + + // internal of ts compiler + + function getObjectFlags(type: ts.Type): ts.ObjectFlags { + const ObjectFlagsType = + ts.TypeFlags.Any | + ts.TypeFlags.Undefined | + ts.TypeFlags.Null | + ts.TypeFlags.Never | + ts.TypeFlags.Object | + ts.TypeFlags.Union | + ts.TypeFlags.Intersection; + return type.flags & ObjectFlagsType + ? (type as ts.ObjectType).objectFlags + : 0; + } +} + +function assertDefined(value: T | undefined): T { + if (value === undefined) { + throw new Error('unreachable'); + } + return value; +} +function assertNever(_: never): never { + throw new Error('unreachable'); +} diff --git a/packages/eslint-plugin/tests/rules/no-unnecessary-generics.test.ts b/packages/eslint-plugin/tests/rules/no-unnecessary-generics.test.ts new file mode 100644 index 000000000000..28acda2be510 --- /dev/null +++ b/packages/eslint-plugin/tests/rules/no-unnecessary-generics.test.ts @@ -0,0 +1,231 @@ +import rule from '../../src/rules/no-unnecessary-generics'; +import { RuleTester, getFixturesRootDir } from '../RuleTester'; + +const rootPath = getFixturesRootDir(); + +const ruleTester = new RuleTester({ + parser: '@typescript-eslint/parser', + parserOptions: { + sourceType: 'module', + tsconfigRootDir: rootPath, + project: './tsconfig.json', + }, +}); + +ruleTester.run('no-unnecessary-generics', rule, { + valid: [ + ` +// Uses type parameter twice +function foo(m: Map): void {} + `, + ` +// 'T appears in a constraint, so it appears twice. +function f(t: T, u: U): U; + `, + ` +function f(t: T) { + return t; +} + `, + ` +function f(t: T) { + return null as { t: T }; +} + `, + ` +function f(t: T) { + return null as T | null; +} + `, + ` +function f(t: T) { + return null as (t: T) => void; +} + `, + ` +function f(t: T) { + return (t: any): t is T => { + return true; + }; +} + `, + ` +function f(t: T) { + return (t: any): t is () => T => { + return true; + }; +} + `, + ` +function f(t: T) { + return null as { [k in keyof T]: string }; +} + `, + ` +function f() { + return null as T[P]; +} + `, + ` +// not supported +function f() { + return null as A extends B ? 1 : 2; +} + `, + ` +function f() { + return null as A extends B ? string : number; +} +`, + ` +function f() { + return null as [A, B]; +} +`, + ` +function f() { + return [] as A[]; +} +`, + ` +function f(t: T) { + return null as Record; +} + `, + ` +function f1(a: T) { + return class Foo extends a { + f = 1; + }; +} + `, + ` +function f1(a: T) { + return class Foo extends a { + f = 1; + }; +} + `, + ], + + invalid: [ + { + code: ` +interface I { + (value: T): void; + m(x: T): void; +} + `, + errors: [ + { + messageId: 'failureString', + line: 3, + column: 14, + }, + { + messageId: 'failureString', + line: 4, + column: 11, + }, + ], + }, + { + code: ` +class C { + constructor(x: T) {} +} + `, + errors: [ + { + messageId: 'failureString', + line: 3, + column: 21, + }, + ], + }, + { + code: ` +type Fn = () => T; + `, + errors: [ + { + messageId: 'failureString', + line: 2, + column: 20, + }, + ], + }, + { + code: ` +type Ctr = new () => T; + `, + errors: [ + { + messageId: 'failureString', + line: 2, + column: 25, + }, + ], + }, + { + code: ` +function f(): T {} + `, + errors: [ + { + messageId: 'failureString', + line: 2, + column: 18, + }, + ], + }, + { + code: ` +const f2 = (): T => {}; + `, + errors: [ + { + messageId: 'failureString', + line: 2, + column: 19, + }, + ], + }, + { + code: ` +function f(x: { T: number }): void; + `, + errors: [ + { + messageId: 'failureString', + line: 2, + column: 12, + }, + ], + }, + { + code: ` +function f(u: U): U; + `, + errors: [ + { + messageId: 'failureString', + line: 2, + column: 25, + }, + ], + }, + { + code: ` +function f(t: T) {} + `, + errors: [ + { + messageId: 'failureString', + line: 2, + column: 18, + }, + ], + }, + ], +});