Skip to content

Commit

Permalink
feat(eslint-plugin): add no-redundant-type-constituents rule
Browse files Browse the repository at this point in the history
  • Loading branch information
JoshuaKGoldberg committed Dec 31, 2021
1 parent a9eb0b9 commit b8bc780
Show file tree
Hide file tree
Showing 5 changed files with 827 additions and 2 deletions.
5 changes: 3 additions & 2 deletions .cspell.json
Expand Up @@ -71,8 +71,8 @@
"IIFE",
"IIFEs",
"linebreaks",
"markdownlint",
"lzstring",
"markdownlint",
"necroing",
"nocheck",
"nullish",
Expand Down Expand Up @@ -101,14 +101,15 @@
"transpiled",
"transpiles",
"transpiling",
"tsvfs",
"tsconfigs",
"tsutils",
"tsvfs",
"typedef",
"typedefs",
"unfixable",
"unoptimized",
"unprefixed",
"upsert",
"Zacher"
],
"overrides": [
Expand Down
323 changes: 323 additions & 0 deletions packages/eslint-plugin/src/rules/no-redundant-type-constituents.ts
@@ -0,0 +1,323 @@
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';

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<Key, Value>(
map: Map<Key, Value[]>,
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<PrimitiveTypeFlag, string[]>();
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<PrimitiveTypeFlag>();

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 = util.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,
});
}
}
},
};
},
});
21 changes: 21 additions & 0 deletions packages/eslint-plugin/src/util/misc.ts
Expand Up @@ -23,6 +23,26 @@ function upperCaseFirst(str: string): string {
return str[0].toUpperCase() + str.slice(1);
}

function arrayGroupBy<T, Key extends string | number>(
array: T[],
getKey: (item: T) => Key,
): Map<Key, T[]> {
const groups = new Map<Key, T[]>();

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<T> = (a: T, b: T) => boolean;

Expand Down Expand Up @@ -152,6 +172,7 @@ function formatWordList(words: string[]): string {
}

export {
arrayGroupBy,
arraysAreEqual,
Equal,
ExcludeKeys,
Expand Down

0 comments on commit b8bc780

Please sign in to comment.