Skip to content

Commit 4c82dc1

Browse files
FRSgitsindresorhusfisker
authored
Add prefer-class-fields rule (#2512)
Co-authored-by: Sindre Sorhus <[email protected]> Co-authored-by: fisker <[email protected]>
1 parent f1d0252 commit 4c82dc1

File tree

9 files changed

+1022
-4
lines changed

9 files changed

+1022
-4
lines changed

docs/rules/prefer-class-fields.md

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
# Prefer class field declarations over `this` assignments in constructors
2+
3+
💼 This rule is enabled in the ✅ `recommended` [config](https://github.com/sindresorhus/eslint-plugin-unicorn#recommended-config).
4+
5+
🔧💡 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix) and manually fixable by [editor suggestions](https://eslint.org/docs/latest/use/core-concepts#rule-suggestions).
6+
7+
<!-- end auto-generated rule header -->
8+
<!-- Do not manually modify this header. Run: `npm run fix:eslint-docs` -->
9+
10+
Enforces declaring property defaults with class fields instead of setting them inside the constructor.
11+
12+
> To avoid leaving empty constructors after autofixing, use the [`no-useless-constructor` rule](https://eslint.org/docs/latest/rules/no-useless-constructor).
13+
14+
## Examples
15+
16+
```js
17+
//
18+
class Foo {
19+
constructor() {
20+
this.foo = 'foo';
21+
}
22+
}
23+
24+
//
25+
class Foo {
26+
foo = 'foo';
27+
}
28+
```
29+
30+
```js
31+
//
32+
class MyError extends Error {
33+
constructor(message: string) {
34+
super(message);
35+
this.name = 'MyError';
36+
}
37+
}
38+
39+
//
40+
class MyError extends Error {
41+
name = 'MyError'
42+
}
43+
```
44+
45+
```js
46+
//
47+
class Foo {
48+
foo = 'foo';
49+
constructor() {
50+
this.foo = 'bar';
51+
}
52+
}
53+
54+
//
55+
class Foo {
56+
foo = 'bar';
57+
}
58+
```
59+
60+
```js
61+
//
62+
class Foo {
63+
#foo = 'foo';
64+
constructor() {
65+
this.#foo = 'bar';
66+
}
67+
}
68+
69+
//
70+
class Foo {
71+
#foo = 'bar';
72+
}
73+
```

readme.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,7 @@ export default [
133133
| [prefer-array-some](docs/rules/prefer-array-some.md) | Prefer `.some(…)` over `.filter(…).length` check and `.{find,findLast,findIndex,findLastIndex}(…)`. || 🔧 | 💡 |
134134
| [prefer-at](docs/rules/prefer-at.md) | Prefer `.at()` method for index access and `String#charAt()`. || 🔧 | 💡 |
135135
| [prefer-blob-reading-methods](docs/rules/prefer-blob-reading-methods.md) | Prefer `Blob#arrayBuffer()` over `FileReader#readAsArrayBuffer(…)` and `Blob#text()` over `FileReader#readAsText(…)`. || | |
136+
| [prefer-class-fields](docs/rules/prefer-class-fields.md) | Prefer class field declarations over `this` assignments in constructors. || 🔧 | 💡 |
136137
| [prefer-code-point](docs/rules/prefer-code-point.md) | Prefer `String#codePointAt(…)` over `String#charCodeAt(…)` and `String.fromCodePoint(…)` over `String.fromCharCode(…)`. || | 💡 |
137138
| [prefer-date-now](docs/rules/prefer-date-now.md) | Prefer `Date.now()` to get the number of milliseconds since the Unix Epoch. || 🔧 | |
138139
| [prefer-default-parameters](docs/rules/prefer-default-parameters.md) | Prefer default parameters over reassignment. || | 💡 |

rules/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ export {default as 'prefer-array-index-of'} from './prefer-array-index-of.js';
7777
export {default as 'prefer-array-some'} from './prefer-array-some.js';
7878
export {default as 'prefer-at'} from './prefer-at.js';
7979
export {default as 'prefer-blob-reading-methods'} from './prefer-blob-reading-methods.js';
80+
export {default as 'prefer-class-fields'} from './prefer-class-fields.js';
8081
export {default as 'prefer-code-point'} from './prefer-code-point.js';
8182
export {default as 'prefer-date-now'} from './prefer-date-now.js';
8283
export {default as 'prefer-default-parameters'} from './prefer-default-parameters.js';

rules/prefer-class-fields.js

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import getIndentString from './utils/get-indent-string.js';
2+
3+
const MESSAGE_ID_ERROR = 'prefer-class-fields/error';
4+
const MESSAGE_ID_SUGGESTION = 'prefer-class-fields/suggestion';
5+
const messages = {
6+
[MESSAGE_ID_ERROR]:
7+
'Prefer class field declaration over `this` assignment in constructor for static values.',
8+
[MESSAGE_ID_SUGGESTION]:
9+
'Encountered same-named class field declaration and `this` assignment in constructor. Replace the class field declaration with the value from `this` assignment.',
10+
};
11+
12+
/**
13+
@param {import('eslint').Rule.Node} node
14+
@param {import('eslint').Rule.RuleContext['sourceCode']} sourceCode
15+
@param {import('eslint').Rule.RuleFixer} fixer
16+
*/
17+
const removeFieldAssignment = (node, sourceCode, fixer) => {
18+
const {line} = sourceCode.getLoc(node).start;
19+
const nodeText = sourceCode.getText(node);
20+
const lineText = sourceCode.lines[line - 1];
21+
const isOnlyNodeOnLine = lineText.trim() === nodeText;
22+
23+
return isOnlyNodeOnLine
24+
? fixer.removeRange([
25+
sourceCode.getIndexFromLoc({line, column: 0}),
26+
sourceCode.getIndexFromLoc({line: line + 1, column: 0}),
27+
])
28+
: fixer.remove(node);
29+
};
30+
31+
/**
32+
@type {import('eslint').Rule.RuleModule['create']}
33+
*/
34+
const create = context => {
35+
const {sourceCode} = context;
36+
37+
return {
38+
ClassBody(classBody) {
39+
const constructor = classBody.body.find(node =>
40+
node.kind === 'constructor'
41+
&& !node.computed
42+
&& !node.static
43+
&& node.type === 'MethodDefinition'
44+
&& node.value.type === 'FunctionExpression',
45+
);
46+
47+
if (!constructor) {
48+
return;
49+
}
50+
51+
const node = constructor.value.body.body.find(node => node.type !== 'EmptyStatement');
52+
53+
if (!(
54+
node?.type === 'ExpressionStatement'
55+
&& node.expression.type === 'AssignmentExpression'
56+
&& node.expression.operator === '='
57+
&& node.expression.left.type === 'MemberExpression'
58+
&& node.expression.left.object.type === 'ThisExpression'
59+
&& !node.expression.left.computed
60+
&& ['Identifier', 'PrivateIdentifier'].includes(node.expression.left.property.type)
61+
&& node.expression.right.type === 'Literal'
62+
)) {
63+
return;
64+
}
65+
66+
const propertyName = node.expression.left.property.name;
67+
const propertyValue = node.expression.right.raw;
68+
const propertyType = node.expression.left.property.type;
69+
const existingProperty = classBody.body.find(node =>
70+
node.type === 'PropertyDefinition'
71+
&& !node.computed
72+
&& !node.static
73+
&& node.key.type === propertyType
74+
&& node.key.name === propertyName,
75+
);
76+
77+
const problem = {
78+
node,
79+
messageId: MESSAGE_ID_ERROR,
80+
};
81+
82+
/**
83+
@param {import('eslint').Rule.RuleFixer} fixer
84+
*/
85+
function * fix(fixer) {
86+
yield removeFieldAssignment(node, sourceCode, fixer);
87+
88+
if (existingProperty) {
89+
yield existingProperty.value
90+
? fixer.replaceText(existingProperty.value, propertyValue)
91+
: fixer.insertTextAfter(existingProperty.key, ` = ${propertyValue}`);
92+
return;
93+
}
94+
95+
const closingBrace = sourceCode.getLastToken(classBody);
96+
const indent = getIndentString(constructor, sourceCode);
97+
98+
let text = `${indent}${propertyName} = ${propertyValue};\n`;
99+
100+
const characterBefore = sourceCode.getText()[sourceCode.getRange(closingBrace)[0] - 1];
101+
if (characterBefore !== '\n') {
102+
text = `\n${text}`;
103+
}
104+
105+
const lastProperty = classBody.body.at(-1);
106+
if (
107+
lastProperty.type === 'PropertyDefinition'
108+
&& sourceCode.getLastToken(lastProperty).value !== ';'
109+
) {
110+
text = `;${text}`;
111+
}
112+
113+
yield fixer.insertTextBefore(closingBrace, text);
114+
}
115+
116+
if (existingProperty?.value) {
117+
problem.suggest = [
118+
{
119+
messageId: MESSAGE_ID_SUGGESTION,
120+
fix,
121+
},
122+
];
123+
return problem;
124+
}
125+
126+
problem.fix = fix;
127+
return problem;
128+
},
129+
};
130+
};
131+
132+
/** @type {import('eslint').Rule.RuleModule} */
133+
const config = {
134+
create,
135+
meta: {
136+
type: 'suggestion',
137+
docs: {
138+
description: 'Prefer class field declarations over `this` assignments in constructors.',
139+
recommended: true,
140+
},
141+
fixable: 'code',
142+
hasSuggestions: true,
143+
messages,
144+
},
145+
};
146+
147+
export default config;

rules/utils/rule.js

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,7 @@ import getDocumentationUrl from './get-documentation-url.js';
33
const isIterable = object => typeof object?.[Symbol.iterator] === 'function';
44

55
class FixAbortError extends Error {
6-
constructor() {
7-
super();
8-
this.name = 'FixAbortError';
9-
}
6+
name = 'FixAbortError';
107
}
118
const fixOptions = {
129
abort() {

test/package.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ const RULES_WITHOUT_EXAMPLES_SECTION = new Set([
3131
'prefer-modern-math-apis',
3232
'prefer-math-min-max',
3333
'consistent-existence-index-check',
34+
'prefer-class-fields',
3435
'prefer-global-this',
3536
'no-instanceof-builtins',
3637
'no-named-default',

0 commit comments

Comments
 (0)