From b95b746f9bb18381708a2dcb61dd911581fe9df1 Mon Sep 17 00:00:00 2001 From: YuyaYoshioka Date: Mon, 24 Nov 2025 23:22:22 +0900 Subject: [PATCH 1/4] fix private destructed class member case --- .../classScopeAnalyzer.ts | 75 ++++++++++++++++++- .../no-unused-private-class-members.test.ts | 67 +++++++++++++++++ 2 files changed, 141 insertions(+), 1 deletion(-) diff --git a/packages/eslint-plugin/src/util/class-scope-analyzer/classScopeAnalyzer.ts b/packages/eslint-plugin/src/util/class-scope-analyzer/classScopeAnalyzer.ts index b72ece7f9a25..50f4cb867107 100644 --- a/packages/eslint-plugin/src/util/class-scope-analyzer/classScopeAnalyzer.ts +++ b/packages/eslint-plugin/src/util/class-scope-analyzer/classScopeAnalyzer.ts @@ -16,7 +16,7 @@ import { extractNameForMember, extractNameForMemberExpression, } from './extractComputedName'; -import { privateKey } from './types'; +import { privateKey, publicKey } from './types'; export class Member { /** @@ -220,6 +220,12 @@ abstract class ThisScope extends Visitor { */ protected readonly thisContext: ClassScope | null; + /** + * Map of variable names destructured from `this` to their property names. + * Example: const { privateMember } = this; => { 'privateMember' => true } + */ + private readonly destructuredThisProperties = new Map(); + constructor( scopeManager: ScopeManager, upper: ThisScope | null, @@ -451,6 +457,44 @@ abstract class ThisScope extends Visitor { this.visitIntermediate(node); } + protected Identifier(node: TSESTree.Identifier): void { + if (node.parent.type === AST_NODE_TYPES.MemberExpression) { + // will be handled by the MemberExpression visitor + return; + } + + // Skip property keys in object literals + // const obj = { privateMember: 123 } - skip the 'privateMember' key + // const obj = { privateMember } - don't skip, this is usage of 'privateMember' + if ( + node.parent.type === AST_NODE_TYPES.Property && + node.parent.key === node && + !node.parent.shorthand + ) { + return; + } + + if (!this.destructuredThisProperties.get(node.name)) { + return; + } + + if (this.thisContext == null) { + return; + } + + const memberKey = publicKey(node.name); + const members = this.isStaticThisContext + ? this.thisContext.members.static + : this.thisContext.members.instance; + const member = members.get(memberKey); + + if (member == null) { + return; + } + + countReference(node, member); + } + protected MemberExpression(node: TSESTree.MemberExpression): void { this.visitChildren(node); @@ -528,6 +572,35 @@ abstract class ThisScope extends Visitor { protected StaticBlock(node: TSESTree.StaticBlock): void { this.visitIntermediate(node); } + + protected VariableDeclarator(node: TSESTree.VariableDeclarator): void { + this.visitChildren(node); + + const init = node.init; + if (init == null || init.type !== AST_NODE_TYPES.ThisExpression) { + return; + } + + if (node.id.type !== AST_NODE_TYPES.ObjectPattern) { + return; + } + + for (const prop of node.id.properties) { + if (prop.type !== AST_NODE_TYPES.Property) { + continue; + } + + if (prop.key.type !== AST_NODE_TYPES.Identifier) { + continue; + } + + if (!prop.shorthand) { + continue; + } + + this.destructuredThisProperties.set(prop.key.name, true); + } + } } /** diff --git a/packages/eslint-plugin/tests/rules/no-unused-private-class-members.test.ts b/packages/eslint-plugin/tests/rules/no-unused-private-class-members.test.ts index c6541fc7464c..70f5e2ad5d1c 100644 --- a/packages/eslint-plugin/tests/rules/no-unused-private-class-members.test.ts +++ b/packages/eslint-plugin/tests/rules/no-unused-private-class-members.test.ts @@ -379,7 +379,36 @@ class C { } } `, + ` +class Foo { + private privateMember; + private privateMember2; + method() { + const { privateMember, privateMember2 } = this; + console.log(privateMember, privateMember2); + } +} + `, + ` +class Foo { + private static staticMember = 1; + static method() { + const { staticMember } = this; + console.log(staticMember); + } +} + `, + ` +class Foo { + private privateMember = 1; + method() { + const { privateMember } = this; + const obj = { privateMember }; + return obj; + } +} + `, //-------------------------------------------------------------------------- // Method definitions //-------------------------------------------------------------------------- @@ -1217,6 +1246,44 @@ class Foo { method() { [this.privateMember] = bar; } +} + `, + errors: [ + { + data: { + classMemberName: 'privateMember', + }, + messageId: 'unusedPrivateClassMember', + }, + ], + }, + { + code: ` +class Foo { + private privateMember; + method() { + const { privateMember } = this; + const obj = { privateMember: 1 }; + } +} + `, + errors: [ + { + data: { + classMemberName: 'privateMember', + }, + messageId: 'unusedPrivateClassMember', + }, + ], + }, + { + code: ` +class Foo { + private privateMember = 1; + + method() { + const { privateMember } = this; + } } `, errors: [ From f7c6afe3a7ca04b3696b0596bada4f971c467bfc Mon Sep 17 00:00:00 2001 From: YuyaYoshioka Date: Tue, 25 Nov 2025 00:48:04 +0900 Subject: [PATCH 2/4] remove destructuredThisProperties --- .../classScopeAnalyzer.ts | 69 +++++-------------- .../no-unused-private-class-members.test.ts | 48 +++---------- 2 files changed, 25 insertions(+), 92 deletions(-) diff --git a/packages/eslint-plugin/src/util/class-scope-analyzer/classScopeAnalyzer.ts b/packages/eslint-plugin/src/util/class-scope-analyzer/classScopeAnalyzer.ts index 50f4cb867107..de9ce8b8bcb9 100644 --- a/packages/eslint-plugin/src/util/class-scope-analyzer/classScopeAnalyzer.ts +++ b/packages/eslint-plugin/src/util/class-scope-analyzer/classScopeAnalyzer.ts @@ -220,12 +220,6 @@ abstract class ThisScope extends Visitor { */ protected readonly thisContext: ClassScope | null; - /** - * Map of variable names destructured from `this` to their property names. - * Example: const { privateMember } = this; => { 'privateMember' => true } - */ - private readonly destructuredThisProperties = new Map(); - constructor( scopeManager: ScopeManager, upper: ThisScope | null, @@ -457,44 +451,6 @@ abstract class ThisScope extends Visitor { this.visitIntermediate(node); } - protected Identifier(node: TSESTree.Identifier): void { - if (node.parent.type === AST_NODE_TYPES.MemberExpression) { - // will be handled by the MemberExpression visitor - return; - } - - // Skip property keys in object literals - // const obj = { privateMember: 123 } - skip the 'privateMember' key - // const obj = { privateMember } - don't skip, this is usage of 'privateMember' - if ( - node.parent.type === AST_NODE_TYPES.Property && - node.parent.key === node && - !node.parent.shorthand - ) { - return; - } - - if (!this.destructuredThisProperties.get(node.name)) { - return; - } - - if (this.thisContext == null) { - return; - } - - const memberKey = publicKey(node.name); - const members = this.isStaticThisContext - ? this.thisContext.members.static - : this.thisContext.members.instance; - const member = members.get(memberKey); - - if (member == null) { - return; - } - - countReference(node, member); - } - protected MemberExpression(node: TSESTree.MemberExpression): void { this.visitChildren(node); @@ -576,12 +532,14 @@ abstract class ThisScope extends Visitor { protected VariableDeclarator(node: TSESTree.VariableDeclarator): void { this.visitChildren(node); - const init = node.init; - if (init == null || init.type !== AST_NODE_TYPES.ThisExpression) { - return; - } - - if (node.id.type !== AST_NODE_TYPES.ObjectPattern) { + // Handle destructuring from `this` + // Example: const { a, b } = this; + if ( + node.init == null || + node.init.type !== AST_NODE_TYPES.ThisExpression || + node.id.type !== AST_NODE_TYPES.ObjectPattern || + this.thisContext == null + ) { return; } @@ -594,11 +552,18 @@ abstract class ThisScope extends Visitor { continue; } - if (!prop.shorthand) { + const memberKey = publicKey(prop.key.name); + const members = this.isStaticThisContext + ? this.thisContext.members.static + : this.thisContext.members.instance; + const member = members.get(memberKey); + + if (member == null) { continue; } - this.destructuredThisProperties.set(prop.key.name, true); + // Destructuring from `this` is a read access + countReference(prop.key, member); } } } diff --git a/packages/eslint-plugin/tests/rules/no-unused-private-class-members.test.ts b/packages/eslint-plugin/tests/rules/no-unused-private-class-members.test.ts index 70f5e2ad5d1c..5b2854dc81cd 100644 --- a/packages/eslint-plugin/tests/rules/no-unused-private-class-members.test.ts +++ b/packages/eslint-plugin/tests/rules/no-unused-private-class-members.test.ts @@ -404,8 +404,14 @@ class Foo { private privateMember = 1; method() { const { privateMember } = this; - const obj = { privateMember }; - return obj; + } +} + `, + ` +class Foo { + private privateMember = 1; + method() { + const { privateMember: privateMember2 } = this; } } `, @@ -1246,44 +1252,6 @@ class Foo { method() { [this.privateMember] = bar; } -} - `, - errors: [ - { - data: { - classMemberName: 'privateMember', - }, - messageId: 'unusedPrivateClassMember', - }, - ], - }, - { - code: ` -class Foo { - private privateMember; - method() { - const { privateMember } = this; - const obj = { privateMember: 1 }; - } -} - `, - errors: [ - { - data: { - classMemberName: 'privateMember', - }, - messageId: 'unusedPrivateClassMember', - }, - ], - }, - { - code: ` -class Foo { - private privateMember = 1; - - method() { - const { privateMember } = this; - } } `, errors: [ From c5c441710e68a9f4b535817d7746c7a2c785faf2 Mon Sep 17 00:00:00 2001 From: YuyaYoshioka Date: Tue, 25 Nov 2025 01:06:45 +0900 Subject: [PATCH 3/4] handle AssignmentExpression and AssignmentPattern --- .../classScopeAnalyzer.ts | 43 +++++++++++++++---- .../no-unused-private-class-members.test.ts | 19 ++++++++ 2 files changed, 54 insertions(+), 8 deletions(-) diff --git a/packages/eslint-plugin/src/util/class-scope-analyzer/classScopeAnalyzer.ts b/packages/eslint-plugin/src/util/class-scope-analyzer/classScopeAnalyzer.ts index de9ce8b8bcb9..9f082124d9d5 100644 --- a/packages/eslint-plugin/src/util/class-scope-analyzer/classScopeAnalyzer.ts +++ b/packages/eslint-plugin/src/util/class-scope-analyzer/classScopeAnalyzer.ts @@ -435,6 +435,28 @@ abstract class ThisScope extends Visitor { // Visit selectors // ///////////////////// + protected AssignmentExpression(node: TSESTree.AssignmentExpression): void { + this.visitChildren(node); + + if ( + node.right.type === AST_NODE_TYPES.ThisExpression && + node.left.type === AST_NODE_TYPES.ObjectPattern + ) { + this.handleThisDestructuring(node.left); + } + } + + protected AssignmentPattern(node: TSESTree.AssignmentPattern): void { + this.visitChildren(node); + + if ( + node.right.type === AST_NODE_TYPES.ThisExpression && + node.left.type === AST_NODE_TYPES.ObjectPattern + ) { + this.handleThisDestructuring(node.left); + } + } + protected ClassDeclaration(node: TSESTree.ClassDeclaration): void { this.visitClass(node); } @@ -532,18 +554,24 @@ abstract class ThisScope extends Visitor { protected VariableDeclarator(node: TSESTree.VariableDeclarator): void { this.visitChildren(node); - // Handle destructuring from `this` - // Example: const { a, b } = this; if ( - node.init == null || - node.init.type !== AST_NODE_TYPES.ThisExpression || - node.id.type !== AST_NODE_TYPES.ObjectPattern || - this.thisContext == null + node.init?.type === AST_NODE_TYPES.ThisExpression && + node.id.type === AST_NODE_TYPES.ObjectPattern ) { + this.handleThisDestructuring(node.id); + } + } + + /** + * Handles destructuring from `this` in ObjectPattern. + * Example: const { property } = this; + */ + private handleThisDestructuring(pattern: TSESTree.ObjectPattern): void { + if (this.thisContext == null) { return; } - for (const prop of node.id.properties) { + for (const prop of pattern.properties) { if (prop.type !== AST_NODE_TYPES.Property) { continue; } @@ -562,7 +590,6 @@ abstract class ThisScope extends Visitor { continue; } - // Destructuring from `this` is a read access countReference(prop.key, member); } } diff --git a/packages/eslint-plugin/tests/rules/no-unused-private-class-members.test.ts b/packages/eslint-plugin/tests/rules/no-unused-private-class-members.test.ts index 5b2854dc81cd..908df4f3b3cc 100644 --- a/packages/eslint-plugin/tests/rules/no-unused-private-class-members.test.ts +++ b/packages/eslint-plugin/tests/rules/no-unused-private-class-members.test.ts @@ -413,6 +413,25 @@ class Foo { method() { const { privateMember: privateMember2 } = this; } +} + `, + ` +class Foo { + private privateMember = 1; + + method() { + let privateMember; + ({ privateMember } = this); + } +} + `, + ` +class Foo { + private privateMember = 1; + + method() { + const foo = ({ privateMember } = this) => {}; + } } `, //-------------------------------------------------------------------------- From 83c15c362df613609217eb180e1c0fb5b5e8eb6a Mon Sep 17 00:00:00 2001 From: YuyaYoshioka Date: Tue, 2 Dec 2025 10:07:27 +0900 Subject: [PATCH 4/4] add test --- .../no-unused-private-class-members.test.ts | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/packages/eslint-plugin/tests/rules/no-unused-private-class-members.test.ts b/packages/eslint-plugin/tests/rules/no-unused-private-class-members.test.ts index 908df4f3b3cc..5c7349d35da6 100644 --- a/packages/eslint-plugin/tests/rules/no-unused-private-class-members.test.ts +++ b/packages/eslint-plugin/tests/rules/no-unused-private-class-members.test.ts @@ -432,6 +432,15 @@ class Foo { method() { const foo = ({ privateMember } = this) => {}; } +} + `, + ` +class Foo { + private privateMember; + + method() { + const { privateMember: used } = this; + } } `, //-------------------------------------------------------------------------- @@ -1271,6 +1280,25 @@ class Foo { method() { [this.privateMember] = bar; } +} + `, + errors: [ + { + data: { + classMemberName: 'privateMember', + }, + messageId: 'unusedPrivateClassMember', + }, + ], + }, + { + code: ` +class Foo { + private privateMember; + + method() { + const { unused: privateMember } = this; + } } `, errors: [