Skip to content

feat(eslint-plugin): [no-unused-private-class-members] new extension rule #10265

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
Closed
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
---
description: 'Disallow unused private class members.'
---

import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';

> 🛑 This file is source code, not the primary documentation location! 🛑
>
> See **https://typescript-eslint.io/rules/no-unused-private-class-members** for documentation.

This rule extends the base [`eslint/no-unused-private-class-members`](https://eslint.org/docs/rules/no-unused-private-class-members) rule.
It adds support for members declared with TypeScript's `private` keyword.

## Options

This rule has no options.

## Examples

<Tabs>
<TabItem value="❌ Incorrect">

```ts
class A {
private foo = 123;
}
```

</TabItem>
<TabItem value="✅ Correct">

```tsx
class A {
private foo = 123;

constructor() {
console.log(this.foo);
}
}
```

</TabItem>
</Tabs>

## When Not To Use It

If you don’t want to be notified about unused private class members, you can safely turn this rule off.
2 changes: 2 additions & 0 deletions packages/eslint-plugin/src/rules/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ import noUnsafeMemberAccess from './no-unsafe-member-access';
import noUnsafeReturn from './no-unsafe-return';
import noUnsafeUnaryMinus from './no-unsafe-unary-minus';
import noUnusedExpressions from './no-unused-expressions';
import noUnusedPrivateClassMembers from './no-unused-private-class-members';
import noUnusedVars from './no-unused-vars';
import noUseBeforeDefine from './no-use-before-define';
import noUselessConstructor from './no-useless-constructor';
Expand Down Expand Up @@ -215,6 +216,7 @@ const rules = {
'no-unsafe-return': noUnsafeReturn,
'no-unsafe-unary-minus': noUnsafeUnaryMinus,
'no-unused-expressions': noUnusedExpressions,
'no-unused-private-class-members': noUnusedPrivateClassMembers,
'no-unused-vars': noUnusedVars,
'no-use-before-define': noUseBeforeDefine,
'no-useless-constructor': noUselessConstructor,
Expand Down
270 changes: 270 additions & 0 deletions packages/eslint-plugin/src/rules/no-unused-private-class-members.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,270 @@
// The following code is adapted from the code in eslint/eslint.
// Source: https://github.com/eslint/eslint/blob/522d8a32f326c52886c531f43cf6a1ff15af8286/lib/rules/no-unused-private-class-members.js
// License: https://github.com/eslint/eslint/blob/522d8a32f326c52886c531f43cf6a1ff15af8286/LICENSE

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

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

import { createRule } from '../util';

type Options = [];
export type MessageIds = 'unusedPrivateClassMember';

interface PrivateMember {
declaredNode:
| TSESTree.MethodDefinitionComputedName
| TSESTree.MethodDefinitionNonComputedName
| TSESTree.PropertyDefinitionComputedName
| TSESTree.PropertyDefinitionNonComputedName;
isAccessor: boolean;
}

export default createRule<Options, MessageIds>({
name: 'no-unused-private-class-members',
meta: {
type: 'problem',
docs: {
description: 'Disallow unused private class members',
extendsBaseRule: true,
requiresTypeChecking: false,
},

messages: {
unusedPrivateClassMember:
"'{{classMemberName}}' is defined but never used.",
},

schema: [],
},
defaultOptions: [],
create(context) {
const trackedClassMembers: Map<string, PrivateMember>[] = [];

/**
* The core ESLint rule tracks methods by adding an extra property of
* "isUsed" to the method, which is a bug according to Joshua Goldberg.
* Instead, in our extended rule, we create a separate data structure to
* track whether a method is being used.
*/
const trackedClassMembersUsed = new Set<
| TSESTree.MethodDefinitionComputedName
| TSESTree.MethodDefinitionNonComputedName
| TSESTree.PropertyDefinitionComputedName
| TSESTree.PropertyDefinitionNonComputedName
>();

/**
* Check whether the current node is in a write only assignment.
* @param node Node referring to a private identifier
* @returns Whether the node is in a write only assignment
* @private
*/
function isWriteOnlyAssignment(
node: TSESTree.Identifier | TSESTree.PrivateIdentifier,
): boolean {
const parentStatement = node.parent.parent;
if (parentStatement === undefined) {
return false;
}

const isAssignmentExpression =
parentStatement.type === AST_NODE_TYPES.AssignmentExpression;

if (
!isAssignmentExpression &&
parentStatement.type !== AST_NODE_TYPES.ForInStatement &&
parentStatement.type !== AST_NODE_TYPES.ForOfStatement &&
parentStatement.type !== AST_NODE_TYPES.AssignmentPattern
) {
return false;
}

// It is a write-only usage, since we still allow usages on the right for
// reads.
if (parentStatement.left !== node.parent) {
return false;
}

// For any other operator (such as '+=') we still consider it a read
// operation.
if (isAssignmentExpression && parentStatement.operator !== '=') {
// However, if the read operation is "discarded" in an empty statement,
// then we consider it write only.
return (
parentStatement.parent.type === AST_NODE_TYPES.ExpressionStatement
);
}

return true;
}

//--------------------------------------------------------------------------
// Public
//--------------------------------------------------------------------------

function processPrivateIdentifier(
node: TSESTree.Identifier | TSESTree.PrivateIdentifier,
): void {
const classBody = trackedClassMembers.find(classProperties =>
classProperties.has(node.name),
);

// Can't happen, as it is a parser error to have a missing class body, but
// let's code defensively here.
if (classBody === undefined) {
return;
}

// In case any other usage was already detected, we can short circuit the
// logic here.
const memberDefinition = classBody.get(node.name);
if (memberDefinition === undefined) {
return;
}
if (trackedClassMembersUsed.has(memberDefinition.declaredNode)) {
return;
}

// The definition of the class member itself
if (
node.parent.type === AST_NODE_TYPES.PropertyDefinition ||
node.parent.type === AST_NODE_TYPES.MethodDefinition
) {
return;
}

// Any usage of an accessor is considered a read, as the getter/setter can
// have side-effects in its definition.
if (memberDefinition.isAccessor) {
trackedClassMembersUsed.add(memberDefinition.declaredNode);
return;
}

// Any assignments to this member, except for assignments that also read
if (isWriteOnlyAssignment(node)) {
return;
}

const wrappingExpressionType = node.parent.parent?.type;
const parentOfWrappingExpressionType = node.parent.parent?.parent?.type;

// A statement which only increments (`this.#x++;`)
if (
wrappingExpressionType === AST_NODE_TYPES.UpdateExpression &&
parentOfWrappingExpressionType === AST_NODE_TYPES.ExpressionStatement
) {
return;
}

/*
* ({ x: this.#usedInDestructuring } = bar);
*
* But should treat the following as a read:
* ({ [this.#x]: a } = foo);
*/
if (
wrappingExpressionType === AST_NODE_TYPES.Property &&
parentOfWrappingExpressionType === AST_NODE_TYPES.ObjectPattern &&
node.parent.parent?.value === node.parent
) {
return;
}

// [...this.#unusedInRestPattern] = bar;
if (wrappingExpressionType === AST_NODE_TYPES.RestElement) {
return;
}

// [this.#unusedInAssignmentPattern] = bar;
if (wrappingExpressionType === AST_NODE_TYPES.ArrayPattern) {
return;
}

// We can't delete the memberDefinition, as we need to keep track of which
// member we are marking as used. In the case of nested classes, we only
// mark the first member we encounter as used. If you were to delete the
// member, then any subsequent usage could incorrectly mark the member of
// an encapsulating parent class as used, which is incorrect.
trackedClassMembersUsed.add(memberDefinition.declaredNode);
}

return {
// Collect all declared members/methods up front and assume they are all
// unused.
ClassBody(classBodyNode): void {
const privateMembers = new Map<string, PrivateMember>();

trackedClassMembers.unshift(privateMembers);
for (const bodyMember of classBodyNode.body) {
if (
(bodyMember.type === AST_NODE_TYPES.PropertyDefinition ||
bodyMember.type === AST_NODE_TYPES.MethodDefinition) &&
(bodyMember.key.type === AST_NODE_TYPES.PrivateIdentifier ||
(bodyMember.key.type === AST_NODE_TYPES.Identifier &&
bodyMember.accessibility === 'private'))
) {
privateMembers.set(bodyMember.key.name, {
declaredNode: bodyMember,
isAccessor:
bodyMember.type === AST_NODE_TYPES.MethodDefinition &&
(bodyMember.kind === 'set' || bodyMember.kind === 'get'),
});
}
}
},

// Process nodes like:
// ```ts
// class A {
// #myPrivateMember = 123;
// }
// ```
PrivateIdentifier(node): void {
processPrivateIdentifier(node);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Threading #10265 (comment): I don't get that error locally. But I do get what Nx is getting (https://cloud.nx.app/runs/lsSb3ZQa0g?utm_source=pull-request&utm_medium=comment):

src/rules/no-unused-private-class-members.ts(215,34): error TS2345: Argument of type 'PrivateIdentifier' is not assignable to parameter of type 'Identifier'.
  Type 'PrivateIdentifier' is missing the following properties from type 'Identifier': decorators, optional, typeAnnotation

Maybe a caching issue? I'd suggest merging from main and re-building. 🤷

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I merged with main and now I am getting this Netlify error: https://app.netlify.com/sites/typescript-eslint/deploys/67295a26e190f200080d6053

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Furthermore, it looks like "docs.test.ts" has a lint error, but that is unrelated to my PR.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

#10288

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I merged with main, but CI is now getting this error:

- 429: Unable to connect to Nx Cloud.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

429 Too Many Requests

Screenshot of nine commits fro Zamiell, either named 'update' or a merge from main

It'll probably ease up on you if you push less.

Copy link
Contributor Author

@Zamiell Zamiell Nov 9, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you are implying that I am committing too much, I thought it was best-practice to put each PR fix that was brought up in a PR review thread by other people into a separate commit, which I did.

Anyways, I just merged from main again. Now, I get these two CI errors:

I am not sure what they mean though.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

best-practice to put each PR fix that was brought up in a PR review thread by other people into a separate commit

I don't subscribe to that best practice 😄. It can be nice to have granular commits when there are tricky things being discussed. But there's no real utility most of the time.

The downside is that a lot of CI churn gets added to the repo. Which is never good: almost all repos are either on a rate-limited free plan (like we are) or a pay-for-bandwidth plan ($$$).

Now, I get these two CI errors:

You've got some test failures, have you tried reading through the test files mentioned in them? I haven't looked deeply but if you've got specific questions I can.

Copy link
Contributor Author

@Zamiell Zamiell Nov 9, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You've got some test failures

Right, but the part I am confused about is that the test failures are in files that I haven't modified / not part of this PR. Do I have to add the new rule to one of the existing configs or something?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's what I'm trying to hint at with my questions: did you read the tests that are failing? The test names, the test failures, the code they touch?

Ideally, the repo's docs and tests should be set up so that you can debug through these and figure this out on your own. If it's not clear from them then that would be something we'd want to work on.

I'm not intentionally trying to be obtuse or unhelpful. But these are things that I think as someone who's got a bunch of PRs merged into this repo (❤️) you're equipped to debug.

},

// Process nodes like:
// ```ts
// class A {
// private myPrivateMember = 123;
// }
// ```
PropertyDefinition(node): void {
if (
node.accessibility === 'private' &&
node.key.type === AST_NODE_TYPES.Identifier
) {
processPrivateIdentifier(node.key);
}
},

// Post-process the class members and report any remaining members. Since
// private members can only be accessed in the current class context, we
// can safely assume that all usages are within the current class body.
'ClassBody:exit'(): void {
const unusedPrivateMembers = trackedClassMembers.shift();
if (unusedPrivateMembers === undefined) {
return;
}

for (const [
classMemberName,
{ declaredNode },
] of unusedPrivateMembers.entries()) {
if (trackedClassMembersUsed.has(declaredNode)) {
continue;
}
context.report({
loc: declaredNode.key.loc,
node: declaredNode,
messageId: 'unusedPrivateClassMember',
data: {
classMemberName: `#${classMemberName}`,
},
});
}
},
};
},
});
Loading
Loading