diff --git a/.cspell.json b/.cspell.json index 898e10932518..4a8c0ad226be 100644 --- a/.cspell.json +++ b/.cspell.json @@ -71,8 +71,8 @@ "IIFE", "IIFEs", "linebreaks", - "markdownlint", "lzstring", + "markdownlint", "necroing", "nocheck", "nullish", @@ -101,14 +101,15 @@ "transpiled", "transpiles", "transpiling", - "tsvfs", "tsconfigs", "tsutils", + "tsvfs", "typedef", "typedefs", "unfixable", "unoptimized", "unprefixed", + "upsert", "Zacher" ], "overrides": [ diff --git a/packages/eslint-plugin/src/rules/no-redundant-type-constituents.ts b/packages/eslint-plugin/src/rules/no-redundant-type-constituents.ts new file mode 100644 index 000000000000..e8cbb145fa98 --- /dev/null +++ b/packages/eslint-plugin/src/rules/no-redundant-type-constituents.ts @@ -0,0 +1,324 @@ +import { + TSESTree, + AST_NODE_TYPES, +} from '@typescript-eslint/experimental-utils'; +import * as tsutils from 'tsutils'; +import * as ts from 'typescript'; +import * as util from '../util'; +import { arrayGroupByToMap } from '../util'; + +const literalToPrimitiveTypeFlags = { + [ts.TypeFlags.BigIntLiteral]: ts.TypeFlags.BigInt, + [ts.TypeFlags.BooleanLiteral]: ts.TypeFlags.Boolean, + [ts.TypeFlags.NumberLiteral]: ts.TypeFlags.Number, + [ts.TypeFlags.StringLiteral]: ts.TypeFlags.String, + [ts.TypeFlags.TemplateLiteral]: ts.TypeFlags.String, +} as const; + +const literalTypeFlags = [ + ts.TypeFlags.BigIntLiteral, + ts.TypeFlags.BooleanLiteral, + ts.TypeFlags.NumberLiteral, + ts.TypeFlags.StringLiteral, + ts.TypeFlags.TemplateLiteral, +] as const; + +const primitiveTypeFlags = [ + ts.TypeFlags.BigInt, + ts.TypeFlags.Boolean, + ts.TypeFlags.Number, + ts.TypeFlags.String, +] as const; + +const primitiveTypeFlagNames = { + [ts.TypeFlags.BigInt]: 'bigint', + [ts.TypeFlags.Boolean]: 'boolean', + [ts.TypeFlags.Number]: 'number', + [ts.TypeFlags.String]: 'string', +} as const; + +type PrimitiveTypeFlag = typeof primitiveTypeFlags[number]; + +interface TypeNodeWithValue { + literalValue: unknown; + typeNode: TSESTree.TypeNode; +} + +function addToMapGroup( + map: Map, + key: Key, + value: Value, +): void { + const existing = map.get(key); + + if (existing) { + existing.push(value); + } else { + map.set(key, [value]); + } +} + +function describeLiteralType(type: ts.Type): unknown { + return type.isStringLiteral() + ? JSON.stringify(type.value) + : type.isLiteral() + ? type.value + : util.isTypeTemplateLiteralType(type) + ? 'template literal type' + : util.isTypeBigIntLiteralType(type) + ? `${type.value.negative ? '-' : ''}${type.value.base10Value}n` + : tsutils.isBooleanLiteralType(type, true) + ? 'true' + : tsutils.isBooleanLiteralType(type, false) + ? 'false' + : 'literal type'; +} + +function isNodeInsideReturnType(node: TSESTree.TSUnionType): boolean { + return !!( + node.parent?.type === AST_NODE_TYPES.TSTypeAnnotation && + node.parent.parent && + util.isFunctionType(node.parent.parent) + ); +} + +/** + * @remarks TypeScript stores boolean types as the union false | true, always. + */ +function unionTypePartsUnlessBoolean(type: ts.Type): ts.Type[] { + return type.isUnion() && + type.types.length === 2 && + tsutils.isBooleanLiteralType(type.types[0], false) && + tsutils.isBooleanLiteralType(type.types[1], true) + ? [type] + : tsutils.unionTypeParts(type); +} + +export default util.createRule({ + name: 'no-redundant-type-constituents', + meta: { + docs: { + description: + 'Disallow members of unions and intersections that do nothing or override type information', + recommended: 'error', + requiresTypeChecking: true, + }, + messages: { + literalOverridden: `{{literal}} is overridden by {{primitive}} in this union type.`, + primitiveOverridden: `{{primitive}} is overridden by the literal {{literal}} in this intersection type.`, + overridden: `'never' is overridden by other types in this {{container}} type.`, + overrides: `'{{typeName}}' overrides all other types in this {{container}} type.`, + }, + schema: [], + type: 'suggestion', + }, + defaultOptions: [], + create(context) { + return { + TSIntersectionType(node): void { + const parserServices = util.getParserServices(context); + const checker = parserServices.program.getTypeChecker(); + const seenLiteralTypes = new Map(); + const seenPrimitiveTypes = new Map< + PrimitiveTypeFlag, + TSESTree.TypeNode[] + >(); + + function checkIntersectionBottomAndTopTypes( + nodeType: ts.Type, + typeNode: TSESTree.TypeNode, + ): boolean { + for (const [typeName, messageId, check] of [ + ['any', 'overrides', util.isTypeAnyType], + ['never', 'overrides', util.isTypeNeverType], + ['unknown', 'overridden', util.isTypeUnknownType], + ] as const) { + if (check(nodeType)) { + context.report({ + data: { + container: 'intersection', + typeName, + }, + messageId, + node: typeNode, + }); + return true; + } + } + + return false; + } + + for (const typeNode of node.types) { + const tsNode = parserServices.esTreeNodeToTSNodeMap.get(typeNode); + const nodeType = checker.getTypeAtLocation(tsNode); + const typeParts = tsutils.unionTypeParts(nodeType); + + for (const typePart of typeParts) { + if (checkIntersectionBottomAndTopTypes(typePart, typeNode)) { + continue; + } + + for (const literalTypeFlag of literalTypeFlags) { + if (typePart.flags === literalTypeFlag) { + addToMapGroup( + seenLiteralTypes, + literalToPrimitiveTypeFlags[literalTypeFlag], + describeLiteralType(typePart), + ); + break; + } + } + + for (const primitiveTypeFlag of primitiveTypeFlags) { + if (typePart.flags === primitiveTypeFlag) { + addToMapGroup(seenPrimitiveTypes, primitiveTypeFlag, typeNode); + } + } + } + } + + // For each primitive type of all the seen primitive types, + // if there was a literal type seen that overrides it, + // report each of the primitive type's type nodes + for (const [primitiveTypeFlag, typeNodes] of seenPrimitiveTypes) { + const matchedLiteralTypes = seenLiteralTypes.get(primitiveTypeFlag); + if (matchedLiteralTypes) { + for (const typeNode of typeNodes) { + context.report({ + data: { + literal: matchedLiteralTypes.join(' | '), + primitive: primitiveTypeFlagNames[primitiveTypeFlag], + }, + messageId: 'primitiveOverridden', + node: typeNode, + }); + } + } + } + }, + TSUnionType(node): void { + const parserServices = util.getParserServices(context); + const checker = parserServices.program.getTypeChecker(); + const seenLiteralTypes = new Map< + PrimitiveTypeFlag, + TypeNodeWithValue[] + >(); + const seenPrimitiveTypes = new Set(); + + function checkUnionBottomAndTopTypes( + nodeType: ts.Type, + typeNode: TSESTree.TypeNode, + ): boolean { + for (const [typeName, check] of [ + ['any', util.isTypeAnyType], + ['unknown', util.isTypeUnknownType], + ] as const) { + if (check(nodeType)) { + context.report({ + data: { + container: 'union', + typeName, + }, + messageId: 'overrides', + node: typeNode, + }); + return true; + } + } + + if (util.isTypeNeverType(nodeType) && !isNodeInsideReturnType(node)) { + context.report({ + data: { + container: 'union', + typeName: 'never', + }, + messageId: 'overridden', + node: typeNode, + }); + return true; + } + + return false; + } + + for (const typeNode of node.types) { + const tsNode = parserServices.esTreeNodeToTSNodeMap.get(typeNode); + const nodeType = checker.getTypeAtLocation(tsNode); + const typeParts = unionTypePartsUnlessBoolean(nodeType); + + for (const typePart of typeParts) { + if (checkUnionBottomAndTopTypes(typePart, typeNode)) { + continue; + } + + for (const literalTypeFlag of literalTypeFlags) { + if (typePart.flags === literalTypeFlag) { + addToMapGroup( + seenLiteralTypes, + literalToPrimitiveTypeFlags[literalTypeFlag], + { + literalValue: describeLiteralType(typePart), + typeNode, + }, + ); + break; + } + } + + for (const primitiveTypeFlag of primitiveTypeFlags) { + if (tsutils.isTypeFlagSet(nodeType, primitiveTypeFlag)) { + seenPrimitiveTypes.add(primitiveTypeFlag); + } + } + } + } + + interface TypeFlagWithText { + literalValue: unknown; + primitiveTypeFlag: PrimitiveTypeFlag; + } + + const overriddenTypeNodes = new Map< + TSESTree.TypeNode, + TypeFlagWithText[] + >(); + + // For each primitive type of all the seen literal types, + // if there was a primitive type seen that overrides it, + // upsert the literal text and primitive type under the backing type node + for (const [primitiveTypeFlag, typeNodesWithText] of seenLiteralTypes) { + if (seenPrimitiveTypes.has(primitiveTypeFlag)) { + for (const { literalValue, typeNode } of typeNodesWithText) { + addToMapGroup(overriddenTypeNodes, typeNode, { + literalValue, + primitiveTypeFlag, + }); + } + } + } + + // For each type node that had at least one overridden literal, + // group those literals by their primitive type, + // then report each primitive type with all its literals + for (const [typeNode, typeFlagsWithText] of overriddenTypeNodes) { + const grouped = arrayGroupByToMap( + typeFlagsWithText, + pair => pair.primitiveTypeFlag, + ); + + for (const [primitiveTypeFlag, pairs] of grouped) { + context.report({ + data: { + literal: pairs.map(pair => pair.literalValue).join(' | '), + primitive: primitiveTypeFlagNames[primitiveTypeFlag], + }, + messageId: 'literalOverridden', + node: typeNode, + }); + } + } + }, + }; + }, +}); diff --git a/packages/eslint-plugin/src/util/misc.ts b/packages/eslint-plugin/src/util/misc.ts index 4c6b71d2a4de..a3d892472494 100644 --- a/packages/eslint-plugin/src/util/misc.ts +++ b/packages/eslint-plugin/src/util/misc.ts @@ -23,6 +23,26 @@ function upperCaseFirst(str: string): string { return str[0].toUpperCase() + str.slice(1); } +function arrayGroupBy( + array: T[], + getKey: (item: T) => Key, +): Map { + const groups = new Map(); + + for (const item of array) { + const key = getKey(item); + const existing = groups.get(key); + + if (existing) { + existing.push(item); + } else { + groups.set(key, [item]); + } + } + + return groups; +} + /** Return true if both parameters are equal. */ type Equal = (a: T, b: T) => boolean; @@ -152,6 +172,7 @@ function formatWordList(words: string[]): string { } export { + arrayGroupBy, arraysAreEqual, Equal, ExcludeKeys, diff --git a/packages/eslint-plugin/tests/rules/no-redundant-type-constituents.test.ts b/packages/eslint-plugin/tests/rules/no-redundant-type-constituents.test.ts new file mode 100644 index 000000000000..20bb68bbdd5e --- /dev/null +++ b/packages/eslint-plugin/tests/rules/no-redundant-type-constituents.test.ts @@ -0,0 +1,461 @@ +import rule from '../../src/rules/no-redundant-type-constituents'; +import { RuleTester, getFixturesRootDir } from '../RuleTester'; + +const rootDir = getFixturesRootDir(); +const ruleTester = new RuleTester({ + parserOptions: { + ecmaVersion: 2021, + tsconfigRootDir: rootDir, + project: './tsconfig.json', + }, + parser: '@typescript-eslint/parser', +}); + +ruleTester.run('no-redundant-type-constituents', rule, { + valid: [ + 'type _ = any;', + 'type _ = never;', + 'type _ = () => never;', + 'type _ = () => never | string;', + 'type _ = () => string | never;', + 'type _ = { (): string | never };', + 'type _ = { new (): string | never };', + 'type _ = unknown;', + 'type _ = bigint;', + 'type _ = 1n | 2n;', + 'type _ = boolean;', + 'type _ = false | true;', + 'type _ = number;', + 'type _ = 1 | 2;', + 'type _ = 1 | false;', + 'type _ = string;', + "type _ = 'a' | 'b';", + 'type _ = bigint | null;', + 'type _ = boolean | null;', + 'type _ = number | null;', + 'type _ = string | null;', + 'type _ = bigint & null;', + 'type _ = boolean & null;', + 'type _ = number & null;', + 'type _ = string & null;', + ], + + invalid: [ + { + code: 'type _ = number | any;', + errors: [ + { + column: 19, + data: { + container: 'union', + typeName: 'any', + }, + messageId: 'overrides', + }, + ], + }, + { + code: 'type _ = any | number;', + errors: [ + { + column: 10, + data: { + container: 'union', + typeName: 'any', + }, + messageId: 'overrides', + }, + ], + }, + { + code: 'type _ = number | never;', + errors: [ + { + column: 19, + data: { + container: 'union', + typeName: 'never', + }, + messageId: 'overridden', + }, + ], + }, + { + code: 'type _ = never | number;', + errors: [ + { + column: 10, + data: { + container: 'union', + typeName: 'never', + }, + messageId: 'overridden', + }, + ], + }, + { + code: 'type _ = number | unknown;', + errors: [ + { + column: 19, + data: { + container: 'union', + typeName: 'unknown', + }, + messageId: 'overrides', + }, + ], + }, + { + code: 'type _ = unknown | number;', + errors: [ + { + column: 10, + data: { + container: 'union', + typeName: 'unknown', + }, + messageId: 'overrides', + }, + ], + }, + { + code: 'type _ = number | 0;', + errors: [ + { + column: 19, + data: { + literal: '0', + primitive: 'number', + }, + messageId: 'literalOverridden', + }, + ], + }, + { + code: 'type _ = number | (0 | 1);', + errors: [ + { + column: 20, + data: { + literal: '0 | 1', + primitive: 'number', + }, + messageId: 'literalOverridden', + }, + ], + }, + { + code: 'type _ = (0 | 0) | number;', + errors: [ + { + column: 11, + data: { + literal: '0', + primitive: 'number', + }, + messageId: 'literalOverridden', + }, + ], + }, + { + code: 'type _ = (0 | (0 | 0)) | number;', + errors: [ + { + column: 11, + data: { + literal: '0', + primitive: 'number', + }, + messageId: 'literalOverridden', + }, + ], + }, + { + code: 'type _ = (0 | 1) | number;', + errors: [ + { + column: 11, + data: { + literal: '0 | 1', + primitive: 'number', + }, + messageId: 'literalOverridden', + }, + ], + }, + { + code: 'type _ = (0 | (0 | 1)) | number;', + errors: [ + { + column: 11, + data: { + literal: '0 | 1', + primitive: 'number', + }, + messageId: 'literalOverridden', + }, + ], + }, + { + code: 'type _ = (0 | (1 | 2)) | number;', + errors: [ + { + column: 11, + data: { + literal: '0 | 1 | 2', + primitive: 'number', + }, + messageId: 'literalOverridden', + }, + ], + }, + { + code: "type _ = (2 | 'other' | 3) | number;", + errors: [ + { + column: 11, + data: { + literal: '2 | 3', + primitive: 'number', + }, + messageId: 'literalOverridden', + }, + ], + }, + { + code: "type _ = '' | string;", + errors: [ + { + column: 10, + data: { + literal: '""', + primitive: 'string', + }, + messageId: 'literalOverridden', + }, + ], + }, + { + code: 'type _ = `a${number}c` | string;', + errors: [ + { + column: 10, + data: { + literal: 'template literal type', + primitive: 'string', + }, + messageId: 'literalOverridden', + }, + ], + }, + { + code: 'type _ = `${number}` | string;', + errors: [ + { + column: 10, + data: { + literal: 'template literal type', + primitive: 'string', + }, + messageId: 'literalOverridden', + }, + ], + }, + { + code: 'type _ = 0n | bigint;', + errors: [ + { + column: 10, + data: { + literal: '0n', + primitive: 'bigint', + }, + messageId: 'literalOverridden', + }, + ], + }, + { + code: 'type _ = -1n | bigint;', + errors: [ + { + column: 10, + data: { + literal: '-1n', + primitive: 'bigint', + }, + messageId: 'literalOverridden', + }, + ], + }, + { + code: 'type _ = (-1n | 1n) | bigint;', + errors: [ + { + column: 11, + data: { + literal: '1n | -1n', + primitive: 'bigint', + }, + messageId: 'literalOverridden', + }, + ], + }, + { + code: 'type _ = false | boolean;', + errors: [ + { + column: 10, + data: { + literal: 'false', + primitive: 'boolean', + }, + messageId: 'literalOverridden', + }, + ], + }, + { + code: 'type _ = true | boolean;', + errors: [ + { + column: 10, + data: { + literal: 'true', + primitive: 'boolean', + }, + messageId: 'literalOverridden', + }, + ], + }, + { + code: 'type _ = number & any;', + errors: [ + { + column: 19, + data: { + container: 'intersection', + typeName: 'any', + }, + messageId: 'overrides', + }, + ], + }, + { + code: 'type _ = any & number;', + errors: [ + { + column: 10, + data: { + container: 'intersection', + typeName: 'any', + }, + messageId: 'overrides', + }, + ], + }, + { + code: 'type _ = number & never;', + errors: [ + { + column: 19, + data: { + container: 'intersection', + typeName: 'never', + }, + messageId: 'overrides', + }, + ], + }, + { + code: 'type _ = never & number;', + errors: [ + { + column: 10, + data: { + container: 'intersection', + typeName: 'never', + }, + messageId: 'overrides', + }, + ], + }, + { + code: 'type _ = number & unknown;', + errors: [ + { + column: 19, + data: { + container: 'intersection', + typeName: 'unknown', + }, + messageId: 'overridden', + }, + ], + }, + { + code: 'type _ = unknown & number;', + errors: [ + { + column: 10, + data: { + container: 'intersection', + typeName: 'unknown', + }, + messageId: 'overridden', + }, + ], + }, + { + code: 'type _ = number & 0;', + errors: [ + { + column: 10, + data: { + literal: '0', + primitive: 'number', + }, + messageId: 'primitiveOverridden', + }, + ], + }, + { + code: "type _ = '' & string;", + errors: [ + { + column: 15, + data: { + literal: '""', + primitive: 'string', + }, + messageId: 'primitiveOverridden', + }, + ], + }, + { + code: 'type _ = 0n & bigint;', + errors: [ + { + column: 15, + data: { + literal: '0n', + primitive: 'bigint', + }, + messageId: 'primitiveOverridden', + }, + ], + }, + { + code: 'type _ = -1n & bigint;', + errors: [ + { + column: 16, + data: { + literal: '-1n', + primitive: 'bigint', + }, + messageId: 'primitiveOverridden', + }, + ], + }, + ], +}); diff --git a/packages/type-utils/src/predicates.ts b/packages/type-utils/src/predicates.ts index 16afbb25ea89..dd7b35735b85 100644 --- a/packages/type-utils/src/predicates.ts +++ b/packages/type-utils/src/predicates.ts @@ -47,6 +47,13 @@ export function isTypeArrayTypeOrUnionOfArrayTypes( return true; } +/** + * @returns true if the type is `never` + */ +export function isTypeNeverType(type: ts.Type): boolean { + return isTypeFlagSet(type, ts.TypeFlags.Never); +} + /** * @returns true if the type is `unknown` */ @@ -150,3 +157,15 @@ export function typeIsOrHasBaseType( return false; } + +export function isTypeBigIntLiteralType( + type: ts.Type, +): type is ts.BigIntLiteralType { + return isTypeFlagSet(type, ts.TypeFlags.BigIntLiteral); +} + +export function isTypeTemplateLiteralType( + type: ts.Type, +): type is ts.TemplateLiteralType { + return isTypeFlagSet(type, ts.TypeFlags.TemplateLiteral); +}