UNPKG

eslint-plugin-complete

Version:

An ESLint plugin that contains useful rules.

446 lines (445 loc) 18.7 kB
import { getTypeName, isTypeReferenceType, } from "@typescript-eslint/type-utils"; import { ESLintUtils } from "@typescript-eslint/utils"; import ts from "typescript"; import { isSymbolFlagSet, isTypeFlagSet, unionTypeParts, } from "../typeUtils.js"; import { createRule } from "../utils.js"; const ALLOWED_TYPES_FOR_ANY_ENUM_ARGUMENT = ts.TypeFlags.Any | ts.TypeFlags.Unknown | ts.TypeFlags.Number | ts.TypeFlags.String; export const strictEnums = createRule({ name: "strict-enums", meta: { type: "problem", docs: { description: "Disallows the usage of unsafe enum patterns", recommended: true, requiresTypeChecking: true, }, schema: [], messages: { incorrectIncrement: "You cannot increment or decrement an enum type.", mismatchedAssignment: "The type of the enum assignment does not match the declared enum type of the variable.", mismatchedFunctionArgument: "The {{ ordinal }} argument in the function call does not match the declared enum type of the function signature.\nArgument: {{ type1 }}\nParameter: {{ type2 }}", }, }, defaultOptions: [], create(context) { const parserServices = ESLintUtils.getParserServices(context); const checker = parserServices.program.getTypeChecker(); // ---------------- // Helper functions // ---------------- /** * If passed an enum member, returns the type of the parent. Otherwise, returns itself. * * For example: * - `Fruit` --> `Fruit` * - `Fruit.Apple` --> `Fruit` */ function getBaseEnumType(type) { const symbol = type.getSymbol(); if (symbol === undefined) { return type; } if (!isSymbolFlagSet(symbol, ts.SymbolFlags.EnumMember)) { return type; } const { valueDeclaration } = symbol; if (valueDeclaration === undefined) { return type; } const parentType = getTypeFromTSNode(valueDeclaration.parent); return parentType; } /** * A thing can have 0 or more enum types. For example: * - 123 --> [] * - {} --> [] * - Fruit.Apple --> [Fruit] * - Fruit.Apple | Vegetable.Lettuce --> [Fruit, Vegetable] * - Fruit.Apple | Vegetable.Lettuce | 123 --> [Fruit, Vegetable] * - T extends Fruit --> [Fruit] */ function getEnumTypes(type) { /** * First, we get all the parts of the union. For non-union types, this will be an array with * the type in it. For example: * - Fruit --> [Fruit] * - Fruit | Vegetable --> [Fruit, Vegetable] */ const subTypes = unionTypeParts(type); /** * Next, we must resolve generic types with constraints. For example: * - Fruit --> Fruit * - T extends Fruit --> Fruit */ const subTypesConstraints = subTypes.map((subType) => { const constraint = subType.getConstraint(); return constraint ?? subType; }); const enumSubTypes = subTypesConstraints.filter((subType) => isEnum(subType)); const baseEnumSubTypes = enumSubTypes.map((subType) => getBaseEnumType(subType)); return new Set(baseEnumSubTypes); } function getTypeFromNode(node) { const tsNode = parserServices.esTreeNodeToTSNodeMap.get(node); return getTypeFromTSNode(tsNode); } function getTypeFromTSNode(tsNode) { return checker.getTypeAtLocation(tsNode); } function hasEnumTypes(type) { const enumTypes = getEnumTypes(type); return enumTypes.size > 0; } // -------------- // Main functions // -------------- function checkCallExpression(node) { const tsNode = parserServices.esTreeNodeToTSNodeMap.get(node); const signature = checker.getResolvedSignature(tsNode); if (signature === undefined) { return; } const declaration = signature.getDeclaration(); // The `getDeclaration` method actually returns `ts.SignatureDeclaration | undefined`, not // `ts.SignatureDeclaration`. // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (declaration === undefined) { return; } // First, determine if this is a function with a `this` parameter. let firstParamIsThis = false; const firstParameter = declaration.parameters[0]; if (firstParameter !== undefined) { const parameterName = firstParameter.name.getText(); firstParamIsThis = parameterName === "this"; } /** * Iterate through the arguments provided to the call function and cross reference their types * to the types of the "real" function parameters. */ for (const [i, argument] of node.arguments.entries()) { const argumentType = getTypeFromNode(argument); let parameterType = signature.getTypeParameterAtPosition(i); /** * If this function parameter is a generic type that extends another type, we want to * compare the calling argument to the constraint instead. * * For example: * * ```ts * function useFruit<FruitType extends Fruit>(fruitType: FruitType) {} * useFruit(0) * ``` * * Here, we want to compare `Fruit.Apple` to `Fruit`, not `FruitType`, because `FruitType` * would just be equal to 0 in this case (and would be unsafe). * * Finally, if the function has a `this` parameter, getting a constraint will mess things * up, so we skip checking for a constraint if this is the case. */ if (!firstParamIsThis) { const parameter = declaration.parameters[i]; if (parameter !== undefined) { const parameterTSNode = getTypeFromTSNode(parameter); const constraint = parameterTSNode.getConstraint(); if (constraint !== undefined) { parameterType = constraint; } } } /** * Disallow mismatched function calls, like the following: * * ```ts * function useFruit(fruit: Fruit) {} * useFruit(0); * ``` */ if (isMismatchedEnumFunctionArgument(argumentType, parameterType)) { context.report({ node: argument, messageId: "mismatchedFunctionArgument", data: { ordinal: getOrdinalSuffix(i + 1), // e.g. 0 --> 1st type1: getTypeName(checker, argumentType), type2: getTypeName(checker, parameterType), }, }); } } } function isAssigningNonEnumValueToEnumVariable(leftType, rightType) { /** * First, recursively check for containers like the following: * * ```ts * declare let fruits: Fruit[]; * fruits = [0, 1]; * ``` */ if (isTypeReferenceType(leftType) && isTypeReferenceType(rightType)) { const leftTypeArguments = checker.getTypeArguments(leftType); const rightTypeArguments = checker.getTypeArguments(rightType); // eslint-disable-next-line unicorn/no-for-loop for (let i = 0; i < leftTypeArguments.length; i++) { const leftTypeArgument = leftTypeArguments[i]; const rightTypeArgument = rightTypeArguments[i]; if (leftTypeArgument === undefined || rightTypeArgument === undefined) { continue; } if (isAssigningNonEnumValueToEnumVariable(leftTypeArgument, rightTypeArgument)) { return true; } } return false; } const leftEnumTypes = getEnumTypes(leftType); if (leftEnumTypes.size === 0) { // This is not an enum assignment. return false; } /** * As a special case, allow assignment of certain types that the TypeScript compiler should * handle properly. */ if (isNullOrUndefinedOrAnyOrUnknownOrNever(rightType)) { return false; } const rightEnumTypes = getEnumTypes(rightType); const intersectingTypes = getIntersectingSet(leftEnumTypes, rightEnumTypes); return intersectingTypes.size === 0; } function isMismatchedEnumFunctionArgument(argumentType, // From the function call parameterType) { /** * First, recursively check for functions with type containers like the following: * * ```ts * function useFruits(fruits: Fruit[]) {} * useFruits([0, 1]); * ``` */ if (isTypeReferenceType(argumentType)) { const argumentTypeArguments = checker.getTypeArguments(argumentType); const parameterSubTypes = unionTypeParts(parameterType); for (const parameterSubType of parameterSubTypes) { if (!isTypeReferenceType(parameterSubType)) { continue; } const parameterTypeArguments = checker.getTypeArguments(parameterSubType); // eslint-disable-next-line unicorn/no-for-loop for (let i = 0; i < argumentTypeArguments.length; i++) { const argumentTypeArgument = argumentTypeArguments[i]; const parameterTypeArgument = parameterTypeArguments[i]; if (argumentTypeArgument === undefined || parameterTypeArgument === undefined) { continue; } if (isMismatchedEnumFunctionArgument(argumentTypeArgument, parameterTypeArgument)) { return true; } } } return false; } /** * Allow function calls that have nothing to do with enums, like the following: * * ```ts * function useNumber(num: number) {} * useNumber(0); * ``` */ const argumentEnumTypes = getEnumTypes(argumentType); const parameterEnumTypes = getEnumTypes(parameterType); if (parameterEnumTypes.size === 0) { return false; } /** * Allow passing enum values into functions that take in the "any" type and similar types that * should basically match any enum, like the following: * * ```ts * function useNumber(num: number) {} * useNumber(Fruit.Apple); * ``` */ const parameterSubTypes = unionTypeParts(parameterType); for (const parameterSubType of parameterSubTypes) { if (isTypeFlagSet(parameterSubType, ALLOWED_TYPES_FOR_ANY_ENUM_ARGUMENT)) { return false; } } /** * Disallow passing number literals or string literals into functions that take in an enum, * like the following: * * ```ts * function useFruit(fruit: Fruit) {} * declare const fruit: Fruit.Apple | 1; * useFruit(fruit) * ``` */ const argumentSubTypes = unionTypeParts(argumentType); for (const argumentSubType of argumentSubTypes) { if (argumentSubType.isLiteral() && !isEnum(argumentSubType) // Allow passing number literals if there are number literals in the actual function type. && !parameterSubTypes.includes(argumentSubType)) { return true; } } /** * Allow function calls that match one of the types in a union, like the following: * * ```ts * function useApple(fruitOrNull: Fruit | null) {} * useApple(null); * ``` */ const argumentSubTypesSet = new Set(argumentSubTypes); const parameterSubTypesSet = new Set(parameterSubTypes); if (setHasAnyElementFromSet(argumentSubTypesSet, parameterSubTypesSet)) { return false; } /** * Allow function calls that have a base enum that match the function type, like the * following: * * ```ts * function useFruit(fruit: Fruit) {} * useFruit(Fruit.Apple); * ``` */ if (setHasAnyElementFromSet(argumentEnumTypes, parameterEnumTypes)) { return false; } return true; } // ------------------ // AST node callbacks // ------------------ return { /** When something is assigned to a variable. */ AssignmentExpression(node) { const leftType = getTypeFromNode(node.left); const rightType = getTypeFromNode(node.right); if (isAssigningNonEnumValueToEnumVariable(leftType, rightType)) { context.report({ node, messageId: "mismatchedAssignment", }); } }, /** When a function is invoked or a class is instantiated. */ "CallExpression, NewExpression": function callExpressionOrNewExpression(node) { checkCallExpression(node); }, /** When a unary operator is invoked. */ UpdateExpression(node) { const argumentType = getTypeFromNode(node.argument); /** * Disallow using enums with unary operators, like the following: * * ```ts * const fruit = Fruit.Apple; * fruit++; * ``` */ if (hasEnumTypes(argumentType)) { context.report({ node, messageId: "incorrectIncrement", }); } }, /** When a new variable is created. */ VariableDeclarator(node) { /** * Allow enum declarations without an initializer, like the following: * * ```ts * let fruit: Fruit; * if (something()) { * fruit = Fruit.Apple; * } else { * fruit = Fruit.Banana; * } * ``` */ if (node.init === null) { return; } const leftTSNode = parserServices.esTreeNodeToTSNodeMap.get(node); const rightNode = leftTSNode.initializer; if (rightNode === undefined) { return; } /** * We have to use `leftTSNode.name` instead of `leftTSNode` to avoid run-time errors because * the `checker.getTypeAtLocation` method expects a `ts.BindingName` instead of a * `ts.VariableDeclaration`. * https://github.com/microsoft/TypeScript/issues/48878 */ const leftType = getTypeFromTSNode(leftTSNode.name); const rightType = getTypeFromTSNode(rightNode); if (isAssigningNonEnumValueToEnumVariable(leftType, rightType)) { context.report({ node, messageId: "mismatchedAssignment", data: { assignmentType: getTypeName(checker, rightType), declaredType: getTypeName(checker, leftType), }, }); } }, }; }, }); /** Given a set A and set B, return a set that contains only elements that are in both sets. */ function getIntersectingSet(a, b) { const intersectingValues = [...a.values()].filter((value) => b.has(value)); return new Set(intersectingValues); } /** * From: * https://stackoverflow.com/questions/13627308/add-st-nd-rd-and-th-ordinal-suffix-to-a-number */ function getOrdinalSuffix(i) { const j = i % 10; const k = i % 100; if (j === 1 && k !== 11) { return `${i}st`; } if (j === 2 && k !== 12) { return `${i}nd`; } if (j === 3 && k !== 13) { return `${i}rd`; } return `${i}th`; } function isEnum(type) { /** The "EnumLiteral" flag will be set on both enum base types and enum members/values. */ return isTypeFlagSet(type, ts.TypeFlags.EnumLiteral); } function isNullOrUndefinedOrAnyOrUnknownOrNever(...types) { return types.some((type) => isTypeFlagSet(type, ts.TypeFlags.Null | ts.TypeFlags.Undefined | ts.TypeFlags.Any | ts.TypeFlags.Unknown | ts.TypeFlags.Never)); } function setHasAnyElementFromSet(set1, set2) { for (const value of set2) { if (set1.has(value)) { return true; } } return false; }