From f76329a4cbc37cfc9778c6a86950155f621076fc Mon Sep 17 00:00:00 2001 From: Josh Goldberg Date: Mon, 29 Sep 2025 07:02:38 -0400 Subject: [PATCH 1/7] feat(eslint-plugin): [no-unsafe-member-access] add allowOptionalChaining option --- .../docs/rules/no-unsafe-member-access.mdx | 38 ++++++ .../src/rules/no-unsafe-member-access.ts | 58 ++++++++- .../rules/no-unsafe-member-access.test.ts | 122 ++++++++++++++++++ 3 files changed, 212 insertions(+), 6 deletions(-) diff --git a/packages/eslint-plugin/docs/rules/no-unsafe-member-access.mdx b/packages/eslint-plugin/docs/rules/no-unsafe-member-access.mdx index 2f1eb0edbf0f..ad6c36d33b5f 100644 --- a/packages/eslint-plugin/docs/rules/no-unsafe-member-access.mdx +++ b/packages/eslint-plugin/docs/rules/no-unsafe-member-access.mdx @@ -65,6 +65,44 @@ arr[idx++]; +## Options + +### `allowOptionalChaining` + +{/* insert option description */} + +Examples of code for this rule with `{ allowOptionalChaining: true }`: + + + + +```ts +declare const outer: any; + +outer.inner; +outer.middle.inner; +outer?.middle.inner; +``` + + + + +```ts +declare const outer: any; + +outer?.inner; +outer?.middle?.inner; +``` + + + + +:::caution +We only recommend using `allowOptionalChaining` to help transition an existing project towards fully enabling `no-unsafe-member-access`. +Optional chaining makes is more safe than normal property accesses in that you won't get a runtime error if the parent value is `null` or `undefined`. +However, it still results in an `any`-typed value, which is unsafe. +::: + ## When Not To Use It If your codebase has many existing `any`s or areas of unsafe code, it may be difficult to enable this rule. diff --git a/packages/eslint-plugin/src/rules/no-unsafe-member-access.ts b/packages/eslint-plugin/src/rules/no-unsafe-member-access.ts index 6cf16d5211e4..c939348219e9 100644 --- a/packages/eslint-plugin/src/rules/no-unsafe-member-access.ts +++ b/packages/eslint-plugin/src/rules/no-unsafe-member-access.ts @@ -15,6 +15,7 @@ import { const enum State { Unsafe = 1, Safe = 2, + Chained = 3, } function createDataType(type: ts.Type): '`any`' | '`error` typed' { @@ -22,7 +23,18 @@ function createDataType(type: ts.Type): '`any`' | '`error` typed' { return isErrorType ? '`error` typed' : '`any`'; } -export default createRule({ +export type Options = [ + { + allowOptionalChaining?: boolean; + }, +]; + +export type MessageIds = + | 'unsafeComputedMemberAccess' + | 'unsafeMemberExpression' + | 'unsafeThisMemberExpression'; + +export default createRule({ name: 'no-unsafe-member-access', meta: { type: 'problem', @@ -41,10 +53,25 @@ export default createRule({ 'You can try to fix this by turning on the `noImplicitThis` compiler option, or adding a `this` parameter to the function.', ].join('\n'), }, - schema: [], + schema: [ + { + type: 'object', + additionalProperties: false, + properties: { + allowOptionalChaining: { + type: 'boolean', + description: 'Whether to allow `?.` optional chains on any values.', + }, + }, + }, + ], }, - defaultOptions: [], - create(context) { + defaultOptions: [ + { + allowOptionalChaining: false, + }, + ], + create(context, [{ allowOptionalChaining }]) { const services = getParserServices(context); const compilerOptions = services.program.getCompilerOptions(); const isNoImplicitThis = tsutils.isStrictCompilerOptionEnabled( @@ -54,7 +81,20 @@ export default createRule({ const stateCache = new Map(); + // Case notes: + // value?.outer.middle.inner + // The ChainExpression is a child of the root expression, and a parent of all the MemberExpressions. + // But the left-most expression is what we want to report on: the inner-most expressions. + // In fact, this is true even if the chain is on the inside! + // value.outer.middle?.inner; + // It was already true that every `object` (MemberExpression) has optional: boolean + function checkMemberExpression(node: TSESTree.MemberExpression): State { + if (allowOptionalChaining && node.optional) { + stateCache.set(node, State.Chained); + return State.Chained; + } + const cachedState = stateCache.get(node); if (cachedState) { return cachedState; @@ -77,8 +117,7 @@ export default createRule({ if (state === State.Unsafe) { const propertyName = context.sourceCode.getText(node.property); - let messageId: 'unsafeMemberExpression' | 'unsafeThisMemberExpression' = - 'unsafeMemberExpression'; + let messageId: MessageIds = 'unsafeMemberExpression'; if (!isNoImplicitThis) { // `this.foo` or `this.foo[bar]` @@ -114,6 +153,13 @@ export default createRule({ 'MemberExpression[computed = true] > *.property'( node: TSESTree.Expression, ): void { + if ( + allowOptionalChaining && + (node.parent as TSESTree.MemberExpression).optional + ) { + return; + } + if ( // x[1] node.type === AST_NODE_TYPES.Literal || diff --git a/packages/eslint-plugin/tests/rules/no-unsafe-member-access.test.ts b/packages/eslint-plugin/tests/rules/no-unsafe-member-access.test.ts index 089d20ee6e19..50b24d58fd0a 100644 --- a/packages/eslint-plugin/tests/rules/no-unsafe-member-access.test.ts +++ b/packages/eslint-plugin/tests/rules/no-unsafe-member-access.test.ts @@ -67,6 +67,38 @@ class B implements F.S.T.A {} ` interface B extends F.S.T.A {} `, + { + code: ` +function foo(x?: { a: number }) { + x?.a; +} + `, + options: [{ allowOptionalChaining: true }], + }, + { + code: ` +function foo(x?: { a: number }, y: string) { + x?.[y]; +} + `, + options: [{ allowOptionalChaining: true }], + }, + { + code: ` +function foo(x: { a: number }, y: 'a') { + x?.[y]; +} + `, + options: [{ allowOptionalChaining: true }], + }, + { + code: ` +function foo(x: { a: number }, y: NotKnown) { + x?.[y]; +} + `, + options: [{ allowOptionalChaining: true }], + }, ], invalid: [ { @@ -382,5 +414,95 @@ class C { }, ], }, + { + code: ` +let value: any; + +value?.middle.inner; + `, + errors: [ + { + column: 15, + data: { + property: '.inner', + type: '`any`', + }, + endColumn: 20, + line: 4, + messageId: 'unsafeMemberExpression', + }, + ], + options: [{ allowOptionalChaining: true }], + }, + { + code: ` +let value: any; + +value?.outer.middle.inner; + `, + errors: [ + { + column: 14, + data: { + property: '.middle', + type: '`any`', + }, + endColumn: 20, + line: 4, + messageId: 'unsafeMemberExpression', + }, + ], + options: [{ allowOptionalChaining: true }], + }, + { + code: ` +let value: any; + +value.outer?.middle.inner; + `, + errors: [ + { + column: 7, + data: { + property: '.outer', + type: '`any`', + }, + endColumn: 12, + line: 4, + messageId: 'unsafeMemberExpression', + }, + { + column: 21, + data: { + property: '.inner', + type: '`any`', + }, + endColumn: 26, + line: 4, + messageId: 'unsafeMemberExpression', + }, + ], + options: [{ allowOptionalChaining: true }], + }, + { + code: ` +let value: any; + +value.outer.middle?.inner; + `, + errors: [ + { + column: 7, + data: { + property: '.outer', + type: '`any`', + }, + endColumn: 12, + line: 4, + messageId: 'unsafeMemberExpression', + }, + ], + options: [{ allowOptionalChaining: true }], + }, ], }); From 055dd59eae9247e4dfe155aa00cd90809762e8ff Mon Sep 17 00:00:00 2001 From: Josh Goldberg Date: Mon, 29 Sep 2025 07:50:39 -0400 Subject: [PATCH 2/7] Update rule schema snapshot --- .../no-unsafe-member-access.shot | 21 ++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/packages/eslint-plugin/tests/schema-snapshots/no-unsafe-member-access.shot b/packages/eslint-plugin/tests/schema-snapshots/no-unsafe-member-access.shot index 42f81875ed94..82c0ace2eeef 100644 --- a/packages/eslint-plugin/tests/schema-snapshots/no-unsafe-member-access.shot +++ b/packages/eslint-plugin/tests/schema-snapshots/no-unsafe-member-access.shot @@ -1,10 +1,25 @@ # SCHEMA: -[] +[ + { + "additionalProperties": false, + "properties": { + "allowOptionalChaining": { + "description": "Whether to allow `?.` optional chains on any values.", + "type": "boolean" + } + }, + "type": "object" + } +] # TYPES: -/** No options declared */ -type Options = []; \ No newline at end of file +type Options = [ + { + /** Whether to allow `?.` optional chains on any values. */ + allowOptionalChaining?: boolean; + }, +]; From afd687b6652d33a4921aac27d7516f181abbd1e5 Mon Sep 17 00:00:00 2001 From: Josh Goldberg Date: Mon, 29 Sep 2025 10:23:47 -0400 Subject: [PATCH 3/7] Fix docs --- packages/eslint-plugin/docs/rules/no-unsafe-member-access.mdx | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/eslint-plugin/docs/rules/no-unsafe-member-access.mdx b/packages/eslint-plugin/docs/rules/no-unsafe-member-access.mdx index ad6c36d33b5f..b9707eb635ce 100644 --- a/packages/eslint-plugin/docs/rules/no-unsafe-member-access.mdx +++ b/packages/eslint-plugin/docs/rules/no-unsafe-member-access.mdx @@ -81,7 +81,6 @@ declare const outer: any; outer.inner; outer.middle.inner; -outer?.middle.inner; ``` From d723a38624d3f2a2ff2417b8a937bb3845a26830 Mon Sep 17 00:00:00 2001 From: Josh Goldberg Date: Mon, 29 Sep 2025 10:24:39 -0400 Subject: [PATCH 4/7] Add missing ts option; update snapshots --- .../docs/rules/no-unsafe-member-access.mdx | 2 +- .../no-unsafe-member-access.shot | 17 +++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/packages/eslint-plugin/docs/rules/no-unsafe-member-access.mdx b/packages/eslint-plugin/docs/rules/no-unsafe-member-access.mdx index b9707eb635ce..9f71ea0ebf9b 100644 --- a/packages/eslint-plugin/docs/rules/no-unsafe-member-access.mdx +++ b/packages/eslint-plugin/docs/rules/no-unsafe-member-access.mdx @@ -86,7 +86,7 @@ outer.middle.inner; -```ts +```ts option='{ "allowOptionalChaining": true }' declare const outer: any; outer?.inner; diff --git a/packages/eslint-plugin/tests/docs-eslint-output-snapshots/no-unsafe-member-access.shot b/packages/eslint-plugin/tests/docs-eslint-output-snapshots/no-unsafe-member-access.shot index fee1bc00f418..91a7260593f1 100644 --- a/packages/eslint-plugin/tests/docs-eslint-output-snapshots/no-unsafe-member-access.shot +++ b/packages/eslint-plugin/tests/docs-eslint-output-snapshots/no-unsafe-member-access.shot @@ -43,3 +43,20 @@ arr[1]; let idx = 1; arr[idx]; arr[idx++]; + +Incorrect + +declare const outer: any; + +outer.inner; + ~~~~~ Unsafe member access .inner on an `any` value. +outer.middle.inner; + ~~~~~~ Unsafe member access .middle on an `any` value. + +Correct +Options: { "allowOptionalChaining": true } + +declare const outer: any; + +outer?.inner; +outer?.middle?.inner; From 501a3c91441a91a8075b8a7e61ffda10576d1dfc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josh=20Goldberg=20=E2=9C=A8?= Date: Tue, 30 Sep 2025 09:47:29 -0400 Subject: [PATCH 5/7] Apply suggestions from code review Co-authored-by: Ronen Amiel --- packages/eslint-plugin/docs/rules/no-unsafe-member-access.mdx | 2 +- packages/eslint-plugin/src/rules/no-unsafe-member-access.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/eslint-plugin/docs/rules/no-unsafe-member-access.mdx b/packages/eslint-plugin/docs/rules/no-unsafe-member-access.mdx index 9f71ea0ebf9b..f96138fc93eb 100644 --- a/packages/eslint-plugin/docs/rules/no-unsafe-member-access.mdx +++ b/packages/eslint-plugin/docs/rules/no-unsafe-member-access.mdx @@ -98,7 +98,7 @@ outer?.middle?.inner; :::caution We only recommend using `allowOptionalChaining` to help transition an existing project towards fully enabling `no-unsafe-member-access`. -Optional chaining makes is more safe than normal property accesses in that you won't get a runtime error if the parent value is `null` or `undefined`. +Optional chaining makes it safer than normal property accesses in that you won't get a runtime error if the parent value is `null` or `undefined`. However, it still results in an `any`-typed value, which is unsafe. ::: diff --git a/packages/eslint-plugin/src/rules/no-unsafe-member-access.ts b/packages/eslint-plugin/src/rules/no-unsafe-member-access.ts index c939348219e9..a6f045532afd 100644 --- a/packages/eslint-plugin/src/rules/no-unsafe-member-access.ts +++ b/packages/eslint-plugin/src/rules/no-unsafe-member-access.ts @@ -60,7 +60,7 @@ export default createRule({ properties: { allowOptionalChaining: { type: 'boolean', - description: 'Whether to allow `?.` optional chains on any values.', + description: 'Whether to allow `?.` optional chains on `any` values.', }, }, }, From 7b9caae9c51f0cb91eed4c89adeb9535f3b67803 Mon Sep 17 00:00:00 2001 From: Josh Goldberg Date: Tue, 30 Sep 2025 09:57:27 -0400 Subject: [PATCH 6/7] Added test for allowOptionalChaining without optional --- .../src/rules/no-unsafe-member-access.ts | 3 ++- .../rules/no-unsafe-member-access.test.ts | 20 +++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/packages/eslint-plugin/src/rules/no-unsafe-member-access.ts b/packages/eslint-plugin/src/rules/no-unsafe-member-access.ts index a6f045532afd..510d53c5e3e3 100644 --- a/packages/eslint-plugin/src/rules/no-unsafe-member-access.ts +++ b/packages/eslint-plugin/src/rules/no-unsafe-member-access.ts @@ -60,7 +60,8 @@ export default createRule({ properties: { allowOptionalChaining: { type: 'boolean', - description: 'Whether to allow `?.` optional chains on `any` values.', + description: + 'Whether to allow `?.` optional chains on `any` values.', }, }, }, diff --git a/packages/eslint-plugin/tests/rules/no-unsafe-member-access.test.ts b/packages/eslint-plugin/tests/rules/no-unsafe-member-access.test.ts index 50b24d58fd0a..b300edf5bb0e 100644 --- a/packages/eslint-plugin/tests/rules/no-unsafe-member-access.test.ts +++ b/packages/eslint-plugin/tests/rules/no-unsafe-member-access.test.ts @@ -504,5 +504,25 @@ value.outer.middle?.inner; ], options: [{ allowOptionalChaining: true }], }, + { + code: ` +function foo(x: { a: number }, y: NotKnown) { + x[y]; +} + `, + errors: [ + { + column: 5, + data: { + property: '[y]', + type: '`error` typed', + }, + endColumn: 6, + line: 3, + messageId: 'unsafeComputedMemberAccess', + }, + ], + options: [{ allowOptionalChaining: true }], + }, ], }); From c45b7867b19f789dec5300e374b3f29f3403c7c3 Mon Sep 17 00:00:00 2001 From: Josh Goldberg Date: Tue, 30 Sep 2025 11:16:49 -0400 Subject: [PATCH 7/7] yarn test -u --- .../tests/schema-snapshots/no-unsafe-member-access.shot | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/eslint-plugin/tests/schema-snapshots/no-unsafe-member-access.shot b/packages/eslint-plugin/tests/schema-snapshots/no-unsafe-member-access.shot index 82c0ace2eeef..254c8f5ff07e 100644 --- a/packages/eslint-plugin/tests/schema-snapshots/no-unsafe-member-access.shot +++ b/packages/eslint-plugin/tests/schema-snapshots/no-unsafe-member-access.shot @@ -6,7 +6,7 @@ "additionalProperties": false, "properties": { "allowOptionalChaining": { - "description": "Whether to allow `?.` optional chains on any values.", + "description": "Whether to allow `?.` optional chains on `any` values.", "type": "boolean" } }, @@ -19,7 +19,7 @@ type Options = [ { - /** Whether to allow `?.` optional chains on any values. */ + /** Whether to allow `?.` optional chains on `any` values. */ allowOptionalChaining?: boolean; }, ];