diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md index cf0073d35f0..67428be1e01 100644 --- a/.github/ISSUE_TEMPLATE.md +++ b/.github/ISSUE_TEMPLATE.md @@ -73,6 +73,7 @@ i.e. eslint --ext ".ts,.js" src --debug | `@typescript-eslint/parser` | `X.Y.Z` | | `@typescript-eslint/typescript-estree` | `X.Y.Z` | | `@typescript-eslint/experimental-utils` | `X.Y.Z` | +| `@typescript-eslint/type-utils` | `X.Y.Z` | | `TypeScript` | `X.Y.Z` | | `node` | `X.Y.Z` | | `npm` | `X.Y.Z` | diff --git a/.github/ISSUE_TEMPLATE/typescript-eslint-type-utils.md b/.github/ISSUE_TEMPLATE/typescript-eslint-type-utils.md new file mode 100644 index 00000000000..c2f8229829c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/typescript-eslint-type-utils.md @@ -0,0 +1,72 @@ +--- +name: '@typescript-eslint/type-utils' +about: Report an issue with the '@typescript-eslint/type-utils' package +title: '' +labels: 'package: type-utils, triage' +assignees: '' +--- + + + + + +- [ ] I have tried restarting my IDE and the issue persists. +- [ ] I have updated to the latest version of the packages. +- [ ] I have [read the FAQ](https://github.com/typescript-eslint/typescript-eslint/blob/master/docs/getting-started/linting/FAQ.md) and my problem is not listed. + +**Repro** + + + +```TS +// your repro code case +``` + +**Expected Result** + + + +**Actual Result** + + + +**Additional Info** + + + +**Versions** + +| package | version | +| ------------------------------- | ------- | +| `@typescript-eslint/type-utils` | `X.Y.Z` | +| `@typescript-eslint/type-utils` | `X.Y.Z` | +| `TypeScript` | `X.Y.Z` | +| `node` | `X.Y.Z` | diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7af2bdb414e..f05912ef76a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -116,6 +116,11 @@ jobs: env: CI: true + - name: Run unit tests for type-utils + run: npx nx test @typescript-eslint/type-utils + env: + CI: true + - name: Run unit tests for parser run: npx nx test @typescript-eslint/parser env: @@ -283,6 +288,11 @@ jobs: env: CI: true + - name: Run unit tests for type-utils + run: npx nx test @typescript-eslint/type-utils + env: + CI: true + - name: Run unit tests for parser run: npx nx test @typescript-eslint/parser env: diff --git a/.vscode/launch.json b/.vscode/launch.json index 5ad1e226457..0162b9ae831 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -24,6 +24,8 @@ "${workspaceFolder}/packages/experimental-utils/dist/index.js", "${workspaceFolder}/packages/experimental-utils/src/ts-estree.ts", "${workspaceFolder}/packages/experimental-utils/dist/ts-estree.js", + "${workspaceFolder}/packages/type-utils/src/index.ts", + "${workspaceFolder}/packages/type-utils/dist/index.js", "${workspaceFolder}/packages/parser/src/index.ts", "${workspaceFolder}/packages/parser/dist/index.js", "${workspaceFolder}/packages/typescript-estree/src/index.ts", @@ -56,6 +58,8 @@ "${workspaceFolder}/packages/experimental-utils/dist/index.js", "${workspaceFolder}/packages/experimental-utils/src/ts-estree.ts", "${workspaceFolder}/packages/experimental-utils/dist/ts-estree.js", + "${workspaceFolder}/packages/type-utils/src/index.ts", + "${workspaceFolder}/packages/type-utils/dist/index.js", "${workspaceFolder}/packages/parser/src/index.ts", "${workspaceFolder}/packages/parser/dist/index.js", "${workspaceFolder}/packages/typescript-estree/src/index.ts", @@ -88,6 +92,8 @@ "${workspaceFolder}/packages/experimental-utils/dist/index.js", "${workspaceFolder}/packages/experimental-utils/src/ts-estree.ts", "${workspaceFolder}/packages/experimental-utils/dist/ts-estree.js", + "${workspaceFolder}/packages/type-utils/src/ts-estree.ts", + "${workspaceFolder}/packages/type-utils/dist/ts-estree.js", "${workspaceFolder}/packages/parser/src/index.ts", "${workspaceFolder}/packages/parser/dist/index.js", "${workspaceFolder}/packages/typescript-estree/src/index.ts", @@ -120,6 +126,8 @@ "${workspaceFolder}/packages/experimental-utils/dist/index.js", "${workspaceFolder}/packages/experimental-utils/src/ts-estree.ts", "${workspaceFolder}/packages/experimental-utils/dist/ts-estree.js", + "${workspaceFolder}/packages/type-utils/src/index.ts", + "${workspaceFolder}/packages/type-utils/dist/index.js", "${workspaceFolder}/packages/parser/src/index.ts", "${workspaceFolder}/packages/parser/dist/index.js", "${workspaceFolder}/packages/typescript-estree/src/index.ts", @@ -152,6 +160,42 @@ "${workspaceFolder}/packages/experimental-utils/dist/index.js", "${workspaceFolder}/packages/experimental-utils/src/ts-estree.ts", "${workspaceFolder}/packages/experimental-utils/dist/ts-estree.js", + "${workspaceFolder}/packages/type-utils/src/index.ts", + "${workspaceFolder}/packages/type-utils/dist/index.js", + "${workspaceFolder}/packages/parser/src/index.ts", + "${workspaceFolder}/packages/parser/dist/index.js", + "${workspaceFolder}/packages/typescript-estree/src/index.ts", + "${workspaceFolder}/packages/typescript-estree/dist/index.js", + "${workspaceFolder}/packages/types/src/index.ts", + "${workspaceFolder}/packages/types/dist/index.js", + "${workspaceFolder}/packages/visitor-keys/src/index.ts", + "${workspaceFolder}/packages/visitor-keys/dist/index.js", + "${workspaceFolder}/packages/scope-manager/dist/index.js", + "${workspaceFolder}/packages/scope-manager/dist/index.js", + ], + }, + { + "type": "node", + "request": "launch", + "name": "Run currently opened type-utils test", + "cwd": "${workspaceFolder}/packages/type-utils/", + "program": "${workspaceFolder}/node_modules/jest/bin/jest.js", + "args": [ + "--runInBand", + "--no-cache", + "--no-coverage", + "${fileBasenameNoExtension}" + ], + "sourceMaps": true, + "console": "integratedTerminal", + "internalConsoleOptions": "neverOpen", + "skipFiles": [ + "${workspaceFolder}/packages/experimental-utils/src/index.ts", + "${workspaceFolder}/packages/experimental-utils/dist/index.js", + "${workspaceFolder}/packages/experimental-utils/src/ts-estree.ts", + "${workspaceFolder}/packages/experimental-utils/dist/ts-estree.js", + "${workspaceFolder}/packages/type-utils/src/index.ts", + "${workspaceFolder}/packages/type-utils/dist/index.js", "${workspaceFolder}/packages/parser/src/index.ts", "${workspaceFolder}/packages/parser/dist/index.js", "${workspaceFolder}/packages/typescript-estree/src/index.ts", @@ -184,6 +228,8 @@ "${workspaceFolder}/packages/experimental-utils/dist/index.js", "${workspaceFolder}/packages/experimental-utils/src/ts-estree.ts", "${workspaceFolder}/packages/experimental-utils/dist/ts-estree.js", + "${workspaceFolder}/packages/type-utils/src/index.ts", + "${workspaceFolder}/packages/type-utils/dist/index.js", "${workspaceFolder}/packages/parser/src/index.ts", "${workspaceFolder}/packages/parser/dist/index.js", "${workspaceFolder}/packages/typescript-estree/src/index.ts", diff --git a/package.json b/package.json index 93f74783e63..d2df363bc50 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,6 @@ "start": "nx run website:start", "test": "nx run-many --target=test --all --parallel", "test-integration": "yarn jest -c ./tests/integration/jest.config.js", - "test-kill-integration-containers": "docker-compose -f tests/integration/docker-compose.yml down -v --rmi local", "typecheck": "nx run-many --target=typecheck --all --parallel" }, "config": { diff --git a/packages/ast-spec/README.md b/packages/ast-spec/README.md index 7c954398395..6d29fecd24e 100644 --- a/packages/ast-spec/README.md +++ b/packages/ast-spec/README.md @@ -16,7 +16,7 @@ It includes: **You probably don't want to use it directly.** -If you're building an ESLint plugin, consider using [`@typescript-eslint/experimental-utils`](../experimental-utils). +If you're building an ESLint plugin, consider using [`@typescript-eslint/experimental-utils`](../experimental-utils) and [`@typescript-eslint/type-utils`](../type-utils). If you're parsing TypeScript code, consider using [`@typescript-eslint/typescript-estree`](../typescript-estree). ## Contributing diff --git a/packages/eslint-plugin/package.json b/packages/eslint-plugin/package.json index d813840aff4..ad6bbd7f109 100644 --- a/packages/eslint-plugin/package.json +++ b/packages/eslint-plugin/package.json @@ -46,6 +46,7 @@ "dependencies": { "@typescript-eslint/experimental-utils": "5.8.1", "@typescript-eslint/scope-manager": "5.8.1", + "@typescript-eslint/type-utils": "5.8.1", "debug": "^4.3.2", "functional-red-black-tree": "^1.0.1", "ignore": "^5.1.8", diff --git a/packages/eslint-plugin/src/rules/no-unnecessary-boolean-literal-compare.ts b/packages/eslint-plugin/src/rules/no-unnecessary-boolean-literal-compare.ts index 41367d4115b..4474713b3ff 100644 --- a/packages/eslint-plugin/src/rules/no-unnecessary-boolean-literal-compare.ts +++ b/packages/eslint-plugin/src/rules/no-unnecessary-boolean-literal-compare.ts @@ -158,7 +158,7 @@ export default util.createRule({ function deconstructComparison( node: TSESTree.BinaryExpression, ): BooleanComparison | undefined { - const comparisonType = util.getEqualsKind(node.operator); + const comparisonType = getEqualsKind(node.operator); if (!comparisonType) { return undefined; } @@ -275,3 +275,39 @@ export default util.createRule({ }; }, }); + +interface EqualsKind { + isPositive: boolean; + isStrict: boolean; +} + +function getEqualsKind(operator: string): EqualsKind | undefined { + switch (operator) { + case '==': + return { + isPositive: true, + isStrict: false, + }; + + case '===': + return { + isPositive: true, + isStrict: true, + }; + + case '!=': + return { + isPositive: false, + isStrict: false, + }; + + case '!==': + return { + isPositive: false, + isStrict: true, + }; + + default: + return undefined; + } +} diff --git a/packages/eslint-plugin/src/rules/no-unnecessary-condition.ts b/packages/eslint-plugin/src/rules/no-unnecessary-condition.ts index 4e73c0623ad..48a3a4d039d 100644 --- a/packages/eslint-plugin/src/rules/no-unnecessary-condition.ts +++ b/packages/eslint-plugin/src/rules/no-unnecessary-condition.ts @@ -17,6 +17,7 @@ import { createRule, getParserServices, getConstrainedTypeAtLocation, + getTypeOfPropertyOfName, isNullableType, nullThrows, NullThrowsReasons, @@ -24,7 +25,6 @@ import { isTypeAnyType, isTypeUnknownType, getTypeName, - getTypeOfPropertyOfName, } from '../util'; // Truthiness utilities diff --git a/packages/eslint-plugin/src/util/collectUnusedVariables.ts b/packages/eslint-plugin/src/util/collectUnusedVariables.ts index f62d7588753..fe44354fc30 100644 --- a/packages/eslint-plugin/src/util/collectUnusedVariables.ts +++ b/packages/eslint-plugin/src/util/collectUnusedVariables.ts @@ -3,10 +3,10 @@ import { TSESLint, ASTUtils, TSESTree, + ESLintUtils, } from '@typescript-eslint/experimental-utils'; import { ImplicitLibVariable } from '@typescript-eslint/scope-manager'; import { Visitor } from '@typescript-eslint/scope-manager/dist/referencer/Visitor'; -import { nullThrows } from './nullThrows'; class UnusedVarsVisitor< TMessageIds extends string, @@ -25,7 +25,7 @@ class UnusedVarsVisitor< visitChildrenEvenIfSelectorExists: true, }); - this.#scopeManager = nullThrows( + this.#scopeManager = ESLintUtils.nullThrows( context.getSourceCode().scopeManager, 'Missing required scope manager', ); diff --git a/packages/eslint-plugin/src/util/explicitReturnTypeUtils.ts b/packages/eslint-plugin/src/util/explicitReturnTypeUtils.ts index fd512749dfb..c69f8591ae7 100644 --- a/packages/eslint-plugin/src/util/explicitReturnTypeUtils.ts +++ b/packages/eslint-plugin/src/util/explicitReturnTypeUtils.ts @@ -1,11 +1,11 @@ import { TSESTree, AST_NODE_TYPES, + ESLintUtils, TSESLint, } from '@typescript-eslint/experimental-utils'; import { isTypeAssertion, isConstructor, isSetter } from './astUtils'; import { getFunctionHeadLoc } from './getFunctionHeadLoc'; -import { nullThrows, NullThrowsReasons } from './nullThrows'; type FunctionExpression = | TSESTree.ArrowFunctionExpression @@ -187,7 +187,10 @@ function isTypedFunctionExpression( node: FunctionExpression, options: Options, ): boolean { - const parent = nullThrows(node.parent, NullThrowsReasons.MissingParent); + const parent = ESLintUtils.nullThrows( + node.parent, + ESLintUtils.NullThrowsReasons.MissingParent, + ); if (!options.allowTypedFunctionExpressions) { return false; @@ -215,7 +218,10 @@ function isValidFunctionExpressionReturnType( return true; } - const parent = nullThrows(node.parent, NullThrowsReasons.MissingParent); + const parent = ESLintUtils.nullThrows( + node.parent, + ESLintUtils.NullThrowsReasons.MissingParent, + ); if ( options.allowExpressions && parent.type !== AST_NODE_TYPES.VariableDeclarator && diff --git a/packages/eslint-plugin/src/util/getESLintCoreRule.ts b/packages/eslint-plugin/src/util/getESLintCoreRule.ts index 1701b4ef1d7..7c2427c8fcb 100644 --- a/packages/eslint-plugin/src/util/getESLintCoreRule.ts +++ b/packages/eslint-plugin/src/util/getESLintCoreRule.ts @@ -1,6 +1,6 @@ +import { ESLintUtils } from '@typescript-eslint/experimental-utils'; import { version } from 'eslint/package.json'; import * as semver from 'semver'; -import { nullThrows } from './nullThrows'; const isESLintV8 = semver.major(version) >= 8; @@ -42,7 +42,7 @@ type RuleId = keyof RuleMap; export const getESLintCoreRule: (ruleId: R) => RuleMap[R] = isESLintV8 ? (ruleId: R): RuleMap[R] => - nullThrows( + ESLintUtils.nullThrows( // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call require('eslint/use-at-your-own-risk').builtinRules.get( ruleId, diff --git a/packages/eslint-plugin/src/util/getWrappingFixer.ts b/packages/eslint-plugin/src/util/getWrappingFixer.ts index d5efa8bfca7..aa01520fdbd 100644 --- a/packages/eslint-plugin/src/util/getWrappingFixer.ts +++ b/packages/eslint-plugin/src/util/getWrappingFixer.ts @@ -4,7 +4,6 @@ import { ASTUtils, TSESTree, } from '@typescript-eslint/experimental-utils'; -import { SourceCode } from '@typescript-eslint/experimental-utils/src/ts-eslint'; interface WrappingFixerParams { /** Source code. */ @@ -135,7 +134,7 @@ function isWeakPrecedenceParent(node: TSESTree.Node): boolean { */ function isMissingSemicolonBefore( node: TSESTree.Node, - sourceCode: SourceCode, + sourceCode: TSESLint.SourceCode, ): boolean { for (;;) { const parent = node.parent!; diff --git a/packages/eslint-plugin/src/util/index.ts b/packages/eslint-plugin/src/util/index.ts index 4c028489523..48641babf28 100644 --- a/packages/eslint-plugin/src/util/index.ts +++ b/packages/eslint-plugin/src/util/index.ts @@ -6,17 +6,19 @@ export * from './createRule'; export * from './getFunctionHeadLoc'; export * from './getThisExpression'; export * from './getWrappingFixer'; -export * from './isTypeReadonly'; export * from './misc'; -export * from './nullThrows'; export * from './objectIterators'; -export * from './propertyTypes'; -export * from './requiresQuoting'; -export * from './types'; // this is done for convenience - saves migrating all of the old rules -const { applyDefault, deepMerge, isObjectNotArray, getParserServices } = - ESLintUtils; +export * from '@typescript-eslint/type-utils'; +const { + applyDefault, + deepMerge, + isObjectNotArray, + getParserServices, + nullThrows, + NullThrowsReasons, +} = ESLintUtils; type InferMessageIdsTypeFromRule = ESLintUtils.InferMessageIdsTypeFromRule; type InferOptionsTypeFromRule = ESLintUtils.InferOptionsTypeFromRule; @@ -26,6 +28,8 @@ export { deepMerge, isObjectNotArray, getParserServices, + nullThrows, InferMessageIdsTypeFromRule, InferOptionsTypeFromRule, + NullThrowsReasons, }; diff --git a/packages/eslint-plugin/src/util/misc.ts b/packages/eslint-plugin/src/util/misc.ts index 052e7bfd06c..4c6b71d2a4d 100644 --- a/packages/eslint-plugin/src/util/misc.ts +++ b/packages/eslint-plugin/src/util/misc.ts @@ -7,7 +7,7 @@ import { TSESLint, TSESTree, } from '@typescript-eslint/experimental-utils'; -import { requiresQuoting } from './requiresQuoting'; +import { requiresQuoting } from '@typescript-eslint/type-utils'; /** * Check if the context file name is *.d.ts or *.d.tsx diff --git a/packages/eslint-plugin/src/util/types.ts b/packages/eslint-plugin/src/util/types.ts deleted file mode 100644 index 089620e702a..00000000000 --- a/packages/eslint-plugin/src/util/types.ts +++ /dev/null @@ -1,538 +0,0 @@ -import { - AST_NODE_TYPES, - TSESTree, -} from '@typescript-eslint/experimental-utils'; -import debug from 'debug'; -import { - isCallExpression, - isJsxExpression, - isIdentifier, - isNewExpression, - isParameterDeclaration, - isPropertyDeclaration, - isTypeReference, - isUnionOrIntersectionType, - isVariableDeclaration, - unionTypeParts, - isPropertyAssignment, - isBinaryExpression, -} from 'tsutils'; -import * as ts from 'typescript'; - -const log = debug('typescript-eslint:eslint-plugin:utils:types'); - -/** - * Checks if the given type is either an array type, - * or a union made up solely of array types. - */ -export function isTypeArrayTypeOrUnionOfArrayTypes( - type: ts.Type, - checker: ts.TypeChecker, -): boolean { - for (const t of unionTypeParts(type)) { - if (!checker.isArrayType(t)) { - return false; - } - } - - return true; -} - -/** - * @param type Type being checked by name. - * @param allowedNames Symbol names checking on the type. - * @returns Whether the type is, extends, or contains all of the allowed names. - */ -export function containsAllTypesByName( - type: ts.Type, - allowAny: boolean, - allowedNames: Set, -): boolean { - if (isTypeFlagSet(type, ts.TypeFlags.Any | ts.TypeFlags.Unknown)) { - return !allowAny; - } - - if (isTypeReference(type)) { - type = type.target; - } - - const symbol = type.getSymbol(); - if (symbol && allowedNames.has(symbol.name)) { - return true; - } - - if (isUnionOrIntersectionType(type)) { - return type.types.every(t => - containsAllTypesByName(t, allowAny, allowedNames), - ); - } - - const bases = type.getBaseTypes(); - return ( - typeof bases !== 'undefined' && - bases.length > 0 && - bases.every(t => containsAllTypesByName(t, allowAny, allowedNames)) - ); -} - -/** - * Get the type name of a given type. - * @param typeChecker The context sensitive TypeScript TypeChecker. - * @param type The type to get the name of. - */ -export function getTypeName( - typeChecker: ts.TypeChecker, - type: ts.Type, -): string { - // It handles `string` and string literal types as string. - if ((type.flags & ts.TypeFlags.StringLike) !== 0) { - return 'string'; - } - - // If the type is a type parameter which extends primitive string types, - // but it was not recognized as a string like. So check the constraint - // type of the type parameter. - if ((type.flags & ts.TypeFlags.TypeParameter) !== 0) { - // `type.getConstraint()` method doesn't return the constraint type of - // the type parameter for some reason. So this gets the constraint type - // via AST. - const symbol = type.getSymbol(); - const decls = symbol?.getDeclarations(); - const typeParamDecl = decls?.[0] as ts.TypeParameterDeclaration; - if ( - ts.isTypeParameterDeclaration(typeParamDecl) && - typeParamDecl.constraint != null - ) { - return getTypeName( - typeChecker, - typeChecker.getTypeFromTypeNode(typeParamDecl.constraint), - ); - } - } - - // If the type is a union and all types in the union are string like, - // return `string`. For example: - // - `"a" | "b"` is string. - // - `string | string[]` is not string. - if ( - type.isUnion() && - type.types - .map(value => getTypeName(typeChecker, value)) - .every(t => t === 'string') - ) { - return 'string'; - } - - // If the type is an intersection and a type in the intersection is string - // like, return `string`. For example: `string & {__htmlEscaped: void}` - if ( - type.isIntersection() && - type.types - .map(value => getTypeName(typeChecker, value)) - .some(t => t === 'string') - ) { - return 'string'; - } - - return typeChecker.typeToString(type); -} - -/** - * Resolves the given node's type. Will resolve to the type's generic constraint, if it has one. - */ -export function getConstrainedTypeAtLocation( - checker: ts.TypeChecker, - node: ts.Node, -): ts.Type { - const nodeType = checker.getTypeAtLocation(node); - const constrained = checker.getBaseConstraintOfType(nodeType); - - return constrained ?? nodeType; -} - -/** - * Checks if the given type is (or accepts) nullable - * @param isReceiver true if the type is a receiving type (i.e. the type of a called function's parameter) - */ -export function isNullableType( - type: ts.Type, - { - isReceiver = false, - allowUndefined = true, - }: { isReceiver?: boolean; allowUndefined?: boolean } = {}, -): boolean { - const flags = getTypeFlags(type); - - if (isReceiver && flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown)) { - return true; - } - - if (allowUndefined) { - return (flags & (ts.TypeFlags.Null | ts.TypeFlags.Undefined)) !== 0; - } else { - return (flags & ts.TypeFlags.Null) !== 0; - } -} - -/** - * Gets the declaration for the given variable - */ -export function getDeclaration( - checker: ts.TypeChecker, - node: ts.Expression, -): ts.Declaration | null { - const symbol = checker.getSymbolAtLocation(node); - if (!symbol) { - return null; - } - const declarations = symbol.getDeclarations(); - return declarations?.[0] ?? null; -} - -/** - * Gets all of the type flags in a type, iterating through unions automatically - */ -export function getTypeFlags(type: ts.Type): ts.TypeFlags { - let flags: ts.TypeFlags = 0; - for (const t of unionTypeParts(type)) { - flags |= t.flags; - } - return flags; -} - -/** - * Checks if the given type is (or accepts) the given flags - * @param isReceiver true if the type is a receiving type (i.e. the type of a called function's parameter) - */ -export function isTypeFlagSet( - type: ts.Type, - flagsToCheck: ts.TypeFlags, - isReceiver?: boolean, -): boolean { - const flags = getTypeFlags(type); - - if (isReceiver && flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown)) { - return true; - } - - return (flags & flagsToCheck) !== 0; -} - -/** - * @returns Whether a type is an instance of the parent type, including for the parent's base types. - */ -export function typeIsOrHasBaseType( - type: ts.Type, - parentType: ts.Type, -): boolean { - const parentSymbol = parentType.getSymbol(); - if (!type.getSymbol() || !parentSymbol) { - return false; - } - - const typeAndBaseTypes = [type]; - const ancestorTypes = type.getBaseTypes(); - - if (ancestorTypes) { - typeAndBaseTypes.push(...ancestorTypes); - } - - for (const baseType of typeAndBaseTypes) { - const baseSymbol = baseType.getSymbol(); - if (baseSymbol && baseSymbol.name === parentSymbol.name) { - return true; - } - } - - return false; -} - -/** - * Gets the source file for a given node - */ -export function getSourceFileOfNode(node: ts.Node): ts.SourceFile { - while (node && node.kind !== ts.SyntaxKind.SourceFile) { - node = node.parent; - } - return node as ts.SourceFile; -} - -export function getTokenAtPosition( - sourceFile: ts.SourceFile, - position: number, -): ts.Node { - const queue: ts.Node[] = [sourceFile]; - let current: ts.Node; - while (queue.length > 0) { - current = queue.shift()!; - // find the child that contains 'position' - for (const child of current.getChildren(sourceFile)) { - const start = child.getFullStart(); - if (start > position) { - // If this child begins after position, then all subsequent children will as well. - return current; - } - - const end = child.getEnd(); - if ( - position < end || - (position === end && child.kind === ts.SyntaxKind.EndOfFileToken) - ) { - queue.push(child); - break; - } - } - } - return current!; -} - -export interface EqualsKind { - isPositive: boolean; - isStrict: boolean; -} - -export function getEqualsKind(operator: string): EqualsKind | undefined { - switch (operator) { - case '==': - return { - isPositive: true, - isStrict: false, - }; - - case '===': - return { - isPositive: true, - isStrict: true, - }; - - case '!=': - return { - isPositive: false, - isStrict: false, - }; - - case '!==': - return { - isPositive: false, - isStrict: true, - }; - - default: - return undefined; - } -} - -export function getTypeArguments( - type: ts.TypeReference, - checker: ts.TypeChecker, -): readonly ts.Type[] { - // getTypeArguments was only added in TS3.7 - if (checker.getTypeArguments) { - return checker.getTypeArguments(type); - } - - return type.typeArguments ?? []; -} - -/** - * @returns true if the type is `unknown` - */ -export function isTypeUnknownType(type: ts.Type): boolean { - return isTypeFlagSet(type, ts.TypeFlags.Unknown); -} - -/** - * @returns true if the type is `any` - */ -export function isTypeAnyType(type: ts.Type): boolean { - if (isTypeFlagSet(type, ts.TypeFlags.Any)) { - if (type.intrinsicName === 'error') { - log('Found an "error" any type'); - } - return true; - } - return false; -} - -/** - * @returns true if the type is `any[]` - */ -export function isTypeAnyArrayType( - type: ts.Type, - checker: ts.TypeChecker, -): boolean { - return ( - checker.isArrayType(type) && - isTypeAnyType( - // getTypeArguments was only added in TS3.7 - getTypeArguments(type, checker)[0], - ) - ); -} - -/** - * @returns true if the type is `unknown[]` - */ -export function isTypeUnknownArrayType( - type: ts.Type, - checker: ts.TypeChecker, -): boolean { - return ( - checker.isArrayType(type) && - isTypeUnknownType( - // getTypeArguments was only added in TS3.7 - getTypeArguments(type, checker)[0], - ) - ); -} - -export const enum AnyType { - Any, - AnyArray, - Safe, -} -/** - * @returns `AnyType.Any` if the type is `any`, `AnyType.AnyArray` if the type is `any[]` or `readonly any[]`, - * otherwise it returns `AnyType.Safe`. - */ -export function isAnyOrAnyArrayTypeDiscriminated( - node: ts.Node, - checker: ts.TypeChecker, -): AnyType { - const type = checker.getTypeAtLocation(node); - if (isTypeAnyType(type)) { - return AnyType.Any; - } - if (isTypeAnyArrayType(type, checker)) { - return AnyType.AnyArray; - } - return AnyType.Safe; -} - -/** - * Does a simple check to see if there is an any being assigned to a non-any type. - * - * This also checks generic positions to ensure there's no unsafe sub-assignments. - * Note: in the case of generic positions, it makes the assumption that the two types are the same. - * - * @example See tests for examples - * - * @returns false if it's safe, or an object with the two types if it's unsafe - */ -export function isUnsafeAssignment( - type: ts.Type, - receiver: ts.Type, - checker: ts.TypeChecker, - senderNode: TSESTree.Node | null, -): false | { sender: ts.Type; receiver: ts.Type } { - if (isTypeAnyType(type)) { - // Allow assignment of any ==> unknown. - if (isTypeUnknownType(receiver)) { - return false; - } - - if (!isTypeAnyType(receiver)) { - return { sender: type, receiver }; - } - } - - if (isTypeReference(type) && isTypeReference(receiver)) { - // TODO - figure out how to handle cases like this, - // where the types are assignable, but not the same type - /* - function foo(): ReadonlySet { return new Set(); } - - // and - - type Test = { prop: T } - type Test2 = { prop: string } - declare const a: Test; - const b: Test2 = a; - */ - - if (type.target !== receiver.target) { - // if the type references are different, assume safe, as we won't know how to compare the two types - // the generic positions might not be equivalent for both types - return false; - } - - if ( - senderNode?.type === AST_NODE_TYPES.NewExpression && - senderNode.callee.type === AST_NODE_TYPES.Identifier && - senderNode.callee.name === 'Map' && - senderNode.arguments.length === 0 && - senderNode.typeParameters == null - ) { - // special case to handle `new Map()` - // unfortunately Map's default empty constructor is typed to return `Map` :( - // https://github.com/typescript-eslint/typescript-eslint/issues/2109#issuecomment-634144396 - return false; - } - - const typeArguments = type.typeArguments ?? []; - const receiverTypeArguments = receiver.typeArguments ?? []; - - for (let i = 0; i < typeArguments.length; i += 1) { - const arg = typeArguments[i]; - const receiverArg = receiverTypeArguments[i]; - - const unsafe = isUnsafeAssignment(arg, receiverArg, checker, senderNode); - if (unsafe) { - return { sender: type, receiver }; - } - } - - return false; - } - - return false; -} - -/** - * Returns the contextual type of a given node. - * Contextual type is the type of the target the node is going into. - * i.e. the type of a called function's parameter, or the defined type of a variable declaration - */ -export function getContextualType( - checker: ts.TypeChecker, - node: ts.Expression, -): ts.Type | undefined { - const parent = node.parent; - if (!parent) { - return; - } - - if (isCallExpression(parent) || isNewExpression(parent)) { - if (node === parent.expression) { - // is the callee, so has no contextual type - return; - } - } else if ( - isVariableDeclaration(parent) || - isPropertyDeclaration(parent) || - isParameterDeclaration(parent) - ) { - return parent.type ? checker.getTypeFromTypeNode(parent.type) : undefined; - } else if (isJsxExpression(parent)) { - return checker.getContextualType(parent); - } else if (isPropertyAssignment(parent) && isIdentifier(node)) { - return checker.getContextualType(node); - } else if ( - isBinaryExpression(parent) && - parent.operatorToken.kind === ts.SyntaxKind.EqualsToken && - parent.right === node - ) { - // is RHS of assignment - return checker.getTypeAtLocation(parent.left); - } else if ( - ![ts.SyntaxKind.TemplateSpan, ts.SyntaxKind.JsxExpression].includes( - parent.kind, - ) - ) { - // parent is not something we know we can get the contextual type of - return; - } - // TODO - support return statement checking - - return checker.getContextualType(node); -} diff --git a/packages/eslint-plugin/tsconfig.build.json b/packages/eslint-plugin/tsconfig.build.json index bc597c33129..fc60d9a85a7 100644 --- a/packages/eslint-plugin/tsconfig.build.json +++ b/packages/eslint-plugin/tsconfig.build.json @@ -12,6 +12,7 @@ "references": [ { "path": "../experimental-utils/tsconfig.build.json" }, { "path": "../parser/tsconfig.build.json" }, - { "path": "../scope-manager/tsconfig.build.json" } + { "path": "../scope-manager/tsconfig.build.json" }, + { "path": "../type-utils/tsconfig.build.json" } ] } diff --git a/packages/eslint-plugin/tsconfig.json b/packages/eslint-plugin/tsconfig.json index c7e9c4ecb2b..31db855dad8 100644 --- a/packages/eslint-plugin/tsconfig.json +++ b/packages/eslint-plugin/tsconfig.json @@ -8,6 +8,7 @@ "references": [ { "path": "../experimental-utils/tsconfig.build.json" }, { "path": "../parser/tsconfig.build.json" }, - { "path": "../scope-manager/tsconfig.build.json" } + { "path": "../scope-manager/tsconfig.build.json" }, + { "path": "../type-utils/tsconfig.build.json" } ] } diff --git a/packages/eslint-plugin/typings/typescript.d.ts b/packages/eslint-plugin/typings/typescript.d.ts index 73304155ee7..0f0b38a5ba6 100644 --- a/packages/eslint-plugin/typings/typescript.d.ts +++ b/packages/eslint-plugin/typings/typescript.d.ts @@ -18,16 +18,5 @@ declare module 'typescript' { * - `readonly [foo]` */ isTupleType(type: Type): type is TupleTypeReference; - /** - * Return the type of the given property in the given type, or undefined if no such property exists - */ - getTypeOfPropertyOfType(type: Type, propertyName: string): Type | undefined; - } - - interface Type { - /** - * If the type is `any`, and this is set to "error", then TS was unable to resolve the type - */ - intrinsicName?: string; } } diff --git a/packages/experimental-utils/src/eslint-utils/index.ts b/packages/experimental-utils/src/eslint-utils/index.ts index bbbe8df2709..fc8e410428e 100644 --- a/packages/experimental-utils/src/eslint-utils/index.ts +++ b/packages/experimental-utils/src/eslint-utils/index.ts @@ -5,3 +5,4 @@ export * from './InferTypesFromRule'; export * from './RuleCreator'; export * from './RuleTester'; export * from './deepMerge'; +export * from './nullThrows'; diff --git a/packages/eslint-plugin/src/util/nullThrows.ts b/packages/experimental-utils/src/eslint-utils/nullThrows.ts similarity index 100% rename from packages/eslint-plugin/src/util/nullThrows.ts rename to packages/experimental-utils/src/eslint-utils/nullThrows.ts diff --git a/packages/type-utils/LICENSE b/packages/type-utils/LICENSE new file mode 100644 index 00000000000..7641edcfd0a --- /dev/null +++ b/packages/type-utils/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 TypeScript ESLint and other contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/type-utils/README.md b/packages/type-utils/README.md new file mode 100644 index 00000000000..099067095a4 --- /dev/null +++ b/packages/type-utils/README.md @@ -0,0 +1,15 @@ +

Type utils for ESLint Plugins

+ +

Type utilities for working with TypeScript within ESLint rules.

+ +

+ CI + NPM Version + NPM Downloads +

+ +This utilities in this package are separated from `@typescript-eslint/experimental-utils` so that that package does not require a dependency on `typescript`. + +## Contributing + +[See the contributing guide here](../../CONTRIBUTING.md) diff --git a/packages/type-utils/jest.config.js b/packages/type-utils/jest.config.js new file mode 100644 index 00000000000..bf4e270e376 --- /dev/null +++ b/packages/type-utils/jest.config.js @@ -0,0 +1,21 @@ +'use strict'; + +// @ts-check +/** @type {import('@jest/types').Config.InitialOptions} */ +module.exports = { + resolver: '/../../tests/jest-resolver.js', + globals: { + 'ts-jest': { + isolatedModules: true, + }, + }, + testEnvironment: 'node', + transform: { + '^.+\\.tsx?$': 'ts-jest', + }, + testRegex: './tests/.+\\.test\\.ts$', + collectCoverage: false, + collectCoverageFrom: ['src/**/*.{js,jsx,ts,tsx}'], + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], + coverageReporters: ['text-summary', 'lcov'], +}; diff --git a/packages/type-utils/package.json b/packages/type-utils/package.json new file mode 100644 index 00000000000..91b8c385dc3 --- /dev/null +++ b/packages/type-utils/package.json @@ -0,0 +1,69 @@ +{ + "name": "@typescript-eslint/type-utils", + "version": "5.8.1", + "description": "Type utilities for working with TypeScript + ESLint together", + "keywords": [ + "eslint", + "typescript", + "estree" + ], + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "files": [ + "dist", + "_ts3.4", + "package.json", + "README.md", + "LICENSE" + ], + "repository": { + "type": "git", + "url": "https://github.com/typescript-eslint/typescript-eslint.git", + "directory": "packages/type-utils" + }, + "bugs": { + "url": "https://github.com/typescript-eslint/typescript-eslint/issues" + }, + "license": "MIT", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "scripts": { + "build": "tsc -b tsconfig.build.json", + "postbuild": "downlevel-dts dist _ts3.4/dist", + "clean": "tsc -b tsconfig.build.json --clean", + "postclean": "rimraf dist && rimraf _ts3.4 && rimraf coverage", + "format": "prettier --write \"./**/*.{ts,js,json,md}\" --ignore-path ../../.prettierignore", + "lint": "eslint . --ext .js,.ts --ignore-path='../../.eslintignore'", + "test": "jest --coverage", + "typecheck": "tsc -p tsconfig.json --noEmit" + }, + "dependencies": { + "@typescript-eslint/experimental-utils": "5.8.1", + "debug": "^4.3.2", + "tsutils": "^3.21.0" + }, + "devDependencies": { + "@typescript-eslint/parser": "5.8.1", + "typescript": "*" + }, + "peerDependencies": { + "eslint": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "typesVersions": { + "<3.8": { + "*": [ + "_ts3.4/*" + ] + } + } +} diff --git a/packages/type-utils/project.json b/packages/type-utils/project.json new file mode 100644 index 00000000000..fea4ee94ee4 --- /dev/null +++ b/packages/type-utils/project.json @@ -0,0 +1,5 @@ +{ + "root": "packages/type-utils", + "type": "library", + "implicitDependencies": [] +} diff --git a/packages/type-utils/src/containsAllTypesByName.ts b/packages/type-utils/src/containsAllTypesByName.ts new file mode 100644 index 00000000000..798355adb59 --- /dev/null +++ b/packages/type-utils/src/containsAllTypesByName.ts @@ -0,0 +1,40 @@ +import { isTypeReference, isUnionOrIntersectionType } from 'tsutils'; +import * as ts from 'typescript'; +import { isTypeFlagSet } from './typeFlagUtils'; + +/** + * @param type Type being checked by name. + * @param allowedNames Symbol names checking on the type. + * @returns Whether the type is, extends, or contains all of the allowed names. + */ +export function containsAllTypesByName( + type: ts.Type, + allowAny: boolean, + allowedNames: Set, +): boolean { + if (isTypeFlagSet(type, ts.TypeFlags.Any | ts.TypeFlags.Unknown)) { + return !allowAny; + } + + if (isTypeReference(type)) { + type = type.target; + } + + const symbol = type.getSymbol(); + if (symbol && allowedNames.has(symbol.name)) { + return true; + } + + if (isUnionOrIntersectionType(type)) { + return type.types.every(t => + containsAllTypesByName(t, allowAny, allowedNames), + ); + } + + const bases = type.getBaseTypes(); + return ( + typeof bases !== 'undefined' && + bases.length > 0 && + bases.every(t => containsAllTypesByName(t, allowAny, allowedNames)) + ); +} diff --git a/packages/type-utils/src/getConstrainedTypeAtLocation.ts b/packages/type-utils/src/getConstrainedTypeAtLocation.ts new file mode 100644 index 00000000000..d14b4d4a7bf --- /dev/null +++ b/packages/type-utils/src/getConstrainedTypeAtLocation.ts @@ -0,0 +1,14 @@ +import * as ts from 'typescript'; + +/** + * Resolves the given node's type. Will resolve to the type's generic constraint, if it has one. + */ +export function getConstrainedTypeAtLocation( + checker: ts.TypeChecker, + node: ts.Node, +): ts.Type { + const nodeType = checker.getTypeAtLocation(node); + const constrained = checker.getBaseConstraintOfType(nodeType); + + return constrained ?? nodeType; +} diff --git a/packages/type-utils/src/getContextualType.ts b/packages/type-utils/src/getContextualType.ts new file mode 100644 index 00000000000..78bffbc523c --- /dev/null +++ b/packages/type-utils/src/getContextualType.ts @@ -0,0 +1,61 @@ +import { + isCallExpression, + isJsxExpression, + isIdentifier, + isNewExpression, + isParameterDeclaration, + isPropertyDeclaration, + isVariableDeclaration, + isPropertyAssignment, + isBinaryExpression, +} from 'tsutils'; +import * as ts from 'typescript'; + +/** + * Returns the contextual type of a given node. + * Contextual type is the type of the target the node is going into. + * i.e. the type of a called function's parameter, or the defined type of a variable declaration + */ +export function getContextualType( + checker: ts.TypeChecker, + node: ts.Expression, +): ts.Type | undefined { + const parent = node.parent; + if (!parent) { + return; + } + + if (isCallExpression(parent) || isNewExpression(parent)) { + if (node === parent.expression) { + // is the callee, so has no contextual type + return; + } + } else if ( + isVariableDeclaration(parent) || + isPropertyDeclaration(parent) || + isParameterDeclaration(parent) + ) { + return parent.type ? checker.getTypeFromTypeNode(parent.type) : undefined; + } else if (isJsxExpression(parent)) { + return checker.getContextualType(parent); + } else if (isPropertyAssignment(parent) && isIdentifier(node)) { + return checker.getContextualType(node); + } else if ( + isBinaryExpression(parent) && + parent.operatorToken.kind === ts.SyntaxKind.EqualsToken && + parent.right === node + ) { + // is RHS of assignment + return checker.getTypeAtLocation(parent.left); + } else if ( + ![ts.SyntaxKind.TemplateSpan, ts.SyntaxKind.JsxExpression].includes( + parent.kind, + ) + ) { + // parent is not something we know we can get the contextual type of + return; + } + // TODO - support return statement checking + + return checker.getContextualType(node); +} diff --git a/packages/type-utils/src/getDeclaration.ts b/packages/type-utils/src/getDeclaration.ts new file mode 100644 index 00000000000..6dab23f92cd --- /dev/null +++ b/packages/type-utils/src/getDeclaration.ts @@ -0,0 +1,16 @@ +import * as ts from 'typescript'; + +/** + * Gets the declaration for the given variable + */ +export function getDeclaration( + checker: ts.TypeChecker, + node: ts.Expression, +): ts.Declaration | null { + const symbol = checker.getSymbolAtLocation(node); + if (!symbol) { + return null; + } + const declarations = symbol.getDeclarations(); + return declarations?.[0] ?? null; +} diff --git a/packages/type-utils/src/getSourceFileOfNode.ts b/packages/type-utils/src/getSourceFileOfNode.ts new file mode 100644 index 00000000000..9bd8f77ea46 --- /dev/null +++ b/packages/type-utils/src/getSourceFileOfNode.ts @@ -0,0 +1,11 @@ +import * as ts from 'typescript'; + +/** + * Gets the source file for a given node + */ +export function getSourceFileOfNode(node: ts.Node): ts.SourceFile { + while (node && node.kind !== ts.SyntaxKind.SourceFile) { + node = node.parent; + } + return node as ts.SourceFile; +} diff --git a/packages/type-utils/src/getTokenAtPosition.ts b/packages/type-utils/src/getTokenAtPosition.ts new file mode 100644 index 00000000000..9307a9cf5c4 --- /dev/null +++ b/packages/type-utils/src/getTokenAtPosition.ts @@ -0,0 +1,30 @@ +import * as ts from 'typescript'; + +export function getTokenAtPosition( + sourceFile: ts.SourceFile, + position: number, +): ts.Node { + const queue: ts.Node[] = [sourceFile]; + let current: ts.Node; + while (queue.length > 0) { + current = queue.shift()!; + // find the child that contains 'position' + for (const child of current.getChildren(sourceFile)) { + const start = child.getFullStart(); + if (start > position) { + // If this child begins after position, then all subsequent children will as well. + return current; + } + + const end = child.getEnd(); + if ( + position < end || + (position === end && child.kind === ts.SyntaxKind.EndOfFileToken) + ) { + queue.push(child); + break; + } + } + } + return current!; +} diff --git a/packages/type-utils/src/getTypeArguments.ts b/packages/type-utils/src/getTypeArguments.ts new file mode 100644 index 00000000000..5af50f81d33 --- /dev/null +++ b/packages/type-utils/src/getTypeArguments.ts @@ -0,0 +1,13 @@ +import * as ts from 'typescript'; + +export function getTypeArguments( + type: ts.TypeReference, + checker: ts.TypeChecker, +): readonly ts.Type[] { + // getTypeArguments was only added in TS3.7 + if (checker.getTypeArguments) { + return checker.getTypeArguments(type); + } + + return type.typeArguments ?? []; +} diff --git a/packages/type-utils/src/getTypeName.ts b/packages/type-utils/src/getTypeName.ts new file mode 100644 index 00000000000..ea3f69e4b5d --- /dev/null +++ b/packages/type-utils/src/getTypeName.ts @@ -0,0 +1,63 @@ +import * as ts from 'typescript'; + +/** + * Get the type name of a given type. + * @param typeChecker The context sensitive TypeScript TypeChecker. + * @param type The type to get the name of. + */ +export function getTypeName( + typeChecker: ts.TypeChecker, + type: ts.Type, +): string { + // It handles `string` and string literal types as string. + if ((type.flags & ts.TypeFlags.StringLike) !== 0) { + return 'string'; + } + + // If the type is a type parameter which extends primitive string types, + // but it was not recognized as a string like. So check the constraint + // type of the type parameter. + if ((type.flags & ts.TypeFlags.TypeParameter) !== 0) { + // `type.getConstraint()` method doesn't return the constraint type of + // the type parameter for some reason. So this gets the constraint type + // via AST. + const symbol = type.getSymbol(); + const decls = symbol?.getDeclarations(); + const typeParamDecl = decls?.[0] as ts.TypeParameterDeclaration; + if ( + ts.isTypeParameterDeclaration(typeParamDecl) && + typeParamDecl.constraint != null + ) { + return getTypeName( + typeChecker, + typeChecker.getTypeFromTypeNode(typeParamDecl.constraint), + ); + } + } + + // If the type is a union and all types in the union are string like, + // return `string`. For example: + // - `"a" | "b"` is string. + // - `string | string[]` is not string. + if ( + type.isUnion() && + type.types + .map(value => getTypeName(typeChecker, value)) + .every(t => t === 'string') + ) { + return 'string'; + } + + // If the type is an intersection and a type in the intersection is string + // like, return `string`. For example: `string & {__htmlEscaped: void}` + if ( + type.isIntersection() && + type.types + .map(value => getTypeName(typeChecker, value)) + .some(t => t === 'string') + ) { + return 'string'; + } + + return typeChecker.typeToString(type); +} diff --git a/packages/type-utils/src/index.ts b/packages/type-utils/src/index.ts new file mode 100644 index 00000000000..44eb35ec945 --- /dev/null +++ b/packages/type-utils/src/index.ts @@ -0,0 +1,14 @@ +export * from './containsAllTypesByName'; +export * from './getConstrainedTypeAtLocation'; +export * from './getContextualType'; +export * from './getDeclaration'; +export * from './getSourceFileOfNode'; +export * from './getTokenAtPosition'; +export * from './getTypeArguments'; +export * from './getTypeName'; +export * from './isTypeReadonly'; +export * from './isUnsafeAssignment'; +export * from './predicates'; +export * from './propertyTypes'; +export * from './requiresQuoting'; +export * from './typeFlagUtils'; diff --git a/packages/eslint-plugin/src/util/isTypeReadonly.ts b/packages/type-utils/src/isTypeReadonly.ts similarity index 94% rename from packages/eslint-plugin/src/util/isTypeReadonly.ts rename to packages/type-utils/src/isTypeReadonly.ts index efb6966d676..c6bc0f5761a 100644 --- a/packages/eslint-plugin/src/util/isTypeReadonly.ts +++ b/packages/type-utils/src/isTypeReadonly.ts @@ -1,3 +1,4 @@ +import { ESLintUtils } from '@typescript-eslint/experimental-utils'; import { isObjectType, isUnionType, @@ -7,7 +8,6 @@ import { isSymbolFlagSet, } from 'tsutils'; import * as ts from 'typescript'; -import { nullThrows, NullThrowsReasons } from './nullThrows'; import { getTypeOfPropertyOfType } from './propertyTypes'; const enum Readonlyness { @@ -75,9 +75,9 @@ function isTypeReadonlyArrayOrTuple( } if (checker.isArrayType(type)) { - const symbol = nullThrows( + const symbol = ESLintUtils.nullThrows( type.getSymbol(), - NullThrowsReasons.MissingToken('symbol', 'array type'), + ESLintUtils.NullThrowsReasons.MissingToken('symbol', 'array type'), ); const escapedName = symbol.getEscapedName(); if (escapedName === 'Array') { @@ -142,9 +142,12 @@ function isTypeReadonlyObject( // as we might be able to bail out early due to a mutable property before // doing this deep, potentially expensive check. for (const property of properties) { - const propertyType = nullThrows( + const propertyType = ESLintUtils.nullThrows( getTypeOfPropertyOfType(checker, type, property), - NullThrowsReasons.MissingToken(`property "${property.name}"`, 'type'), + ESLintUtils.NullThrowsReasons.MissingToken( + `property "${property.name}"`, + 'type', + ), ); // handle recursive types. diff --git a/packages/type-utils/src/isUnsafeAssignment.ts b/packages/type-utils/src/isUnsafeAssignment.ts new file mode 100644 index 00000000000..32dc84b0c30 --- /dev/null +++ b/packages/type-utils/src/isUnsafeAssignment.ts @@ -0,0 +1,86 @@ +import { + TSESTree, + AST_NODE_TYPES, +} from '@typescript-eslint/experimental-utils'; +import { isTypeReference } from 'tsutils'; +import * as ts from 'typescript'; +import { isTypeAnyType, isTypeUnknownType } from './predicates'; + +/** + * Does a simple check to see if there is an any being assigned to a non-any type. + * + * This also checks generic positions to ensure there's no unsafe sub-assignments. + * Note: in the case of generic positions, it makes the assumption that the two types are the same. + * + * @example See tests for examples + * + * @returns false if it's safe, or an object with the two types if it's unsafe + */ +export function isUnsafeAssignment( + type: ts.Type, + receiver: ts.Type, + checker: ts.TypeChecker, + senderNode: TSESTree.Node | null, +): false | { sender: ts.Type; receiver: ts.Type } { + if (isTypeAnyType(type)) { + // Allow assignment of any ==> unknown. + if (isTypeUnknownType(receiver)) { + return false; + } + + if (!isTypeAnyType(receiver)) { + return { sender: type, receiver }; + } + } + + if (isTypeReference(type) && isTypeReference(receiver)) { + // TODO - figure out how to handle cases like this, + // where the types are assignable, but not the same type + /* + function foo(): ReadonlySet { return new Set(); } + + // and + + type Test = { prop: T } + type Test2 = { prop: string } + declare const a: Test; + const b: Test2 = a; + */ + + if (type.target !== receiver.target) { + // if the type references are different, assume safe, as we won't know how to compare the two types + // the generic positions might not be equivalent for both types + return false; + } + + if ( + senderNode?.type === AST_NODE_TYPES.NewExpression && + senderNode.callee.type === AST_NODE_TYPES.Identifier && + senderNode.callee.name === 'Map' && + senderNode.arguments.length === 0 && + senderNode.typeParameters == null + ) { + // special case to handle `new Map()` + // unfortunately Map's default empty constructor is typed to return `Map` :( + // https://github.com/typescript-eslint/typescript-eslint/issues/2109#issuecomment-634144396 + return false; + } + + const typeArguments = type.typeArguments ?? []; + const receiverTypeArguments = receiver.typeArguments ?? []; + + for (let i = 0; i < typeArguments.length; i += 1) { + const arg = typeArguments[i]; + const receiverArg = receiverTypeArguments[i]; + + const unsafe = isUnsafeAssignment(arg, receiverArg, checker, senderNode); + if (unsafe) { + return { sender: type, receiver }; + } + } + + return false; + } + + return false; +} diff --git a/packages/type-utils/src/predicates.ts b/packages/type-utils/src/predicates.ts new file mode 100644 index 00000000000..16afbb25ea8 --- /dev/null +++ b/packages/type-utils/src/predicates.ts @@ -0,0 +1,152 @@ +import debug from 'debug'; +import { unionTypeParts } from 'tsutils'; +import * as ts from 'typescript'; +import { getTypeArguments } from './getTypeArguments'; +import { getTypeFlags, isTypeFlagSet } from './typeFlagUtils'; + +const log = debug('typescript-eslint:eslint-plugin:utils:types'); + +/** + * Checks if the given type is (or accepts) nullable + * @param isReceiver true if the type is a receiving type (i.e. the type of a called function's parameter) + */ +export function isNullableType( + type: ts.Type, + { + isReceiver = false, + allowUndefined = true, + }: { isReceiver?: boolean; allowUndefined?: boolean } = {}, +): boolean { + const flags = getTypeFlags(type); + + if (isReceiver && flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown)) { + return true; + } + + if (allowUndefined) { + return (flags & (ts.TypeFlags.Null | ts.TypeFlags.Undefined)) !== 0; + } else { + return (flags & ts.TypeFlags.Null) !== 0; + } +} + +/** + * Checks if the given type is either an array type, + * or a union made up solely of array types. + */ +export function isTypeArrayTypeOrUnionOfArrayTypes( + type: ts.Type, + checker: ts.TypeChecker, +): boolean { + for (const t of unionTypeParts(type)) { + if (!checker.isArrayType(t)) { + return false; + } + } + + return true; +} + +/** + * @returns true if the type is `unknown` + */ +export function isTypeUnknownType(type: ts.Type): boolean { + return isTypeFlagSet(type, ts.TypeFlags.Unknown); +} + +/** + * @returns true if the type is `any` + */ +export function isTypeAnyType(type: ts.Type): boolean { + if (isTypeFlagSet(type, ts.TypeFlags.Any)) { + if (type.intrinsicName === 'error') { + log('Found an "error" any type'); + } + return true; + } + return false; +} + +/** + * @returns true if the type is `any[]` + */ +export function isTypeAnyArrayType( + type: ts.Type, + checker: ts.TypeChecker, +): boolean { + return ( + checker.isArrayType(type) && + isTypeAnyType( + // getTypeArguments was only added in TS3.7 + getTypeArguments(type, checker)[0], + ) + ); +} + +/** + * @returns true if the type is `unknown[]` + */ +export function isTypeUnknownArrayType( + type: ts.Type, + checker: ts.TypeChecker, +): boolean { + return ( + checker.isArrayType(type) && + isTypeUnknownType( + // getTypeArguments was only added in TS3.7 + getTypeArguments(type, checker)[0], + ) + ); +} + +export enum AnyType { + Any, + AnyArray, + Safe, +} +/** + * @returns `AnyType.Any` if the type is `any`, `AnyType.AnyArray` if the type is `any[]` or `readonly any[]`, + * otherwise it returns `AnyType.Safe`. + */ +export function isAnyOrAnyArrayTypeDiscriminated( + node: ts.Node, + checker: ts.TypeChecker, +): AnyType { + const type = checker.getTypeAtLocation(node); + if (isTypeAnyType(type)) { + return AnyType.Any; + } + if (isTypeAnyArrayType(type, checker)) { + return AnyType.AnyArray; + } + return AnyType.Safe; +} + +/** + * @returns Whether a type is an instance of the parent type, including for the parent's base types. + */ +export function typeIsOrHasBaseType( + type: ts.Type, + parentType: ts.Type, +): boolean { + const parentSymbol = parentType.getSymbol(); + if (!type.getSymbol() || !parentSymbol) { + return false; + } + + const typeAndBaseTypes = [type]; + const ancestorTypes = type.getBaseTypes(); + + if (ancestorTypes) { + typeAndBaseTypes.push(...ancestorTypes); + } + + for (const baseType of typeAndBaseTypes) { + const baseSymbol = baseType.getSymbol(); + if (baseSymbol && baseSymbol.name === parentSymbol.name) { + return true; + } + } + + return false; +} diff --git a/packages/eslint-plugin/src/util/propertyTypes.ts b/packages/type-utils/src/propertyTypes.ts similarity index 100% rename from packages/eslint-plugin/src/util/propertyTypes.ts rename to packages/type-utils/src/propertyTypes.ts diff --git a/packages/eslint-plugin/src/util/requiresQuoting.ts b/packages/type-utils/src/requiresQuoting.ts similarity index 100% rename from packages/eslint-plugin/src/util/requiresQuoting.ts rename to packages/type-utils/src/requiresQuoting.ts diff --git a/packages/type-utils/src/typeFlagUtils.ts b/packages/type-utils/src/typeFlagUtils.ts new file mode 100644 index 00000000000..134fdcf4ece --- /dev/null +++ b/packages/type-utils/src/typeFlagUtils.ts @@ -0,0 +1,31 @@ +import { unionTypeParts } from 'tsutils'; +import * as ts from 'typescript'; + +/** + * Gets all of the type flags in a type, iterating through unions automatically + */ +export function getTypeFlags(type: ts.Type): ts.TypeFlags { + let flags: ts.TypeFlags = 0; + for (const t of unionTypeParts(type)) { + flags |= t.flags; + } + return flags; +} + +/** + * Checks if the given type is (or accepts) the given flags + * @param isReceiver true if the type is a receiving type (i.e. the type of a called function's parameter) + */ +export function isTypeFlagSet( + type: ts.Type, + flagsToCheck: ts.TypeFlags, + isReceiver?: boolean, +): boolean { + const flags = getTypeFlags(type); + + if (isReceiver && flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown)) { + return true; + } + + return (flags & flagsToCheck) !== 0; +} diff --git a/packages/type-utils/tests/fixtures/file.ts b/packages/type-utils/tests/fixtures/file.ts new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/type-utils/tests/fixtures/tsconfig.json b/packages/type-utils/tests/fixtures/tsconfig.json new file mode 100644 index 00000000000..65b63294fc1 --- /dev/null +++ b/packages/type-utils/tests/fixtures/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "jsx": "preserve", + "target": "es5", + "module": "commonjs", + "strict": true, + "esModuleInterop": true, + "lib": ["es2015", "es2017", "esnext"], + "experimentalDecorators": true + }, + "include": [ + "file.ts" + ] +} diff --git a/packages/eslint-plugin/tests/util/isUnsafeAssignment.test.ts b/packages/type-utils/tests/isUnsafeAssignment.test.ts similarity index 97% rename from packages/eslint-plugin/tests/util/isUnsafeAssignment.test.ts rename to packages/type-utils/tests/isUnsafeAssignment.test.ts index 1d4946a964d..060dc880e53 100644 --- a/packages/eslint-plugin/tests/util/isUnsafeAssignment.test.ts +++ b/packages/type-utils/tests/isUnsafeAssignment.test.ts @@ -1,12 +1,11 @@ import * as ts from 'typescript'; import { TSESTree } from '@typescript-eslint/experimental-utils'; import { parseForESLint } from '@typescript-eslint/parser'; +import { isUnsafeAssignment } from '../src/isUnsafeAssignment'; import path from 'path'; -import { getFixturesRootDir } from '../RuleTester'; -import { isUnsafeAssignment } from '../../src/util/types'; describe('isUnsafeAssignment', () => { - const rootDir = getFixturesRootDir(); + const rootDir = path.join(__dirname, 'fixtures'); function getTypes(code: string): { sender: ts.Type; diff --git a/packages/type-utils/tsconfig.build.json b/packages/type-utils/tsconfig.build.json new file mode 100644 index 00000000000..3b5743aaf5d --- /dev/null +++ b/packages/type-utils/tsconfig.build.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "outDir": "./dist", + "rootDir": "./src", + "resolveJsonModule": true + }, + "include": ["src", "typings"], + "references": [{ "path": "../experimental-utils/tsconfig.build.json" }] +} diff --git a/packages/type-utils/tsconfig.json b/packages/type-utils/tsconfig.json new file mode 100644 index 00000000000..9c0c2ac4ba5 --- /dev/null +++ b/packages/type-utils/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.build.json", + "compilerOptions": { + "composite": false, + "rootDir": "." + }, + "include": ["src", "typings", "tests", "tools"], + "references": [{ "path": "../experimental-utils/tsconfig.build.json" }] +} diff --git a/packages/type-utils/typings/typescript.d.ts b/packages/type-utils/typings/typescript.d.ts new file mode 100644 index 00000000000..73304155ee7 --- /dev/null +++ b/packages/type-utils/typings/typescript.d.ts @@ -0,0 +1,33 @@ +import 'typescript'; + +declare module 'typescript' { + interface TypeChecker { + // internal TS APIs + + /** + * @returns `true` if the given type is an array type: + * - `Array` + * - `ReadonlyArray` + * - `foo[]` + * - `readonly foo[]` + */ + isArrayType(type: Type): type is TypeReference; + /** + * @returns `true` if the given type is a tuple type: + * - `[foo]` + * - `readonly [foo]` + */ + isTupleType(type: Type): type is TupleTypeReference; + /** + * Return the type of the given property in the given type, or undefined if no such property exists + */ + getTypeOfPropertyOfType(type: Type, propertyName: string): Type | undefined; + } + + interface Type { + /** + * If the type is `any`, and this is set to "error", then TS was unable to resolve the type + */ + intrinsicName?: string; + } +} diff --git a/tests/integration/integration-test-base.ts b/tests/integration/integration-test-base.ts index 71c5934d945..f49a4e90a23 100644 --- a/tests/integration/integration-test-base.ts +++ b/tests/integration/integration-test-base.ts @@ -62,6 +62,11 @@ export function integrationTest(testFilename: string, filesGlob: string): void { ? rootPackageJson.devDependencies.tslint : undefined, }, + // ensure everything uses the locally packed versions instead of the NPM versions + resolutions: { + // @ts-expect-error -- this is in `./pack-packages.ts` + ...global.tseslintPackages, + }, }), ); // console.log('package.json written.'); diff --git a/workspace.json b/workspace.json index f6b206e61ed..6fbf76fb078 100644 --- a/workspace.json +++ b/workspace.json @@ -10,6 +10,7 @@ "@typescript-eslint/scope-manager": "packages/scope-manager", "@typescript-eslint/shared-fixtures": "packages/shared-fixtures", "@typescript-eslint/types": "packages/types", + "@typescript-eslint/type-utils": "packages/type-utils", "@typescript-eslint/typescript-estree": "packages/typescript-estree", "@typescript-eslint/visitor-keys": "packages/visitor-keys", "@typescript-eslint/website-eslint": "packages/website-eslint",