Skip to content
Draft
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
159 changes: 159 additions & 0 deletions packages/eslint-plugin/src/rules/strict-interface-implementation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import type { TSESTree } from '@typescript-eslint/utils';
import type * as ts from 'typescript';

import { AST_NODE_TYPES } from '@typescript-eslint/utils';

import type { NodeWithKey } from '../util';

import {
createRule,
getParserServices,
getStaticMemberAccessValue,
isNodeWithKey,
} from '../util';

type NodeWithStaticKey = Exclude<
NodeWithKey,
| TSESTree.MemberExpressionComputedName
| TSESTree.MemberExpressionNonComputedName
>;

export default createRule({
name: 'strict-interface-implementation',
meta: {
type: 'problem',
docs: {
description:
'Enforce classes are fully assignable to any interfaces they implement',
requiresTypeChecking: true,
},
fixable: 'code',
messages: {
unassignable:
'This {{target}} is not fully assignable to the interface {{interface}} type for {{name}}.',
},
schema: [],
},
defaultOptions: [],
create(context) {
const services = getParserServices(context);
const checker = services.program.getTypeChecker();

function checkClassImplements(
node: TSESTree.ClassDeclaration | TSESTree.ClassExpression,
base: ts.Type,
) {
for (const element of node.body.body) {
if (element.type === AST_NODE_TYPES.MethodDefinition) {
checkMethod(element, base);
} else if (isNodeWithKey(element)) {
checkProperty(element, base);
}
}
}

function checkMethod(element: TSESTree.MethodDefinition, base: ts.Type) {
const methodName = getStaticMemberAccessValue(element, context);
if (typeof methodName !== 'string') {
return;
}

const baseMethod = base.getProperty(methodName);
if (!baseMethod?.valueDeclaration) {
return;
}

const baseType = checker.getTypeAtLocation(baseMethod.valueDeclaration);
const derivedType = services.getTypeAtLocation(element);

if (isMethodAssignable(baseType, derivedType)) {
return;
}

context.report({
node: element.key,
messageId: 'unassignable',
data: {
name: methodName,
interface: checker.typeToString(base),
target: 'method',
},
});
}

function isMethodAssignable(base: ts.Type, derived: ts.Type) {
const baseSignature = base.getCallSignatures()[0];
const derivedSignature = derived.getCallSignatures()[0];

if (
derivedSignature.parameters.length > baseSignature.parameters.length
) {
return false;
}

for (let i = 0; i < baseSignature.parameters.length; i += 1) {
const baseType = checker.getTypeOfSymbol(baseSignature.parameters[i]);
const derivedType = checker.getTypeOfSymbol(
derivedSignature.parameters[i],
);

if (!checker.isTypeAssignableTo(baseType, derivedType)) {
return false;
}
}

return true;
}

function checkProperty(element: NodeWithStaticKey, base: ts.Type) {
const propertyName = getStaticMemberAccessValue(element, context);
if (typeof propertyName !== 'string') {
return;
}

const baseProperty = base.getProperty(propertyName);
if (!baseProperty?.valueDeclaration) {
return;
}

const baseType = checker.getTypeAtLocation(baseProperty.valueDeclaration);
const derivedType = services.getTypeAtLocation(element);

if (checker.isTypeAssignableTo(baseType, derivedType)) {
return;
}

context.report({
node: element.key,
messageId: 'unassignable',
data: {
name: propertyName,
interface: checker.typeToString(base),
target: 'property',
},
});
}

function getSuperClassImplements(
superClass: TSESTree.LeftHandSideExpression,
) {
// TODO
}

return {
'ClassDeclaration, ClassExpression'(
node: TSESTree.ClassDeclaration | TSESTree.ClassExpression,
) {
for (const base of node.implements) {
checkClassImplements(node, services.getTypeAtLocation(base));
}

if (node.superClass) {
for (const base of getSuperClassImplements(node.superClass)) {
checkClassImplements(node, base);
}
}
},
};
},
});
15 changes: 15 additions & 0 deletions packages/eslint-plugin/src/util/misc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,21 @@ export type NodeWithKey =
| TSESTree.TSAbstractMethodDefinition
| TSESTree.TSAbstractPropertyDefinition;

export function isNodeWithKey(node: TSESTree.Node): node is NodeWithKey {
switch (node.type) {
case AST_NODE_TYPES.AccessorProperty:
case AST_NODE_TYPES.MemberExpression:
case AST_NODE_TYPES.MethodDefinition:
case AST_NODE_TYPES.Property:
case AST_NODE_TYPES.PropertyDefinition:
case AST_NODE_TYPES.TSAbstractMethodDefinition:
case AST_NODE_TYPES.TSAbstractPropertyDefinition:
return true;
default:
return false;
}
}

/**
* Gets a member being accessed or declared if its value can be determined statically, and
* resolves it to the string or symbol value that will be used as the actual member
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { RuleTester } from '@typescript-eslint/rule-tester';

import rule from '../../src/rules/strict-interface-implementation';
import { getFixturesRootDir } from '../RuleTester';

const rootDir = getFixturesRootDir();
const ruleTester = new RuleTester({
languageOptions: {
parserOptions: {
project: './tsconfig.json',
tsconfigRootDir: rootDir,
},
},
});

ruleTester.run('strict-interface-implementation', rule, {
valid: [
'class Standalone {}',
'const Standalone = class {};',
'const Standalone = class Standalone {};',
`
interface Base {}
class Derived implements Base {}
`,
`
interface Base {
process(): void;
}
class Derived implements Base {
process() {}
}
`,
`
interface Base {
value: string;
}
class Derived implements Base {
value: string;
}
`,
],
invalid: [
{
code: `
interface Base {
value: string | undefined;
}

class Derived implements Base {
value: string;
}
`,
errors: [
{
data: {
interface: 'Base',
name: 'value',
target: 'property',
},
messageId: 'unassignable',
},
],
},
{
code: `
interface Base {
process(value: string | null): void;
}

class Derived implements Base {
public process(value: string) {}
}
`,
errors: [
{
data: {
interface: 'Base',
name: 'process',
target: 'method',
},
messageId: 'unassignable',
},
],
},
{
code: `
interface Base {
process(value?: string): void;
}

class Derived implements Base {
public process(value: string) {}
}
`,
errors: [
{
data: {
interface: 'Base',
name: 'process',
target: 'method',
},
messageId: 'unassignable',
},
],
},
],
});
Loading