From 5a9cd780c929151e529a110b1f1f31466cd4372d Mon Sep 17 00:00:00 2001 From: Bharat Middha <5100938+bmiddha@users.noreply.github.com> Date: Wed, 19 Aug 2026 14:45:29 -0700 Subject: [PATCH 1/2] feat(eslint): require native private fields Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 8ebd5bf2-c44b-42d5-be25-e7936d4b0a14 --- ...ve-private-fields_2026-08-18-12-00-00.json | 9 +++ ...ve-private-fields_2026-08-18-12-00-00.json | 9 +++ eslint/eslint-config/flat/profile/_common.js | 3 + eslint/eslint-config/profile/_common.js | 3 + eslint/eslint-plugin/README.md | 31 +++++++++++ eslint/eslint-plugin/src/index.ts | 6 +- .../src/prefer-ecmascript-private-fields.ts | 36 ++++++++++++ .../prefer-ecmascript-private-fields.test.ts | 55 +++++++++++++++++++ 8 files changed, 151 insertions(+), 1 deletion(-) create mode 100644 common/changes/@rushstack/eslint-config/native-private-fields_2026-08-18-12-00-00.json create mode 100644 common/changes/@rushstack/eslint-plugin/native-private-fields_2026-08-18-12-00-00.json create mode 100644 eslint/eslint-plugin/src/prefer-ecmascript-private-fields.ts create mode 100644 eslint/eslint-plugin/src/test/prefer-ecmascript-private-fields.test.ts 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..b6f0990ee77 --- /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 fields.", + "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..6e1f814f621 --- /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 class fields instead of TypeScript private fields.", + "type": "minor" + } + ] +} diff --git a/eslint/eslint-config/flat/profile/_common.js b/eslint/eslint-config/flat/profile/_common.js index 4513f97c146..0740e556f04 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-fields': '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..57a261013a3 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-fields': '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..abe3b2cc9be 100644 --- a/eslint/eslint-plugin/README.md +++ b/eslint/eslint-plugin/README.md @@ -425,6 +425,37 @@ enum E { let e: E._PrivateMember = E._PrivateMember; // okay, because _PrivateMember is declared by E ``` +## `@rushstack/prefer-ecmascript-private-fields` + +Require ECMAScript private fields instead of fields declared with TypeScript's `private` modifier. + +#### Rule Details + +ECMAScript `#` fields provide runtime privacy. TypeScript's `private` modifier is erased during compilation, +allowing the field to be read or written through JavaScript, bracket notation, or type assertions. + +This rule applies only to class fields. Private methods, accessors, and constructor parameter properties are +not affected. The rule does not provide an autofix because converting a field 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 +} +``` + +The following pattern is NOT considered a problem: + +```ts +class Example { + #value: string = ''; +} +``` + ## `@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..def9236f4e3 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 { preferEcmascriptPrivateFieldsRule } from './prefer-ecmascript-private-fields'; 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-fields" + 'prefer-ecmascript-private-fields': preferEcmascriptPrivateFieldsRule } }; diff --git a/eslint/eslint-plugin/src/prefer-ecmascript-private-fields.ts b/eslint/eslint-plugin/src/prefer-ecmascript-private-fields.ts new file mode 100644 index 00000000000..17f5427bdea --- /dev/null +++ b/eslint/eslint-plugin/src/prefer-ecmascript-private-fields.ts @@ -0,0 +1,36 @@ +// 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-field'; +type Options = []; + +const preferEcmascriptPrivateFieldsRule: TSESLint.RuleModule = { + defaultOptions: [], + meta: { + type: 'suggestion', + messages: { + 'use-ecmascript-private-field': + 'Use an ECMAScript private field ("#field") instead of the TypeScript "private" modifier.' + }, + schema: [], + docs: { + description: 'Require ECMAScript private fields instead of TypeScript private class fields', + 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-field' + }); + } + } + }) +}; + +export { preferEcmascriptPrivateFieldsRule }; diff --git a/eslint/eslint-plugin/src/test/prefer-ecmascript-private-fields.test.ts b/eslint/eslint-plugin/src/test/prefer-ecmascript-private-fields.test.ts new file mode 100644 index 00000000000..8d1fe6a242f --- /dev/null +++ b/eslint/eslint-plugin/src/test/prefer-ecmascript-private-fields.test.ts @@ -0,0 +1,55 @@ +// 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 { preferEcmascriptPrivateFieldsRule } from '../prefer-ecmascript-private-fields'; +import { getRuleTesterWithoutProject } from './ruleTester'; + +const ruleTester: RuleTester = getRuleTesterWithoutProject(); + +ruleTester.run('prefer-ecmascript-private-fields', preferEcmascriptPrivateFieldsRule, { + invalid: [ + { + code: 'class Example { private value: string = ""; }', + errors: [{ messageId: 'use-ecmascript-private-field' }] + }, + { + code: 'class Example { private static readonly values: Set = new Set(); }', + errors: [{ messageId: 'use-ecmascript-private-field' }] + }, + { + code: 'class Example { private optional?: string; private assigned!: string; }', + errors: [ + { messageId: 'use-ecmascript-private-field' }, + { messageId: 'use-ecmascript-private-field' } + ] + }, + { + code: 'class Example { declare private value: string; }', + errors: [{ messageId: 'use-ecmascript-private-field' }] + }, + { + code: 'class Example { private ["value"]: string = ""; }', + errors: [{ messageId: 'use-ecmascript-private-field' }] + } + ], + valid: [ + { + code: 'class Example { #value: string = ""; static #values: Set = new Set(); }' + }, + { + code: 'class Example { public value: string = ""; protected otherValue: string = ""; }' + }, + { + code: [ + 'class Example {', + ' private method(): void {}', + ' private get value(): string { return ""; }', + ' private set value(value: string) {}', + ' public constructor(private readonly parameter: string) {}', + '}' + ].join('\n') + } + ] +}); From c34121d7318dfca34d57bc12396484e4fc75e06e Mon Sep 17 00:00:00 2001 From: Bharat Middha <5100938+bmiddha@users.noreply.github.com> Date: Wed, 19 Aug 2026 15:19:23 -0700 Subject: [PATCH 2/2] feat(eslint): cover private methods Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 8ebd5bf2-c44b-42d5-be25-e7936d4b0a14 --- ...ve-private-fields_2026-08-18-12-00-00.json | 2 +- ...ve-private-fields_2026-08-18-12-00-00.json | 2 +- eslint/eslint-config/flat/profile/_common.js | 2 +- eslint/eslint-config/profile/_common.js | 2 +- eslint/eslint-plugin/README.md | 18 ++++--- eslint/eslint-plugin/src/index.ts | 6 +-- ...s => prefer-ecmascript-private-members.ts} | 22 ++++++--- ...prefer-ecmascript-private-members.test.ts} | 47 ++++++++++++++----- 8 files changed, 69 insertions(+), 32 deletions(-) rename eslint/eslint-plugin/src/{prefer-ecmascript-private-fields.ts => prefer-ecmascript-private-members.ts} (50%) rename eslint/eslint-plugin/src/test/{prefer-ecmascript-private-fields.test.ts => prefer-ecmascript-private-members.test.ts} (52%) 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 index b6f0990ee77..4aabe1f11ca 100644 --- 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 @@ -2,7 +2,7 @@ "changes": [ { "packageName": "@rushstack/eslint-config", - "comment": "Enable the rule that prefers ECMAScript private class fields.", + "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 index 6e1f814f621..1bfe58309c1 100644 --- 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 @@ -2,7 +2,7 @@ "changes": [ { "packageName": "@rushstack/eslint-plugin", - "comment": "Add a rule that requires ECMAScript private class fields instead of TypeScript private fields.", + "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 0740e556f04..5cf7eebe575 100644 --- a/eslint/eslint-config/flat/profile/_common.js +++ b/eslint/eslint-config/flat/profile/_common.js @@ -221,7 +221,7 @@ const commonConfig = [ '@rushstack/no-new-null': 'warn', // RATIONALE: See the @rushstack/eslint-plugin documentation - '@rushstack/prefer-ecmascript-private-fields': 'warn', + '@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 57a261013a3..6b49372fd0f 100644 --- a/eslint/eslint-config/profile/_common.js +++ b/eslint/eslint-config/profile/_common.js @@ -237,7 +237,7 @@ function buildRules(profile) { '@rushstack/no-new-null': 'warn', // RATIONALE: See the @rushstack/eslint-plugin documentation - '@rushstack/prefer-ecmascript-private-fields': 'warn', + '@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 abe3b2cc9be..04dc53c9127 100644 --- a/eslint/eslint-plugin/README.md +++ b/eslint/eslint-plugin/README.md @@ -425,18 +425,20 @@ enum E { let e: E._PrivateMember = E._PrivateMember; // okay, because _PrivateMember is declared by E ``` -## `@rushstack/prefer-ecmascript-private-fields` +## `@rushstack/prefer-ecmascript-private-members` -Require ECMAScript private fields instead of fields declared with TypeScript's `private` modifier. +Require ECMAScript private syntax for fields, methods, and accessors declared with TypeScript's `private` +modifier. #### Rule Details -ECMAScript `#` fields provide runtime privacy. TypeScript's `private` modifier is erased during compilation, -allowing the field to be read or written through JavaScript, bracket notation, or type assertions. +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 only to class fields. Private methods, accessors, and constructor parameter properties are -not affected. The rule does not provide an autofix because converting a field requires updating every reference -and may change runtime behavior for reflection or objects created without invoking the constructor. +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 @@ -445,6 +447,7 @@ The following pattern is considered a problem: ```ts class Example { private value: string = ''; // error + private calculate(): number {} // error } ``` @@ -453,6 +456,7 @@ The following pattern is NOT considered a problem: ```ts class Example { #value: string = ''; + #calculate(): number {} } ``` diff --git a/eslint/eslint-plugin/src/index.ts b/eslint/eslint-plugin/src/index.ts index def9236f4e3..a3d2d7288a4 100644 --- a/eslint/eslint-plugin/src/index.ts +++ b/eslint/eslint-plugin/src/index.ts @@ -14,7 +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 { preferEcmascriptPrivateFieldsRule } from './prefer-ecmascript-private-fields'; +import { preferEcmascriptPrivateMembersRule } from './prefer-ecmascript-private-members'; interface IPlugin { rules: { [ruleName: string]: TSESLint.RuleModule }; @@ -55,8 +55,8 @@ const plugin: IPlugin = { // Full name: "@rushstack/pair-react-dom-render-unmount" 'pair-react-dom-render-unmount': pairReactDomRenderUnmountRule, - // Full name: "@rushstack/prefer-ecmascript-private-fields" - 'prefer-ecmascript-private-fields': preferEcmascriptPrivateFieldsRule + // Full name: "@rushstack/prefer-ecmascript-private-members" + 'prefer-ecmascript-private-members': preferEcmascriptPrivateMembersRule } }; diff --git a/eslint/eslint-plugin/src/prefer-ecmascript-private-fields.ts b/eslint/eslint-plugin/src/prefer-ecmascript-private-members.ts similarity index 50% rename from eslint/eslint-plugin/src/prefer-ecmascript-private-fields.ts rename to eslint/eslint-plugin/src/prefer-ecmascript-private-members.ts index 17f5427bdea..f5e6f52244a 100644 --- a/eslint/eslint-plugin/src/prefer-ecmascript-private-fields.ts +++ b/eslint/eslint-plugin/src/prefer-ecmascript-private-members.ts @@ -3,20 +3,20 @@ import type { TSESLint, TSESTree } from '@typescript-eslint/utils'; -type MessageIds = 'use-ecmascript-private-field'; +type MessageIds = 'use-ecmascript-private-member'; type Options = []; -const preferEcmascriptPrivateFieldsRule: TSESLint.RuleModule = { +const preferEcmascriptPrivateMembersRule: TSESLint.RuleModule = { defaultOptions: [], meta: { type: 'suggestion', messages: { - 'use-ecmascript-private-field': - 'Use an ECMAScript private field ("#field") instead of the TypeScript "private" modifier.' + 'use-ecmascript-private-member': + 'Use ECMAScript private syntax ("#member") instead of the TypeScript "private" modifier.' }, schema: [], docs: { - description: 'Require ECMAScript private fields instead of TypeScript private class fields', + 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 @@ -26,11 +26,19 @@ const preferEcmascriptPrivateFieldsRule: TSESLint.RuleModule = new Set(); }', - errors: [{ messageId: 'use-ecmascript-private-field' }] + errors: [{ messageId: 'use-ecmascript-private-member' }] }, { code: 'class Example { private optional?: string; private assigned!: string; }', errors: [ - { messageId: 'use-ecmascript-private-field' }, - { messageId: 'use-ecmascript-private-field' } + { messageId: 'use-ecmascript-private-member' }, + { messageId: 'use-ecmascript-private-member' } ] }, { code: 'class Example { declare private value: string; }', - errors: [{ messageId: 'use-ecmascript-private-field' }] + errors: [{ messageId: 'use-ecmascript-private-member' }] }, { code: 'class Example { private ["value"]: string = ""; }', - errors: [{ messageId: 'use-ecmascript-private-field' }] + 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 {', - ' private method(): void {}', - ' private get value(): string { return ""; }', - ' private set value(value: string) {}', ' public constructor(private readonly parameter: string) {}', '}' ].join('\n') + }, + { + code: 'class Example { private constructor() {} }' } ] });