diff --git a/common/changes/@rushstack/eslint-config/native-private-fields_2026-08-18-12-00-00.json b/common/changes/@rushstack/eslint-config/native-private-fields_2026-08-18-12-00-00.json new file mode 100644 index 00000000000..4aabe1f11ca --- /dev/null +++ b/common/changes/@rushstack/eslint-config/native-private-fields_2026-08-18-12-00-00.json @@ -0,0 +1,9 @@ +{ + "changes": [ + { + "packageName": "@rushstack/eslint-config", + "comment": "Enable the rule that prefers ECMAScript private class members.", + "type": "minor" + } + ] +} diff --git a/common/changes/@rushstack/eslint-plugin/native-private-fields_2026-08-18-12-00-00.json b/common/changes/@rushstack/eslint-plugin/native-private-fields_2026-08-18-12-00-00.json new file mode 100644 index 00000000000..1bfe58309c1 --- /dev/null +++ b/common/changes/@rushstack/eslint-plugin/native-private-fields_2026-08-18-12-00-00.json @@ -0,0 +1,9 @@ +{ + "changes": [ + { + "packageName": "@rushstack/eslint-plugin", + "comment": "Add a rule that requires ECMAScript private syntax for class fields, methods, and accessors.", + "type": "minor" + } + ] +} diff --git a/eslint/eslint-config/flat/profile/_common.js b/eslint/eslint-config/flat/profile/_common.js index 4513f97c146..5cf7eebe575 100644 --- a/eslint/eslint-config/flat/profile/_common.js +++ b/eslint/eslint-config/flat/profile/_common.js @@ -220,6 +220,9 @@ const commonConfig = [ // RATIONALE: See the @rushstack/eslint-plugin documentation '@rushstack/no-new-null': 'warn', + // RATIONALE: See the @rushstack/eslint-plugin documentation + '@rushstack/prefer-ecmascript-private-members': 'warn', + // RATIONALE: See the @rushstack/eslint-plugin documentation '@rushstack/typedef-var': 'warn', diff --git a/eslint/eslint-config/profile/_common.js b/eslint/eslint-config/profile/_common.js index 584628cd15f..6b49372fd0f 100644 --- a/eslint/eslint-config/profile/_common.js +++ b/eslint/eslint-config/profile/_common.js @@ -236,6 +236,9 @@ function buildRules(profile) { // RATIONALE: See the @rushstack/eslint-plugin documentation '@rushstack/no-new-null': 'warn', + // RATIONALE: See the @rushstack/eslint-plugin documentation + '@rushstack/prefer-ecmascript-private-members': 'warn', + // RATIONALE: See the @rushstack/eslint-plugin documentation '@rushstack/typedef-var': 'warn', diff --git a/eslint/eslint-plugin/README.md b/eslint/eslint-plugin/README.md index b76d3142ea2..04dc53c9127 100644 --- a/eslint/eslint-plugin/README.md +++ b/eslint/eslint-plugin/README.md @@ -425,6 +425,41 @@ enum E { let e: E._PrivateMember = E._PrivateMember; // okay, because _PrivateMember is declared by E ``` +## `@rushstack/prefer-ecmascript-private-members` + +Require ECMAScript private syntax for fields, methods, and accessors declared with TypeScript's `private` +modifier. + +#### Rule Details + +ECMAScript `#` members provide runtime privacy. TypeScript's `private` modifier is erased during compilation, +allowing the member to be accessed through JavaScript, bracket notation, or type assertions. + +This rule applies to class fields, methods, and accessors. Private constructors and constructor parameter +properties are not affected. The rule does not provide an autofix because converting a member requires updating +every reference and may change runtime behavior for reflection or objects created without invoking the +constructor. + +#### Examples + +The following pattern is considered a problem: + +```ts +class Example { + private value: string = ''; // error + private calculate(): number {} // error +} +``` + +The following pattern is NOT considered a problem: + +```ts +class Example { + #value: string = ''; + #calculate(): number {} +} +``` + ## `@rushstack/normalized-imports` Require relative import paths to be written in a normalized minimal form and autofix unnecessary directory traversals. diff --git a/eslint/eslint-plugin/src/index.ts b/eslint/eslint-plugin/src/index.ts index 61f0c64f23e..a3d2d7288a4 100644 --- a/eslint/eslint-plugin/src/index.ts +++ b/eslint/eslint-plugin/src/index.ts @@ -14,6 +14,7 @@ import { normalizedImportsRule } from './normalized-imports'; import { typedefVar } from './typedef-var'; import { importRequiresChunkNameRule } from './import-requires-chunk-name'; import { pairReactDomRenderUnmountRule } from './pair-react-dom-render-unmount'; +import { preferEcmascriptPrivateMembersRule } from './prefer-ecmascript-private-members'; interface IPlugin { rules: { [ruleName: string]: TSESLint.RuleModule }; @@ -52,7 +53,10 @@ const plugin: IPlugin = { 'import-requires-chunk-name': importRequiresChunkNameRule, // Full name: "@rushstack/pair-react-dom-render-unmount" - 'pair-react-dom-render-unmount': pairReactDomRenderUnmountRule + 'pair-react-dom-render-unmount': pairReactDomRenderUnmountRule, + + // Full name: "@rushstack/prefer-ecmascript-private-members" + 'prefer-ecmascript-private-members': preferEcmascriptPrivateMembersRule } }; diff --git a/eslint/eslint-plugin/src/prefer-ecmascript-private-members.ts b/eslint/eslint-plugin/src/prefer-ecmascript-private-members.ts new file mode 100644 index 00000000000..f5e6f52244a --- /dev/null +++ b/eslint/eslint-plugin/src/prefer-ecmascript-private-members.ts @@ -0,0 +1,44 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import type { TSESLint, TSESTree } from '@typescript-eslint/utils'; + +type MessageIds = 'use-ecmascript-private-member'; +type Options = []; + +const preferEcmascriptPrivateMembersRule: TSESLint.RuleModule = { + defaultOptions: [], + meta: { + type: 'suggestion', + messages: { + 'use-ecmascript-private-member': + 'Use ECMAScript private syntax ("#member") instead of the TypeScript "private" modifier.' + }, + schema: [], + docs: { + description: 'Require ECMAScript private syntax for private class fields, methods, and accessors', + recommended: 'recommended', + url: 'https://www.npmjs.com/package/@rushstack/eslint-plugin' + } as TSESLint.RuleMetaDataDocs + }, + create: (context: TSESLint.RuleContext) => ({ + PropertyDefinition(node: TSESTree.PropertyDefinition): void { + if (node.accessibility === 'private') { + context.report({ + node, + messageId: 'use-ecmascript-private-member' + }); + } + }, + MethodDefinition(node: TSESTree.MethodDefinition): void { + if (node.accessibility === 'private' && node.kind !== 'constructor') { + context.report({ + node, + messageId: 'use-ecmascript-private-member' + }); + } + } + }) +}; + +export { preferEcmascriptPrivateMembersRule }; diff --git a/eslint/eslint-plugin/src/test/prefer-ecmascript-private-members.test.ts b/eslint/eslint-plugin/src/test/prefer-ecmascript-private-members.test.ts new file mode 100644 index 00000000000..e26ab3dae58 --- /dev/null +++ b/eslint/eslint-plugin/src/test/prefer-ecmascript-private-members.test.ts @@ -0,0 +1,80 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import type { RuleTester } from '@typescript-eslint/rule-tester'; + +import { preferEcmascriptPrivateMembersRule } from '../prefer-ecmascript-private-members'; +import { getRuleTesterWithoutProject } from './ruleTester'; + +const ruleTester: RuleTester = getRuleTesterWithoutProject(); + +ruleTester.run('prefer-ecmascript-private-members', preferEcmascriptPrivateMembersRule, { + invalid: [ + { + code: 'class Example { private value: string = ""; }', + errors: [{ messageId: 'use-ecmascript-private-member' }] + }, + { + code: 'class Example { private static readonly values: Set = new Set(); }', + errors: [{ messageId: 'use-ecmascript-private-member' }] + }, + { + code: 'class Example { private optional?: string; private assigned!: string; }', + errors: [ + { messageId: 'use-ecmascript-private-member' }, + { messageId: 'use-ecmascript-private-member' } + ] + }, + { + code: 'class Example { declare private value: string; }', + errors: [{ messageId: 'use-ecmascript-private-member' }] + }, + { + code: 'class Example { private ["value"]: string = ""; }', + errors: [{ messageId: 'use-ecmascript-private-member' }] + }, + { + code: 'class Example { private calculate(): number { return 1; } }', + errors: [{ messageId: 'use-ecmascript-private-member' }] + }, + { + code: [ + 'class Example {', + ' private get value(): string { return ""; }', + ' private set value(value: string) {}', + '}' + ].join('\n'), + errors: [ + { messageId: 'use-ecmascript-private-member' }, + { messageId: 'use-ecmascript-private-member' } + ] + } + ], + valid: [ + { + code: 'class Example { #value: string = ""; static #values: Set = new Set(); }' + }, + { + code: [ + 'class Example {', + ' #calculate(): number { return 1; }', + ' get #value(): string { return ""; }', + ' set #value(value: string) {}', + '}' + ].join('\n') + }, + { + code: 'class Example { public value: string = ""; protected otherValue: string = ""; }' + }, + { + code: [ + 'class Example {', + ' public constructor(private readonly parameter: string) {}', + '}' + ].join('\n') + }, + { + code: 'class Example { private constructor() {} }' + } + ] +});