From 05b2fdafada8922be7b9ec05513cb8628f5e6cd2 Mon Sep 17 00:00:00 2001 From: Satoshi Qazi Muhammed Date: Fri, 9 Oct 2026 15:15:47 +0400 Subject: [PATCH] Drop the contextual report of no-unnecessary-type-assertion on `as` typescript-eslint 8 reports `x as T` as unnecessary when the receiver would accept `x` unchanged, and its autofix deletes the assertion. That is unsound when the assertion also drives inference around it: const { validators } = found || { validators: {} as ValPoints }; loses the type of `validators`, and every later use becomes an unresolved type (api-derive's stakerRewards after an autofix). It also deletes assertions kept on purpose for another environment's typings. Across api, common, ui and apps it raised 45 reports. The plugin is now registered through withoutContextualAssertions, which drops only that report on `as` / `` assertions. Assertions to the type an expression already has are still reported and fixed, and so are non-null assertions a receiver makes unnecessary. A spec lints a fixture through the real type checker with the plugin wrapped and as published, so it fails if the filter stops filtering. --- packages/dev/config/eslint.js | 4 +- packages/dev/config/eslint.typeAssertion.js | 65 ++++++++++++++++ packages/dev/src/typeAssertion.spec.ts | 86 +++++++++++++++++++++ 3 files changed, 154 insertions(+), 1 deletion(-) create mode 100644 packages/dev/config/eslint.typeAssertion.js create mode 100644 packages/dev/src/typeAssertion.spec.ts diff --git a/packages/dev/config/eslint.js b/packages/dev/config/eslint.js index 0bf0b35..ac36e78 100644 --- a/packages/dev/config/eslint.js +++ b/packages/dev/config/eslint.js @@ -24,6 +24,7 @@ import globals from 'globals'; import { overrideAll, overrideJs, overrideJsx, overrideSpec } from './eslint.rules.js'; import { standardRules } from './eslint.standard.js'; +import { withoutContextualAssertions } from './eslint.typeAssertion.js'; /** * eslint-plugin-header declares no options schema, and eslint 9+ rejects @@ -93,7 +94,8 @@ export default [ }, plugins: { '@stylistic': stylisticPlugin, - '@typescript-eslint': tsPlugin, + // see eslint.typeAssertion.js + '@typescript-eslint': withoutContextualAssertions(tsPlugin), // eslint-plugin-header reports at the first token after the leading // comments, so a generated file's /* eslint-disable */ covers it; the // maintained fork reports at line 1 instead. The compat layer supplies the diff --git a/packages/dev/config/eslint.typeAssertion.js b/packages/dev/config/eslint.typeAssertion.js new file mode 100644 index 0000000..3fe184a --- /dev/null +++ b/packages/dev/config/eslint.typeAssertion.js @@ -0,0 +1,65 @@ +// Copyright 2017-2026 @pezkuwi/dev authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +const RULE = 'no-unnecessary-type-assertion'; +const ASSERTIONS = ['TSAsExpression', 'TSTypeAssertion']; + +/** + * typescript-eslint 8 added a second check to no-unnecessary-type-assertion: + * an `x as T` is reported as unnecessary when the place it is passed to would + * accept `x` as it is ("the receiver accepts the original type"), and the + * autofix deletes the assertion. That is unsound when the assertion also + * drives inference around it: in + * + * const { validators } = found || { validators: {} as ValPoints }; + * + * deleting `as ValPoints` leaves `validators` typed `{}`, and every use of it + * after that is an unresolved type. It also deletes assertions kept on + * purpose for another environment's typings (a Ledger transport under + * esm.sh). Measured across the consumers before this was added: 45 such + * reports, the first of them breaking the types of api-derive. + * + * This keeps the rule as it was before that check: an assertion to the type + * the expression already has is still reported and fixed, and so are non-null + * assertions the receiver makes unnecessary. Only the contextual report on + * `as` / `` assertions is dropped. + * + * @template {object} P + * @param {P} plugin the @typescript-eslint plugin + * @returns {P} + */ +export function withoutContextualAssertions (plugin) { + const rules = /** @type {Record} */ (/** @type {{ rules: unknown }} */ (plugin).rules); + const rule = rules[RULE]; + + return /** @type {P} */ ({ + ...plugin, + rules: { + ...rules, + [RULE]: { + ...rule, + /** @param {import('eslint').Rule.RuleContext} context */ + create (context) { + const filtered = Object.create(context, { + report: { + value: (/** @type {import('eslint').Rule.ReportDescriptor} */ descriptor) => { + if ( + 'messageId' in descriptor && + descriptor.messageId === 'contextuallyUnnecessary' && + 'node' in descriptor && + ASSERTIONS.includes(descriptor.node.type) + ) { + return; + } + + context.report(descriptor); + } + } + }); + + return rule.create(filtered); + } + } + } + }); +} diff --git a/packages/dev/src/typeAssertion.spec.ts b/packages/dev/src/typeAssertion.spec.ts new file mode 100644 index 0000000..bb7f5e6 --- /dev/null +++ b/packages/dev/src/typeAssertion.spec.ts @@ -0,0 +1,86 @@ +// Copyright 2017-2026 @pezkuwi/dev authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +/// + +import type { ESLint as ESLintType } from 'eslint'; + +import tsPlugin from '@typescript-eslint/eslint-plugin'; +import tsParser from '@typescript-eslint/parser'; +import { ESLint } from 'eslint'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +// Loaded by path: the config directory is plain JavaScript outside this +// package's TypeScript root. +const WRAPPER = path.join(process.cwd(), 'packages/dev/config/eslint.typeAssertion.js'); + +const SOURCE = `interface ValPoints { [id: string]: number } + +declare const found: { validators: ValPoints } | undefined; +declare const text: string; +declare function take (value: string): void; + +// contextual: the assertion types \`validators\` below +const { validators } = found || { validators: {} as ValPoints }; + +export const first = validators['a']; + +// unchanged: the expression already has this type +export const same = text as string; + +let target: string | undefined; + +// contextual non-null: assigning to it needs no assertion +target! = text; + +take(target ?? ''); +`; + +async function lint (plugin: ESLintType.Plugin): Promise { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'type-assertion-')); + + try { + fs.writeFileSync(path.join(dir, 'tsconfig.json'), JSON.stringify({ compilerOptions: { strict: true, target: 'es2022' }, include: ['*.ts'] })); + fs.writeFileSync(path.join(dir, 'sample.ts'), SOURCE); + + const eslint = new ESLint({ + cwd: dir, + overrideConfig: [{ + files: ['**/*.ts'], + languageOptions: { + parser: tsParser, + parserOptions: { project: './tsconfig.json', tsconfigRootDir: dir } + }, + plugins: { '@typescript-eslint': plugin }, + rules: { '@typescript-eslint/no-unnecessary-type-assertion': 'error' } + }], + overrideConfigFile: true + }); + const [result] = await eslint.lintFiles(['sample.ts']); + + return result.messages.map(({ line, messageId }) => `${line}:${messageId ?? ''}`); + } finally { + fs.rmSync(dir, { force: true, recursive: true }); + } +} + +describe('withoutContextualAssertions', (): void => { + it('drops the contextual report on an assertion, keeps the others', async (): Promise => { + const { withoutContextualAssertions } = await import(WRAPPER) as { withoutContextualAssertions: (p: ESLintType.Plugin) => ESLintType.Plugin }; + + expect(await lint(withoutContextualAssertions(tsPlugin as unknown as ESLintType.Plugin))).toEqual([ + '13:unnecessaryAssertion', + '18:contextuallyUnnecessary' + ]); + }); + + it('is needed: the plugin as published reports the assertion that types validators', async (): Promise => { + expect(await lint(tsPlugin as unknown as ESLintType.Plugin)).toEqual([ + '8:contextuallyUnnecessary', + '13:unnecessaryAssertion', + '18:contextuallyUnnecessary' + ]); + }); +});