Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,21 +53,21 @@
"@typescript-eslint/utils": "^8.12.2",
"common-tags": "^1.8.0",
"decamelize": "^5.0.0 || ^6.0.0",
"tslib": "^2.0.0",
"tsutils": "^3.0.0",
"tsutils-etc": "^1.4.2"
"ts-api-utils": "^1.3.0",
"tslib": "^2.1.0"
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0",
"rxjs": ">=7.0.0",
"typescript": ">=4.0.0"
"typescript": ">=4.2.0"
},
"devDependencies": {
"@eslint/js": "^9.13.0",
"@stylistic/eslint-plugin": "^2.10.1",
"@types/common-tags": "^1.8.4",
"@types/node": "^18.18.0",
"@typescript-eslint/rule-tester": "^8.12.2",
"@typescript/vfs": "^1.6.0",
"@vitest/coverage-v8": "^2.1.4",
"@vitest/eslint-plugin": "^1.1.7",
"bumpp": "^9.8.0",
Expand Down
11 changes: 11 additions & 0 deletions src/etc/could-be-function.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import * as ts from 'typescript';
import { couldBeType } from './could-be-type';

export function couldBeFunction(type: ts.Type): boolean {
return (
type.getCallSignatures().length > 0
|| couldBeType(type, 'Function')
|| couldBeType(type, 'ArrowFunction')
|| couldBeType(type, ts.InternalSymbolName.Function)
);
}
110 changes: 110 additions & 0 deletions src/etc/could-be-type.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import * as tsutils from 'ts-api-utils';
import * as ts from 'typescript';

export function couldBeType(
type: ts.Type,
name: string | RegExp,
qualified?: {
name: RegExp;
typeChecker: ts.TypeChecker;
},
): boolean {
if (tsutils.isTypeReference(type)) {
type = type.target;
}

if (isType(type, name, qualified)) {
return true;
}

if (tsutils.isUnionOrIntersectionType(type)) {
return type.types.some(t => couldBeType(t, name, qualified));
}

const baseTypes = type.getBaseTypes();
if (baseTypes?.some(t => couldBeType(t, name, qualified))) {
return true;
}

if (couldImplement(type, name, qualified)) {
return true;
}

return false;
}

function isType(
type: ts.Type,
name: string | RegExp,
qualified?: {
name: RegExp;
typeChecker: ts.TypeChecker;
},
): boolean {
if (!type.symbol) {
return false;
}
if (
qualified
&& !qualified.name.test(
qualified.typeChecker.getFullyQualifiedName(type.symbol),
)
) {
return false;
}
return typeof name === 'string'
? type.symbol.name === name
: Boolean(type.symbol.name.match(name));
}

function couldImplement(
type: ts.Type,
name: string | RegExp,
qualified?: {
name: RegExp;
typeChecker: ts.TypeChecker;
},
): boolean {
const { symbol } = type;
if (symbol) {
const { valueDeclaration } = symbol;
if (valueDeclaration && ts.isClassDeclaration(valueDeclaration)) {
const { heritageClauses } = valueDeclaration;
if (heritageClauses) {
const implemented = heritageClauses.some(
({ token, types }) =>
token === ts.SyntaxKind.ImplementsKeyword
&& types.some(node => isMatchingNode(node, name, qualified)),
);
if (implemented) {
return true;
}
}
}
}
return false;
}

function isMatchingNode(
node: ts.ExpressionWithTypeArguments,
name: string | RegExp,
qualified?: {
name: RegExp;
typeChecker: ts.TypeChecker;
},
): boolean {
const { expression } = node;
if (qualified) {
const type = qualified.typeChecker.getTypeAtLocation(expression);
if (type) {
const qualifiedName = qualified.typeChecker.getFullyQualifiedName(
type.symbol,
);
if (!qualified.name.test(qualifiedName)) {
return false;
}
}
}
const text = expression.getText();
return typeof name === 'string' ? text === name : Boolean(text.match(name));
}
16 changes: 9 additions & 7 deletions src/etc/get-type-services.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { ESLintUtils, TSESLint, TSESTree } from '@typescript-eslint/utils';
import * as tsutils from 'tsutils-etc';
import * as tsutils from 'ts-api-utils';
import * as ts from 'typescript';
import { couldBeFunction } from './could-be-function';
import { couldBeType as tsutilsEtcCouldBeType } from './could-be-type';
import { isArrowFunctionExpression, isFunctionDeclaration } from './is';

export function getTypeServices<
Expand All @@ -17,7 +19,7 @@ export function getTypeServices<
qualified?: { name: RegExp },
): boolean => {
const type = getType(node);
return tsutils.couldBeType(
return tsutilsEtcCouldBeType(
type,
name,
qualified ? { ...qualified, typeChecker } : undefined,
Expand Down Expand Up @@ -46,7 +48,7 @@ export function getTypeServices<
}
return Boolean(
tsTypeNode
&& tsutils.couldBeType(
&& tsutilsEtcCouldBeType(
typeChecker.getTypeAtLocation(tsTypeNode),
name,
qualified ? { ...qualified, typeChecker } : undefined,
Expand All @@ -66,7 +68,7 @@ export function getTypeServices<
if (isArrowFunctionExpression(node) || isFunctionDeclaration(node)) {
return true;
}
return tsutils.couldBeFunction(getType(node));
return couldBeFunction(getType(node));
},
couldBeMonoTypeOperatorFunction: (node: TSESTree.Node) =>
couldBeType(node, 'MonoTypeOperatorFunction'),
Expand All @@ -78,9 +80,9 @@ export function getTypeServices<
couldReturnType(node, 'Observable'),
couldReturnType,
getType,
isAny: (node: TSESTree.Node) => tsutils.isAny(getType(node)),
isReferenceType: (node: TSESTree.Node) => tsutils.isReferenceType(getType(node)),
isUnknown: (node: TSESTree.Node) => tsutils.isUnknown(getType(node)),
isAny: (node: TSESTree.Node) => tsutils.isIntrinsicAnyType(getType(node)),
isReferenceType: (node: TSESTree.Node) => tsutils.isTypeReference(getType(node)),
isUnknown: (node: TSESTree.Node) => tsutils.isIntrinsicUnknownType(getType(node)),
typeChecker,
};
}
2 changes: 2 additions & 0 deletions src/etc/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
export * from './could-be-function';
export * from './could-be-type';
export * from './find-parent';
export * from './get-loc';
export * from './get-type-services';
Expand Down
8 changes: 4 additions & 4 deletions src/rules/no-unsafe-subject-next.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { TSESTree as es } from '@typescript-eslint/utils';
import * as tsutils from 'tsutils';
import { couldBeType, isReferenceType, isUnionType } from 'tsutils-etc';
import * as tsutils from 'ts-api-utils';
import * as ts from 'typescript';
import {
couldBeType,
getTypeServices,
isMemberExpression } from '../etc';
import { ruleCreator } from '../utils';
Expand Down Expand Up @@ -30,7 +30,7 @@ export const noUnsafeSubjectNext = ruleCreator({
) => {
if (node.arguments.length === 0 && isMemberExpression(node.callee)) {
const type = getType(node.callee.object);
if (isReferenceType(type) && couldBeType(type, 'Subject')) {
if (tsutils.isTypeReference(type) && couldBeType(type, 'Subject')) {
const [typeArg] = typeChecker.getTypeArguments(type);
if (tsutils.isTypeFlagSet(typeArg, ts.TypeFlags.Any)) {
return;
Expand All @@ -42,7 +42,7 @@ export const noUnsafeSubjectNext = ruleCreator({
return;
}
if (
isUnionType(typeArg)
tsutils.isUnionType(typeArg)
&& typeArg.types.some((t) =>
tsutils.isTypeFlagSet(t, ts.TypeFlags.Void),
)
Expand Down
8 changes: 4 additions & 4 deletions src/rules/throw-error.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { TSESTree as es, ESLintUtils } from '@typescript-eslint/utils';
import { couldBeFunction, couldBeType, isAny, isUnknown } from 'tsutils-etc';
import * as tsutils from 'ts-api-utils';
import * as ts from 'typescript';
import { getTypeServices } from '../etc';
import { couldBeFunction, couldBeType, getTypeServices } from '../etc';
import { ruleCreator } from '../utils';

export const throwErrorRule = ruleCreator({
Expand Down Expand Up @@ -32,8 +32,8 @@ export const throwErrorRule = ruleCreator({
type = program.getTypeChecker().getTypeAtLocation(annotation ?? body);
}
if (
!isAny(type)
&& !isUnknown(type)
!tsutils.isIntrinsicAnyType(type)
&& !tsutils.isIntrinsicUnknownType(type)
&& !couldBeType(type, /^(Error|DOMException)$/)
) {
context.report({
Expand Down
137 changes: 137 additions & 0 deletions tests/etc/could-be-type.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import * as ts from 'typescript';
import { couldBeType } from '../../src/etc/could-be-type';
import { createSourceFileAndTypeChecker } from './create-source-file-and-type-checker';

describe('couldBeType', () => {
it('should match a specific type', () => {
const { sourceFile, typeChecker } = createSourceFileAndTypeChecker(
`
class A {}
let a: A;
`,
);
const node = (sourceFile.statements[1] as ts.VariableStatement).declarationList.declarations[0];
const type = typeChecker.getTypeAtLocation(node);

expect(couldBeType(type, 'A')).toBe(true);
});

it('should not match different types', () => {
const { sourceFile, typeChecker } = createSourceFileAndTypeChecker(
`
class A {}
class B {}
let b: B;
`,
);
const node = (sourceFile.statements[2] as ts.VariableStatement).declarationList.declarations[0];
const type = typeChecker.getTypeAtLocation(node);

expect(couldBeType(type, 'A')).toBe(false);
expect(couldBeType(type, 'B')).toBe(true);
});

it('should match a base type', () => {
const { sourceFile, typeChecker } = createSourceFileAndTypeChecker(
`
class A {}
class B extends A {}
let b: B;
`,
);
const node = (sourceFile.statements[2] as ts.VariableStatement).declarationList.declarations[0];
const type = typeChecker.getTypeAtLocation(node);

expect(couldBeType(type, 'A')).toBe(true);
expect(couldBeType(type, 'B')).toBe(true);
});

it('should match an implemented interface', () => {
const { sourceFile, typeChecker } = createSourceFileAndTypeChecker(
`
interface A { name: string; }
class B implements A { name = ""; }
let b: B;
`,
);
const node = (sourceFile.statements[2] as ts.VariableStatement).declarationList.declarations[0];
const type = typeChecker.getTypeAtLocation(node);

expect(couldBeType(type, 'A')).toBe(true);
expect(couldBeType(type, 'B')).toBe(true);
});

it('should match an implemented generic interface', () => {
const { sourceFile, typeChecker } = createSourceFileAndTypeChecker(
`
interface A<T> { value: T; }
class B<T> implements A<T> { constructor(public value: T) {} }
let b = new B<string>("B");
`,
);
const node = (sourceFile.statements[2] as ts.VariableStatement).declarationList.declarations[0];
const type = typeChecker.getTypeAtLocation(node);

expect(couldBeType(type, 'A')).toBe(true);
expect(couldBeType(type, 'B')).toBe(true);
});

it('should match an intersection type', () => {
const { sourceFile, typeChecker } = createSourceFileAndTypeChecker(
`
class A {}
class B {}
let ab: A & B;
`,
);
const node = (sourceFile.statements[2] as ts.VariableStatement).declarationList.declarations[0];
const type = typeChecker.getTypeAtLocation(node);

expect(couldBeType(type, 'A')).toBe(true);
expect(couldBeType(type, 'B')).toBe(true);
});

it('should match a union type', () => {
const { sourceFile, typeChecker } = createSourceFileAndTypeChecker(
`
class A {}
class B {}
let ab: A | B;
`,
);
const node = (sourceFile.statements[2] as ts.VariableStatement).declarationList.declarations[0];
const type = typeChecker.getTypeAtLocation(node);

expect(couldBeType(type, 'A')).toBe(true);
expect(couldBeType(type, 'B')).toBe(true);
});

it.todo('should support fully-qualified types', () => {
// TODO: This test is disabled because we're failing to import from other files using @typescript/vfs. See env.languageService.getSemanticDiagnostics(fileName) for error message.
const { sourceFile, typeChecker } = createSourceFileAndTypeChecker(
`
import { A } from "./a";
class B {}
let a: A;
let b: B;
`,
);
const nodeA = (sourceFile.statements[2] as ts.VariableStatement).declarationList.declarations[0];
const nodeB = (sourceFile.statements[3] as ts.VariableStatement).declarationList.declarations[0];
const typeA = typeChecker.getTypeAtLocation(nodeA);
const typeB = typeChecker.getTypeAtLocation(nodeB);

expect(
couldBeType(typeA, 'A', {
name: /"a"/,
typeChecker,
}),
).toBe(true);
expect(
couldBeType(typeB, 'B', {
name: /"b"/,
typeChecker,
}),
).toBe(false);
});
});
Loading