Skip to content

Add prefer-class-fields rule #2512

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

Merged
merged 54 commits into from
Jun 14, 2025
Merged
Show file tree
Hide file tree
Changes from 51 commits
Commits
Show all changes
54 commits
Select commit Hold shift + click to select a range
76519ef
feat: add `prefer-class-fields` rule
FRSgit Dec 16, 2024
0c33fa7
fix: handle edge case of constructor without body
FRSgit Dec 16, 2024
cd3149a
Update prefer-class-fields.md
sindresorhus Dec 18, 2024
04ce366
Update prefer-class-fields.js
sindresorhus Dec 18, 2024
2dba3a3
perf: iterate over original array instead of copying it over and reve…
FRSgit Dec 20, 2024
78ca6ff
chore: handle only simple assignments
FRSgit Dec 20, 2024
4136f2f
lint: fix
FRSgit Dec 20, 2024
3e632b8
chore: spaces to tabs
FRSgit Dec 20, 2024
9ae80ff
chore: add dynamic field names to valid test cases
FRSgit Dec 20, 2024
621041e
feat: support strings and template strings
FRSgit Dec 20, 2024
bd47b24
chore: replace existing field if present
FRSgit Dec 20, 2024
f7989a4
chore: lint fix
FRSgit Dec 20, 2024
ed45d3d
feat: handle only non-computed properties
FRSgit Dec 21, 2024
faf6c2c
chore: stop static analysis on unsupported cases
FRSgit Dec 22, 2024
d77f630
chore: add missing semicolon
FRSgit Dec 25, 2024
806ddef
docs: update docs/rules/prefer-class-fields.md
FRSgit Jan 17, 2025
34f55d1
docs: update rule description
FRSgit Jan 17, 2025
4d412a5
Update prefer-class-fields.md
sindresorhus Jan 18, 2025
ad13ba7
Update prefer-class-fields.md
sindresorhus Jan 18, 2025
980bc42
Update prefer-class-fields.js
sindresorhus Jan 19, 2025
a691cf6
chore: update rules/prefer-class-fields.js
FRSgit Jan 20, 2025
1bbd138
chore: change examples style
FRSgit Jan 21, 2025
0386602
chore: rename test files
FRSgit Jan 21, 2025
4fee503
chore: run fix:eslint docs (works only on node 22)
FRSgit Jan 29, 2025
f65e1fa
Update prefer-class-fields.md
sindresorhus Jan 29, 2025
8322172
chore: rewrite autofix with side-effects to suggestion
FRSgit Feb 15, 2025
ec0d683
chore: add EmptyStatement as whitelisted node preceding this assignment
FRSgit Feb 15, 2025
a0f95f4
chore: fixup issue raised by unnamed class expressions
FRSgit Feb 15, 2025
d17f310
chore: post cr changes
FRSgit May 23, 2025
6ee11bd
chore: lint fixes
FRSgit May 24, 2025
2ad41e6
chore: lint fix
FRSgit May 24, 2025
4e8f8a0
chore: add eslint docs
May 24, 2025
35e57d3
chore: update snapshots
FRSgit May 24, 2025
197078e
chore: post cr changes with finding MethodDefinition constructor
FRSgit May 25, 2025
2950c65
Fix snapshot
fisker May 25, 2025
37a216e
Simplify logic
fisker May 25, 2025
fb6b239
Fix logic
fisker May 25, 2025
ab1362d
Rewrite tests
fisker May 25, 2025
84a9c82
Safer
fisker May 25, 2025
887aac6
Simplify
fisker May 25, 2025
ef80c7f
Linting
fisker May 25, 2025
788978c
Linting
fisker May 25, 2025
dc28bcf
chore: support private fields
FRSgit Jun 9, 2025
ceace46
chore: update snapshots
FRSgit Jun 9, 2025
cbdaae6
chore: post rebase fixes
FRSgit Jun 9, 2025
fcb0a3a
Remove unused message data
fisker Jun 12, 2025
8b7a2c3
Inline `addClassFieldDeclaration` to avoid ESLint warning
fisker Jun 12, 2025
bb7388c
Use one fix function
fisker Jun 12, 2025
6b6cf06
More tests for fix
fisker Jun 12, 2025
d8a2aa8
Fix logic
fisker Jun 12, 2025
5bd7ab4
Still incorrect
fisker Jun 12, 2025
05528d3
One more test
fisker Jun 12, 2025
570a881
Make sure we are using token instead of text
fisker Jun 12, 2025
f2f5b0f
Update prefer-class-fields.md
sindresorhus Jun 13, 2025
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
73 changes: 73 additions & 0 deletions docs/rules/prefer-class-fields.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
# Prefer class field declarations over `this` assignments in constructors

💼 This rule is enabled in the ✅ `recommended` [config](https://github.com/sindresorhus/eslint-plugin-unicorn#recommended-config).

🔧💡 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).

<!-- end auto-generated rule header -->
<!-- Do not manually modify this header. Run: `npm run fix:eslint-docs` -->

This rule enforces the use of class field declarations for static values, instead of assigning them in constructors using `this`.

> To avoid leaving empty constructors after autofixing, use the [`no-useless-constructor` rule](https://eslint.org/docs/latest/rules/no-useless-constructor).

## Examples

```js
// ❌
class Foo {
constructor() {
this.foo = 'foo';
}
}

// ✅
class Foo {
foo = 'foo';
}
```

```js
// ❌
class MyError extends Error {
constructor(message: string) {
super(message);
this.name = 'MyError';
}
}

// ✅
class MyError extends Error {
name = 'MyError'
}
```

```js
// ❌
class Foo {
foo = 'foo';
constructor() {
this.foo = 'bar';
}
}

// ✅
class Foo {
foo = 'bar';
}
```

```js
// ❌
class Foo {
#foo = 'foo';
constructor() {
this.#foo = 'bar';
}
}

// ✅
class Foo {
#foo = 'bar';
}
```
1 change: 1 addition & 0 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ export default [
| [prefer-array-some](docs/rules/prefer-array-some.md) | Prefer `.some(…)` over `.filter(…).length` check and `.{find,findLast,findIndex,findLastIndex}(…)`. | ✅ | 🔧 | 💡 |
| [prefer-at](docs/rules/prefer-at.md) | Prefer `.at()` method for index access and `String#charAt()`. | ✅ | 🔧 | 💡 |
| [prefer-blob-reading-methods](docs/rules/prefer-blob-reading-methods.md) | Prefer `Blob#arrayBuffer()` over `FileReader#readAsArrayBuffer(…)` and `Blob#text()` over `FileReader#readAsText(…)`. | ✅ | | |
| [prefer-class-fields](docs/rules/prefer-class-fields.md) | Prefer class field declarations over `this` assignments in constructors. | ✅ | 🔧 | 💡 |
| [prefer-code-point](docs/rules/prefer-code-point.md) | Prefer `String#codePointAt(…)` over `String#charCodeAt(…)` and `String.fromCodePoint(…)` over `String.fromCharCode(…)`. | ✅ | | 💡 |
| [prefer-date-now](docs/rules/prefer-date-now.md) | Prefer `Date.now()` to get the number of milliseconds since the Unix Epoch. | ✅ | 🔧 | |
| [prefer-default-parameters](docs/rules/prefer-default-parameters.md) | Prefer default parameters over reassignment. | ✅ | | 💡 |
Expand Down
1 change: 1 addition & 0 deletions rules/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ export {default as 'prefer-array-index-of'} from './prefer-array-index-of.js';
export {default as 'prefer-array-some'} from './prefer-array-some.js';
export {default as 'prefer-at'} from './prefer-at.js';
export {default as 'prefer-blob-reading-methods'} from './prefer-blob-reading-methods.js';
export {default as 'prefer-class-fields'} from './prefer-class-fields.js';
export {default as 'prefer-code-point'} from './prefer-code-point.js';
export {default as 'prefer-date-now'} from './prefer-date-now.js';
export {default as 'prefer-default-parameters'} from './prefer-default-parameters.js';
Expand Down
147 changes: 147 additions & 0 deletions rules/prefer-class-fields.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import getIndentString from './utils/get-indent-string.js';

const MESSAGE_ID_ERROR = 'prefer-class-fields/error';
const MESSAGE_ID_SUGGESTION = 'prefer-class-fields/suggestion';
const messages = {
[MESSAGE_ID_ERROR]:
'Prefer class field declaration over `this` assignment in constructor for static values.',
[MESSAGE_ID_SUGGESTION]:
'Encountered same-named class field declaration and `this` assignment in constructor. Replace the class field declaration with the value from `this` assignment.',
};

/**
@param {import('eslint').Rule.Node} node
@param {import('eslint').Rule.RuleContext['sourceCode']} sourceCode
@param {import('eslint').Rule.RuleFixer} fixer
*/
const removeFieldAssignment = (node, sourceCode, fixer) => {
const {line} = sourceCode.getLoc(node).start;
const nodeText = sourceCode.getText(node);
const lineText = sourceCode.lines[line - 1];
const isOnlyNodeOnLine = lineText.trim() === nodeText;

return isOnlyNodeOnLine
? fixer.removeRange([
sourceCode.getIndexFromLoc({line, column: 0}),
sourceCode.getIndexFromLoc({line: line + 1, column: 0}),
])
: fixer.remove(node);
};

/**
@type {import('eslint').Rule.RuleModule['create']}
*/
const create = context => {
const {sourceCode} = context;

return {
ClassBody(classBody) {
const constructor = classBody.body.find(node =>
node.kind === 'constructor'
&& !node.computed
&& !node.static
&& node.type === 'MethodDefinition'
&& node.value.type === 'FunctionExpression',
);

if (!constructor) {
return;
}

const node = constructor.value.body.body.find(node => node.type !== 'EmptyStatement');

if (!(
node?.type === 'ExpressionStatement'
&& node.expression.type === 'AssignmentExpression'
&& node.expression.operator === '='
&& node.expression.left.type === 'MemberExpression'
&& node.expression.left.object.type === 'ThisExpression'
&& !node.expression.left.computed
&& ['Identifier', 'PrivateIdentifier'].includes(node.expression.left.property.type)
&& node.expression.right.type === 'Literal'
)) {
return;
}

const propertyName = node.expression.left.property.name;
const propertyValue = node.expression.right.raw;
const propertyType = node.expression.left.property.type;
const existingProperty = classBody.body.find(node =>
node.type === 'PropertyDefinition'
&& !node.computed
&& !node.static
&& node.key.type === propertyType
&& node.key.name === propertyName,
);

const problem = {
node,
messageId: MESSAGE_ID_ERROR,
};

/**
@param {import('eslint').Rule.RuleFixer} fixer
*/
function * fix(fixer) {
yield removeFieldAssignment(node, sourceCode, fixer);

if (existingProperty) {
yield existingProperty.value
? fixer.replaceText(existingProperty.value, propertyValue)
: fixer.insertTextAfter(existingProperty.key, ` = ${propertyValue}`);
return;
}

const closingBrace = sourceCode.getLastToken(classBody);
const indent = getIndentString(constructor, sourceCode);

let text = `${indent}${propertyName} = ${propertyValue};\n`;

const characterBefore = sourceCode.getText()[sourceCode.getRange(closingBrace)[0] - 1];
if (characterBefore !== '\n') {
text = `\n${text}`;
}

const lastProperty = classBody.body.at(-1);
if (
lastProperty.type === 'PropertyDefinition'
&& sourceCode.getLastToken(lastProperty).value !== ';'
) {
text = `;${text}`;
}

yield fixer.insertTextBefore(closingBrace, text);
}

if (existingProperty?.value) {
problem.suggest = [
{
messageId: MESSAGE_ID_SUGGESTION,
fix,
},
];
return problem;
}

problem.fix = fix;
return problem;
},
};
};

/** @type {import('eslint').Rule.RuleModule} */
const config = {
create,
meta: {
type: 'suggestion',
docs: {
description: 'Prefer class field declarations over `this` assignments in constructors.',
recommended: true,
},
fixable: 'code',
hasSuggestions: true,
messages,
},
};

export default config;
5 changes: 1 addition & 4 deletions rules/utils/rule.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,7 @@ import getDocumentationUrl from './get-documentation-url.js';
const isIterable = object => typeof object?.[Symbol.iterator] === 'function';

class FixAbortError extends Error {
constructor() {
super();
this.name = 'FixAbortError';
}
name = 'FixAbortError';
}
const fixOptions = {
abort() {
Expand Down
1 change: 1 addition & 0 deletions test/package.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ const RULES_WITHOUT_EXAMPLES_SECTION = new Set([
'prefer-modern-math-apis',
'prefer-math-min-max',
'consistent-existence-index-check',
'prefer-class-fields',
'prefer-global-this',
'no-instanceof-builtins',
'no-named-default',
Expand Down
Loading
Loading