JS-1663 Fix S2094 false positive for classes with property initialization#6991
Conversation
Tests cover the scenario where classes with constructors that initialize instance properties via this.* assignments are incorrectly flagged as useless. The tests verify that the decorator suppresses onlyConstructor reports when this.* assignments exist at any nesting level in the constructor body (including inside for, for...in, while, and if constructs), while still reporting when assignments are only inside nested function or arrow function bodies. Relates to JS-1663
S2094 incorrectly flagged classes as useless when the constructor initializes instance properties via this.* assignments. These are legitimate data-container and value-object patterns. Add a decorator that intercepts 'onlyConstructor' reports and suppresses them when the constructor body contains at least one this.* assignment. The check walks the constructor body recursively through control-flow constructs (loops, if/else, try/catch) but stops at nested function/arrow-function boundaries, so only direct constructor-level state initialization is recognized. Follows the approved implementation guidelines from JS-1663. Relates to JS-1663
The ruling analysis confirmed that the S2094 decorator implementation is correct with no mismatches between shouldRaise and actualRaise across all 22 entries analyzed. The 18 true-positive entries (empty classes with no this.* constructor assignments) correctly continue to raise, including empty exception classes, JSDoc placeholder classes, and mock classes in real-world projects. The 4 suppressed entries are correctly identified as data-container classes: three _User and WithPrivateField classes in watchtower.js and expressionist.js (with direct this.* assignments), and one class in custom-jsts/S3854.js. The recursive constructor body walk with function-boundary stopping works as intended. No implementation changes or test additions were needed: the decorator's walkForThisAssignment function correctly handles all patterns found in the ruling, and the existing unit tests already cover every edge case discovered (direct assignments, loop bodies, conditionals, and function boundaries).
Remove two unnecessary type assertions (typescript:S4325) in decorator.ts: - Line 58: `return parent as ClassDeclaration | ClassExpression` → `return parent`. After the `parent.type === 'ClassDeclaration' || 'ClassExpression'` guard, TypeScript already narrows the type; the explicit cast was redundant. - Lines 73-75: Replaced the boolean predicate in `find()` with a type predicate `(member): member is MethodDefinition =>`, so TypeScript infers `MethodDefinition | undefined` directly and the `as MethodDefinition | undefined` cast is no longer needed.
Rule Profile
class DeferredInit {
constructor() {
setTimeout(() => {
this.value = 1;
}, 0);
}
}False Positive PatternThe flagged classes are not empty in practice: their constructors initialize instance properties, so the class is carrying state as a data container or value object. S2094 previously treated these classes as having "only a constructor" even when the constructor established the instance state directly. goog.labs.mock.timeout.TimeoutMode = class TimeoutMode {
constructor(duration) {
this.duration = duration;
}
};class PropertyDictionary {
constructor(source) {
for (const key in source) {
this[key] = source[key];
}
}
}False Negative RiskThe suppression is intentionally bounded. It only applies to class Outer {
constructor() {
class Inner {
field = (this.value = 1);
}
}
}Fix SummaryThe S2094 decorator keeps the upstream
|
Ruling ReportCode no longer flagged (4 issues)S2094 348 |
349 |
> 350 | class Foo {
351 | constructor(name, options = {}) {
352 | this.name = name;expressionist.js/test/parser.spec.js:864 862 | }
863 |
> 864 | class WithPrivateField {
865 | constructor(){
866 | this.publicField = 4;watchtower.js/test/dirtychecking.spec.js:576 574 | });
575 |
> 576 | class _User {
577 | constructor(first, last, age) {
578 | this.first = first;watchtower.js/test/observer.spec.js:132 130 | });
131 |
> 132 | class _User {
133 | constructor(name) {
134 | this.name = name;New issues flagged (24 issues)S105hs-template/apps/admin/src/components/legacy-component.tsx:9 7 |
8 | interface LegacyComponentProps {
> 9 | userId: string;
10 | label: string;
11 | }S106hs-template/apps/admin/src/components/legacy-component.tsx:50 48 | nextState: LegacyComponentState,
49 | ) {
> 50 | console.log("about to update", nextProps.userId, nextState.count);
51 | }
52 | S1441hs-template/apps/admin/src/components/legacy-component.tsx:2 1 | // Importing PropTypes from "react" is considered deprecated starting at React version 15.5.
> 2 | import React, { PropTypes } from "react";
3 | // `render`, `hydrate` and `unmountComponentAtNode` from "react-dom" are considered deprecated starting at React version 18.0.
4 | import ReactDOM, { render, hydrate, unmountComponentAtNode } from "react-dom";hs-template/apps/admin/src/components/legacy-component.tsx:4 2 | import React, { PropTypes } from "react";
3 | // `render`, `hydrate` and `unmountComponentAtNode` from "react-dom" are considered deprecated starting at React version 18.0.
> 4 | import ReactDOM, { render, hydrate, unmountComponentAtNode } from "react-dom";
5 | // `renderToNodeStream` from "react-dom/server" is considered deprecated starting at React version 18.0.
6 | import ReactDOMServer, { renderToNodeStream } from "react-dom/server";hs-template/apps/admin/src/components/legacy-component.tsx:6 4 | import ReactDOM, { render, hydrate, unmountComponentAtNode } from "react-dom";
5 | // `renderToNodeStream` from "react-dom/server" is considered deprecated starting at React version 18.0.
> 6 | import ReactDOMServer, { renderToNodeStream } from "react-dom/server";
7 |
8 | interface LegacyComponentProps {hs-template/apps/admin/src/components/legacy-component.tsx:50 48 | nextState: LegacyComponentState,
49 | ) {
> 50 | console.log("about to update", nextProps.userId, nextState.count);
51 | }
52 | S1444hs-template/apps/admin/src/components/legacy-component.tsx:23 21 | > {
22 | // Using `PropTypes` from the "react" package is considered deprecated starting at React version 15.5.
> 23 | static propTypes = {
24 | userId: PropTypes.string,
25 | label: PropTypes.string,S1537hs-template/apps/admin/src/components/legacy-component.tsx:25 23 | static propTypes = {
24 | userId: PropTypes.string,
> 25 | label: PropTypes.string,
26 | };
27 | hs-template/apps/admin/src/components/legacy-component.tsx:48 46 | componentWillUpdate(
47 | nextProps: LegacyComponentProps,
> 48 | nextState: LegacyComponentState,
49 | ) {
50 | console.log("about to update", nextProps.userId, nextState.count);S6791hs-template/apps/admin/src/components/legacy-component.tsx:34 32 |
33 | // `componentWillMount` is considered deprecated starting at React version 16.9.
> 34 | componentWillMount() {
35 | this.setState({ data: `loading-${this.props.userId}` });
36 | }...and 14 more 📋 View full reportCode no longer flagged (4)
New issues flagged (24)
|
SummaryThis PR fixes false positives in the S2094 rule (no extraneous classes) by adding a targeted decorator that suppresses The fix recognizes that classes serving as data containers with constructor-level initialization are legitimate, and suppresses warnings for patterns like:
The decorator stops at function boundaries (nested functions, arrow functions, deferred calls, nested classes), ensuring callbacks and deferred initialization still raise appropriate warnings. Real false positives in expressionist.js and watchtower.js test suites are resolved. What reviewers should knowWhere to start:
Key design decisions:
Watch for:
|
Remove unnecessary type assertions (S4325) and rename a variable that shadows the built-in `constructor` property (S2424): - Line 58: removed redundant `as estree.ClassDeclaration | estree.ClassExpression` cast in `getClassNode`; TypeScript accepts the narrowed intersection type directly. - Line 73: renamed local variable `constructor` to `ctor` to avoid shadowing the built-in `constructor` property (S2424). - Line 82: removed `as estree.FunctionExpression` cast on `ctor.value`; the estree typings already type `MethodDefinition.value` as `FunctionExpression`. - Line 104: removed `as estree.AssignmentExpression` cast; TypeScript narrows `node` to `AssignmentExpression` after the early-return type guard. - Line 107: removed `as estree.MemberExpression` cast; TypeScript narrows `left` to `MemberExpression` via the `&&` discriminant check on `left.type`.
Arrow functions preserve the enclosing `this` binding, so `this.x = y` inside a constructor arrow callback initializes the class instance just like a direct assignment or a loop body. Stopping recursion at ArrowFunctionExpression caused forEach-style data containers to still be flagged while for...of equivalents were suppressed. Change the stop condition to only block FunctionExpression and FunctionDeclaration. Add a comment explaining the distinction, move the arrow-function test case to valid, and add it to the sentinel. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…rc/jsts/rules/S2094/unit.test.ts Comment: The PR description and decorator comment both mention `try/catch` as a supported control-flow construct, but there is no test case for it. Given that the implementation relies on `childrenOf` + `visitorKeys` without any explicit `TryStatement`/`CatchClause` handling, a test verifying suppression works through `try/catch/finally` would lock this in. ```js { // Compliant: this.* inside try/catch code: `class SafeContainer { constructor(source) { try { this.value = source.value; } catch (e) { this.value = null; } } }`, options, }, ``` - [ ] Mark as noise
|
Both issues were addressed in this run:
|
|
Right now class DeferredInit {
constructor() {
setTimeout(() => {
this.value = 1;
}, 0);
}
}That class does not initialize instance state during construction, so it should still be reported as Please make |
Comment: `ArrowFunctionExpression` should not be part of the supported suppression scope here.
Right now `walkForThisAssignment` traverses into arrow callbacks, which suppresses cases like:
```js
class DeferredInit {
constructor() {
setTimeout(() => {
this.value = 1;
}, 0);
}
}
```
That class does not initialize instance state during construction, so it should still be reported as `onlyConstructor`.
Please make `ArrowFunctionExpression` a stop boundary again, keep the support limited to direct constructor control-flow (`if`, loops, `try/catch`, etc.), remove the arrow-callback valid test, and add an invalid regression test for the deferred-callback case.
…rc/jsts/rules/S2094/unit.test.ts Comment: The label "deferred arrow callback" implies that deferral is what makes this noncompliant, but `isFunctionNode` stops at **all** `ArrowFunctionExpression` nodes — deferred or not. A non-deferred arrow like `items.forEach(item => { this[item.key] = item.value; })` is also noncompliant under the current code, yet there is no invalid test case for it. This gap matters because a future refactor that reverts arrow functions to being traversed (as in the previous commit) would silently pass all current tests — the only thing locking in the old behaviour (the `DataStore` sentinel entry) was removed in this commit. Please either add an `invalid` case for a synchronous arrow callback (e.g., `forEach(item => { this[item] = ... })`), or rename the comment to clarify that both deferred and immediate arrow callbacks are stopped. - [ ] Mark as noise
…rc/jsts/rules/S2094/decorator.ts Comment: The comment "assignments in callbacks do not count" is ambiguous — it could be read as applying only to passed-in callbacks, not to all arrow functions. Since `isFunctionNode` stops at `ArrowFunctionExpression` too (see `FUNCTION_NODES` in `helpers/ast.ts`), the comment should be explicit about this. Otherwise the next reader may wonder why `isFunctionNode` was preferred over the narrower `FunctionExpression | FunctionDeclaration` check that was here before. ```suggestion // Stop at all function boundaries (FunctionDeclaration, FunctionExpression, and // ArrowFunctionExpression): `this.*` assignments nested inside any callback — even // arrow callbacks whose `this` is bound to the constructor — are not considered direct // constructor-level state initialization. if (isFunctionNode(node)) { ``` - [ ] Mark as noise
|
The current suppression walk is still a bit too broad because it only stops at function boundaries. For example, this outer class is currently treated as compliant even though the only class Outer {
constructor() {
class Inner {
field = (this.value = 1);
}
}
}
Can we stop the traversal at nested class boundaries as well, and add an |
Comment: The current suppression walk is still a bit too broad because it only stops at function boundaries.
For example, this outer class is currently treated as compliant even though the only `this.*` assignment belongs to an inner class field initializer, not to the outer constructor's instance state:
```js
class Outer {
constructor() {
class Inner {
field = (this.value = 1);
}
}
}
```
`walkForThisAssignment()` descends into the nested `ClassDeclaration` / `ClassBody` / `PropertyDefinition`, sees `this.value = 1`, and suppresses the outer `onlyConstructor` report. That is outside the PR contract of "constructor-level instance initialization".
Can we stop the traversal at nested class boundaries as well, and add an `invalid` regression test for this shape?
|




Relates to JS-1663
S2094 no longer reports
onlyConstructorfor data-container and value-object classes whose constructors initialize instance state with directthis.*assignments. The decorator now walks constructor control flow recursively while staying bounded to constructor-level initialization.this.*assignments, including assignments nested in loops, branches, andtry/catch.onlyConstructorreports from the upstream rule unchanged.