From dab286050aae17629b330c300e606400ff8d6e28 Mon Sep 17 00:00:00 2001 From: Ulrich Stark Date: Mon, 24 Nov 2025 23:33:44 +0100 Subject: [PATCH 01/13] feat(eslint-plugin): [no-unnecessary-type-assertion] report more cases based on assignability --- .../rules/no-unnecessary-type-assertion.ts | 193 +++++++++++---- .../no-unnecessary-type-assertion.test.ts | 227 ++++++++++++++++++ 2 files changed, 368 insertions(+), 52 deletions(-) diff --git a/packages/eslint-plugin/src/rules/no-unnecessary-type-assertion.ts b/packages/eslint-plugin/src/rules/no-unnecessary-type-assertion.ts index a78a8ab9d508..35317559f03c 100644 --- a/packages/eslint-plugin/src/rules/no-unnecessary-type-assertion.ts +++ b/packages/eslint-plugin/src/rules/no-unnecessary-type-assertion.ts @@ -190,7 +190,11 @@ export default createRule({ ); } - function isTypeUnchanged(uncast: ts.Type, cast: ts.Type): boolean { + function isTypeUnchanged( + expression: TSESTree.Expression, + uncast: ts.Type, + cast: ts.Type, + ): boolean { if (uncast === cast) { return true; } @@ -219,13 +223,52 @@ export default createRule({ return castParts.every(part => uncastPartsSet.has(part)); } - return false; + if (isConceptuallyLiteral(expression)) { + return false; + } + + return ( + !isTypeFlagSet(uncast, ts.TypeFlags.Any) && + !isTypeFlagSet(cast, ts.TypeFlags.Any) && + checker.isTypeAssignableTo(uncast, cast) && + checker.isTypeAssignableTo(cast, uncast) + ); } function isTypeLiteral(type: ts.Type) { return type.isLiteral() || tsutils.isBooleanLiteralType(type); } + function getOriginalExpression( + node: TSESTree.TSAsExpression | TSESTree.TSTypeAssertion, + ): TSESTree.Expression { + let current = node.expression; + while ( + current.type === AST_NODE_TYPES.TSAsExpression || + current.type === AST_NODE_TYPES.TSTypeAssertion + ) { + current = current.expression; + } + return current; + } + + function isConceptuallyLiteral(node: TSESTree.Node): boolean { + switch (node.type) { + case AST_NODE_TYPES.Literal: + case AST_NODE_TYPES.ArrayExpression: + case AST_NODE_TYPES.ObjectExpression: + case AST_NODE_TYPES.TemplateLiteral: + case AST_NODE_TYPES.ClassExpression: + case AST_NODE_TYPES.FunctionExpression: + case AST_NODE_TYPES.ArrowFunctionExpression: + case AST_NODE_TYPES.JSXElement: + case AST_NODE_TYPES.JSXFragment: + return true; + default: + return false; + } + } + return { 'TSAsExpression, TSTypeAssertion'( node: TSESTree.TSAsExpression | TSESTree.TSTypeAssertion, @@ -253,68 +296,114 @@ export default createRule({ } const uncastType = services.getTypeAtLocation(node.expression); - const typeIsUnchanged = isTypeUnchanged(uncastType, castType); + const typeIsUnchanged = isTypeUnchanged( + node.expression, + uncastType, + castType, + ); const wouldSameTypeBeInferred = castTypeIsLiteral ? isImplicitlyNarrowedLiteralDeclaration(node) : !typeAnnotationIsConstAssertion; + const reportFix: ReportFixFunction = fixer => { + if (node.type === AST_NODE_TYPES.TSTypeAssertion) { + const openingAngleBracket = nullThrows( + context.sourceCode.getTokenBefore( + node.typeAnnotation, + token => + token.type === AST_TOKEN_TYPES.Punctuator && + token.value === '<', + ), + NullThrowsReasons.MissingToken('<', 'type annotation'), + ); + const closingAngleBracket = nullThrows( + context.sourceCode.getTokenAfter( + node.typeAnnotation, + token => + token.type === AST_TOKEN_TYPES.Punctuator && + token.value === '>', + ), + NullThrowsReasons.MissingToken('>', 'type annotation'), + ); + + // < ( number ) > ( 3 + 5 ) + // ^---remove---^ + return fixer.removeRange([ + openingAngleBracket.range[0], + closingAngleBracket.range[1], + ]); + } + // `as` is always present in TSAsExpression + const asToken = nullThrows( + context.sourceCode.getTokenAfter( + node.expression, + token => + token.type === AST_TOKEN_TYPES.Identifier && + token.value === 'as', + ), + NullThrowsReasons.MissingToken('>', 'type annotation'), + ); + const tokenBeforeAs = nullThrows( + context.sourceCode.getTokenBefore(asToken, { + includeComments: true, + }), + NullThrowsReasons.MissingToken('comment', 'as'), + ); + + // ( 3 + 5 ) as number + // ^--remove--^ + return fixer.removeRange([tokenBeforeAs.range[1], node.range[1]]); + }; + if (typeIsUnchanged && wouldSameTypeBeInferred) { context.report({ node, messageId: 'unnecessaryAssertion', - fix(fixer) { - if (node.type === AST_NODE_TYPES.TSTypeAssertion) { - const openingAngleBracket = nullThrows( - context.sourceCode.getTokenBefore( - node.typeAnnotation, - token => - token.type === AST_TOKEN_TYPES.Punctuator && - token.value === '<', - ), - NullThrowsReasons.MissingToken('<', 'type annotation'), - ); - const closingAngleBracket = nullThrows( - context.sourceCode.getTokenAfter( - node.typeAnnotation, - token => - token.type === AST_TOKEN_TYPES.Punctuator && - token.value === '>', - ), - NullThrowsReasons.MissingToken('>', 'type annotation'), - ); + fix: reportFix, + }); + return; + } - // < ( number ) > ( 3 + 5 ) - // ^---remove---^ - return fixer.removeRange([ - openingAngleBracket.range[0], - closingAngleBracket.range[1], - ]); - } - // `as` is always present in TSAsExpression - const asToken = nullThrows( - context.sourceCode.getTokenAfter( - node.expression, - token => - token.type === AST_TOKEN_TYPES.Identifier && - token.value === 'as', - ), - NullThrowsReasons.MissingToken('>', 'type annotation'), - ); - const tokenBeforeAs = nullThrows( - context.sourceCode.getTokenBefore(asToken, { - includeComments: true, - }), - NullThrowsReasons.MissingToken('comment', 'as'), - ); - - // ( 3 + 5 ) as number - // ^--remove--^ - return fixer.removeRange([tokenBeforeAs.range[1], node.range[1]]); - }, + const originalNode = services.esTreeNodeToTSNodeMap.get(node); + const contextualType = getContextualType(checker, originalNode); + + if ( + contextualType && + !typeAnnotationIsConstAssertion && + !isTypeFlagSet(uncastType, ts.TypeFlags.Any) && + checker.isTypeAssignableTo(uncastType, contextualType) + ) { + context.report({ + node, + messageId: 'contextuallyUnnecessary', + fix: reportFix, }); + return; } - // TODO - add contextually unnecessary check for this + if ( + node.expression.type === AST_NODE_TYPES.TSAsExpression || + node.expression.type === AST_NODE_TYPES.TSTypeAssertion + ) { + const originalExpr = getOriginalExpression(node); + const originalType = services.getTypeAtLocation(originalExpr); + + if ( + isTypeUnchanged(node.expression, originalType, castType) && + !isTypeFlagSet(castType, ts.TypeFlags.Any) + ) { + context.report({ + node, + messageId: 'unnecessaryAssertion', + fix(fixer) { + return fixer.replaceText( + node, + context.sourceCode.getText(originalExpr), + ); + }, + }); + } + } }, TSNonNullExpression(node): void { const removeExclamationFix: ReportFixFunction = fixer => { diff --git a/packages/eslint-plugin/tests/rules/no-unnecessary-type-assertion.test.ts b/packages/eslint-plugin/tests/rules/no-unnecessary-type-assertion.test.ts index b2ef78d4500a..e73b66ec59b3 100644 --- a/packages/eslint-plugin/tests/rules/no-unnecessary-type-assertion.test.ts +++ b/packages/eslint-plugin/tests/rules/no-unnecessary-type-assertion.test.ts @@ -450,6 +450,17 @@ declare const a: T.Value1; const b = a as const; `, }, + { + code: ` +function fn(items: ReadonlyArray) {} +fn([42] as const); + `, + }, + ` +declare const a: any; +declare function foo(arg: string): void; +foo(a as string); + `, ], invalid: [ @@ -1442,5 +1453,221 @@ declare const a: T.Value1; const b = a; `, }, + { + code: ` +function doThing(a: number) {} +doThing(5 as any); + `, + errors: [ + { + messageId: 'contextuallyUnnecessary', + }, + ], + output: ` +function doThing(a: number) {} +doThing(5); + `, + }, + { + code: ` +interface A { + required: string; + alsoRequired: number; +} +function doThing(a: A) {} +doThing({ required: 'yes', alsoRequired: 1 } as any); + `, + errors: [ + { + messageId: 'contextuallyUnnecessary', + }, + ], + output: ` +interface A { + required: string; + alsoRequired: number; +} +function doThing(a: A) {} +doThing({ required: 'yes', alsoRequired: 1 }); + `, + }, + { + code: 'const x = 5 as any as 5;', + errors: [ + { + messageId: 'unnecessaryAssertion', + }, + ], + output: 'const x = 5;', + }, + { + code: ` +const v: number = 5; +const x = v as unknown as number; + `, + errors: [ + { + messageId: 'unnecessaryAssertion', + }, + ], + output: ` +const v: number = 5; +const x = v; + `, + }, + { + code: ` +const v: number = 5; +const x = v as any as number; + `, + errors: [ + { + messageId: 'unnecessaryAssertion', + }, + ], + output: ` +const v: number = 5; +const x = v; + `, + }, + { + code: ` +const x = (1 + 1) as any as number; + `, + errors: [ + { + messageId: 'unnecessaryAssertion', + }, + ], + output: ` +const x = 1 + 1; + `, + }, + { + code: ` +const x = 2 * ((1 + 1) as any as number); + `, + errors: [ + { + messageId: 'unnecessaryAssertion', + }, + ], + output: ` +const x = 2 * (1 + 1); + `, + }, + { + code: ` +const v: number = 5; +const x = (v); + `, + errors: [ + { + messageId: 'unnecessaryAssertion', + }, + ], + output: ` +const v: number = 5; +const x = v; + `, + }, + { + code: ` +const obj = { id: '' }; +const obj2 = obj as { id: string }; + `, + errors: [ + { + messageId: 'unnecessaryAssertion', + }, + ], + output: ` +const obj = { id: '' }; +const obj2 = obj; + `, + }, + { + code: ` +const obj = { id: '' }; +const obj2 = obj as any as { id: string }; + `, + errors: [ + { + messageId: 'unnecessaryAssertion', + }, + ], + output: ` +const obj = { id: '' }; +const obj2 = obj; + `, + }, + { + code: ` +const obj = { id: '' }; +const obj2 = obj as unknown as { id: string }; + `, + errors: [ + { + messageId: 'unnecessaryAssertion', + }, + ], + output: ` +const obj = { id: '' }; +const obj2 = obj; + `, + }, + { + code: ` +const array = ['a', 'b']; +const array2 = array as any as string[]; + `, + errors: [ + { + messageId: 'unnecessaryAssertion', + }, + ], + output: ` +const array = ['a', 'b']; +const array2 = array; + `, + }, + { + code: ` +const array = ['a', 'b']; +const array2 = array as unknown as string[]; + `, + errors: [ + { + messageId: 'unnecessaryAssertion', + }, + ], + output: ` +const array = ['a', 'b']; +const array2 = array; + `, + }, + { + code: ` +type A = 'a'; +type B = 'b'; +type AorB = A | B; +function fn(aorb: AorB) {} +const a: A = 'a'; +fn(a as AorB); + `, + errors: [ + { + messageId: 'contextuallyUnnecessary', + }, + ], + output: ` +type A = 'a'; +type B = 'b'; +type AorB = A | B; +function fn(aorb: AorB) {} +const a: A = 'a'; +fn(a); + `, + }, ], }); From 3c915df86a1faa01e9eeff7701ff789a05458caf Mon Sep 17 00:00:00 2001 From: Ulrich Stark Date: Tue, 25 Nov 2025 23:07:23 +0100 Subject: [PATCH 02/13] don't report false positives --- .../rules/no-unnecessary-type-assertion.ts | 31 +++++++++++++++++-- .../no-unnecessary-type-assertion.test.ts | 13 ++++++++ 2 files changed, 41 insertions(+), 3 deletions(-) diff --git a/packages/eslint-plugin/src/rules/no-unnecessary-type-assertion.ts b/packages/eslint-plugin/src/rules/no-unnecessary-type-assertion.ts index 35317559f03c..e48040d0da75 100644 --- a/packages/eslint-plugin/src/rules/no-unnecessary-type-assertion.ts +++ b/packages/eslint-plugin/src/rules/no-unnecessary-type-assertion.ts @@ -227,14 +227,39 @@ export default createRule({ return false; } + if ( + isTypeFlagSet(uncast, ts.TypeFlags.NonPrimitive) && + !isTypeFlagSet(cast, ts.TypeFlags.NonPrimitive) + ) { + return false; + } + + if (hasIndexSignature(uncast) && !hasIndexSignature(cast)) { + return false; + } + + if (containsAny(uncast) || containsAny(cast)) { + return false; + } + return ( - !isTypeFlagSet(uncast, ts.TypeFlags.Any) && - !isTypeFlagSet(cast, ts.TypeFlags.Any) && checker.isTypeAssignableTo(uncast, cast) && checker.isTypeAssignableTo(cast, uncast) ); } + function hasIndexSignature(type: ts.Type): boolean { + return checker.getIndexInfosOfType(type).length > 0; + } + + function containsAny(type: ts.Type): boolean { + if (isTypeFlagSet(type, ts.TypeFlags.Any)) { + return true; + } + const typeArgs = checker.getTypeArguments(type as ts.TypeReference); + return typeArgs.length > 0 && typeArgs.some(containsAny); + } + function isTypeLiteral(type: ts.Type) { return type.isLiteral() || tsutils.isBooleanLiteralType(type); } @@ -370,7 +395,7 @@ export default createRule({ if ( contextualType && !typeAnnotationIsConstAssertion && - !isTypeFlagSet(uncastType, ts.TypeFlags.Any) && + !containsAny(uncastType) && checker.isTypeAssignableTo(uncastType, contextualType) ) { context.report({ diff --git a/packages/eslint-plugin/tests/rules/no-unnecessary-type-assertion.test.ts b/packages/eslint-plugin/tests/rules/no-unnecessary-type-assertion.test.ts index e73b66ec59b3..c90b28c2d8ab 100644 --- a/packages/eslint-plugin/tests/rules/no-unnecessary-type-assertion.test.ts +++ b/packages/eslint-plugin/tests/rules/no-unnecessary-type-assertion.test.ts @@ -461,6 +461,19 @@ declare const a: any; declare function foo(arg: string): void; foo(a as string); `, + ` +declare const a: object; +const b = a as { id?: number }; + `, + ` +declare const array: any[]; +function foo(strings: string[]): void {} +foo(array as string[]); + `, + ` +declare const record: Record; +const obj = record as { id?: number }; + `, ], invalid: [ From 1534c0a7f4f6911ec61c11eb43ee0a89f46e3aaf Mon Sep 17 00:00:00 2001 From: Ulrich Stark Date: Tue, 25 Nov 2025 23:08:03 +0100 Subject: [PATCH 03/13] fix object expression correctly --- .../rules/no-unnecessary-type-assertion.ts | 11 ++++-- .../no-unnecessary-type-assertion.test.ts | 38 +++++++++++++++++++ 2 files changed, 45 insertions(+), 4 deletions(-) diff --git a/packages/eslint-plugin/src/rules/no-unnecessary-type-assertion.ts b/packages/eslint-plugin/src/rules/no-unnecessary-type-assertion.ts index e48040d0da75..e71614510894 100644 --- a/packages/eslint-plugin/src/rules/no-unnecessary-type-assertion.ts +++ b/packages/eslint-plugin/src/rules/no-unnecessary-type-assertion.ts @@ -421,10 +421,13 @@ export default createRule({ node, messageId: 'unnecessaryAssertion', fix(fixer) { - return fixer.replaceText( - node, - context.sourceCode.getText(originalExpr), - ); + let text = context.sourceCode.getText(originalExpr); + + if (originalExpr.type === AST_NODE_TYPES.ObjectExpression) { + text = `(${text})`; + } + + return fixer.replaceText(node, text); }, }); } diff --git a/packages/eslint-plugin/tests/rules/no-unnecessary-type-assertion.test.ts b/packages/eslint-plugin/tests/rules/no-unnecessary-type-assertion.test.ts index c90b28c2d8ab..e2f0fe44fea7 100644 --- a/packages/eslint-plugin/tests/rules/no-unnecessary-type-assertion.test.ts +++ b/packages/eslint-plugin/tests/rules/no-unnecessary-type-assertion.test.ts @@ -1682,5 +1682,43 @@ const a: A = 'a'; fn(a); `, }, + { + code: ` +interface Props { + a: number; +} +const x = { a: 1 } as unknown as Props; + `, + errors: [ + { + messageId: 'unnecessaryAssertion', + }, + ], + output: ` +interface Props { + a: number; +} +const x = ({ a: 1 }); + `, + }, + { + code: ` +interface Props { + a: number; +} +const fn = (): Props => ({ a: 1 }) as unknown as Props; + `, + errors: [ + { + messageId: 'unnecessaryAssertion', + }, + ], + output: ` +interface Props { + a: number; +} +const fn = (): Props => ({ a: 1 }); + `, + }, ], }); From f19933fcdd3295cf5c1b619beab02723c493802d Mon Sep 17 00:00:00 2001 From: Ulrich Stark Date: Tue, 25 Nov 2025 23:21:14 +0100 Subject: [PATCH 04/13] fix simple true positive reports across whole repo --- .../src/rules/no-misused-promises.ts | 2 +- .../src/rules/triple-slash-reference.ts | 2 +- packages/typescript-estree/src/convert.ts | 26 +++++++------------ packages/typescript-estree/src/node-utils.ts | 4 +-- .../eslint-utils/getParserServices.test.ts | 9 +++---- 5 files changed, 18 insertions(+), 25 deletions(-) diff --git a/packages/eslint-plugin/src/rules/no-misused-promises.ts b/packages/eslint-plugin/src/rules/no-misused-promises.ts index d491da6227d4..b9b9feaf9cff 100644 --- a/packages/eslint-plugin/src/rules/no-misused-promises.ts +++ b/packages/eslint-plugin/src/rules/no-misused-promises.ts @@ -382,7 +382,7 @@ export default createRule({ } const tsNode = services.esTreeNodeToTSNodeMap.get(argument); - if (returnsThenable(checker, tsNode as ts.Expression)) { + if (returnsThenable(checker, tsNode)) { context.report({ node: argument, messageId: 'voidReturnArgument', diff --git a/packages/eslint-plugin/src/rules/triple-slash-reference.ts b/packages/eslint-plugin/src/rules/triple-slash-reference.ts index 79c40ac37782..e79c77a454c1 100644 --- a/packages/eslint-plugin/src/rules/triple-slash-reference.ts +++ b/packages/eslint-plugin/src/rules/triple-slash-reference.ts @@ -129,7 +129,7 @@ export default createRule({ const reference = node.moduleReference; if (reference.type === AST_NODE_TYPES.TSExternalModuleReference) { - hasMatchingReference(reference.expression as TSESTree.Literal); + hasMatchingReference(reference.expression); } } }, diff --git a/packages/typescript-estree/src/convert.ts b/packages/typescript-estree/src/convert.ts index 3facbe34af20..f3c0f4886163 100644 --- a/packages/typescript-estree/src/convert.ts +++ b/packages/typescript-estree/src/convert.ts @@ -2345,7 +2345,7 @@ export class Converter { type: AST_NODE_TYPES.MetaProperty, meta: this.createNode( // TODO: do we really want to convert it to Token? - node.getFirstToken()! as ts.Token, + node.getFirstToken()!, { type: AST_NODE_TYPES.Identifier, decorators: [], @@ -3254,12 +3254,9 @@ export class Converter { if (node.literal.kind === SyntaxKind.NullKeyword) { // 4.0 started nesting null types inside a LiteralType node // but our AST is designed around the old way of null being a keyword - return this.createNode( - node.literal as ts.NullLiteral, - { - type: AST_NODE_TYPES.TSNullKeyword, - }, - ); + return this.createNode(node.literal, { + type: AST_NODE_TYPES.TSNullKeyword, + }); } return this.createNode(node, { @@ -3557,15 +3554,12 @@ export class Converter { result.loc = getLocFor(result.range, this.ast); if (declarationIsDefault) { - return this.createNode( - node as Exclude, - { - type: AST_NODE_TYPES.ExportDefaultDeclaration, - range: [exportKeyword.getStart(this.ast), result.range[1]], - declaration: result as TSESTree.DefaultExportDeclarations, - exportKind: 'value', - }, - ); + return this.createNode(node, { + type: AST_NODE_TYPES.ExportDefaultDeclaration, + range: [exportKeyword.getStart(this.ast), result.range[1]], + declaration: result as TSESTree.DefaultExportDeclarations, + exportKind: 'value', + }); } const isType = result.type === AST_NODE_TYPES.TSInterfaceDeclaration || diff --git a/packages/typescript-estree/src/node-utils.ts b/packages/typescript-estree/src/node-utils.ts index b5990014e589..695b3709187c 100644 --- a/packages/typescript-estree/src/node-utils.ts +++ b/packages/typescript-estree/src/node-utils.ts @@ -387,12 +387,12 @@ export function findFirstMatchingAncestor( node: ts.Node, predicate: (node: ts.Node) => boolean, ): ts.Node | undefined { - let current: ts.Node | undefined = node; + let current = node as ts.Node | undefined; while (current) { if (predicate(current)) { return current; } - current = current.parent as ts.Node | undefined; + current = current.parent; } return undefined; } diff --git a/packages/utils/tests/eslint-utils/getParserServices.test.ts b/packages/utils/tests/eslint-utils/getParserServices.test.ts index 62066108fc64..6d2874b8bf6c 100644 --- a/packages/utils/tests/eslint-utils/getParserServices.test.ts +++ b/packages/utils/tests/eslint-utils/getParserServices.test.ts @@ -20,11 +20,10 @@ const defaults = { const createMockRuleContext = ( overrides: Partial = {}, -): UnknownRuleContext => - ({ - ...defaults, - ...overrides, - }) as unknown as UnknownRuleContext; +): UnknownRuleContext => ({ + ...defaults, + ...overrides, +}); const requiresParserServicesMessageTemplate = (parser = '\\S*'): string => 'You have used a rule which requires type information, .+\n' + From 5d9f7d00d0a1912df05c2a3a1c69b86a147f2995 Mon Sep 17 00:00:00 2001 From: Ulrich Stark Date: Wed, 26 Nov 2025 20:16:12 +0100 Subject: [PATCH 05/13] fix another true positive --- packages/eslint-plugin/src/rules/no-deprecated.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/eslint-plugin/src/rules/no-deprecated.ts b/packages/eslint-plugin/src/rules/no-deprecated.ts index cc6bb70be9c4..f26e4ad72aa0 100644 --- a/packages/eslint-plugin/src/rules/no-deprecated.ts +++ b/packages/eslint-plugin/src/rules/no-deprecated.ts @@ -416,7 +416,8 @@ export default createRule({ const propertyName = propertyType.isStringLiteral() ? propertyType.value - : String(propertyType.value as number); + : // eslint-disable-next-line @typescript-eslint/no-base-to-string + String(propertyType.value); const property = objectType.getProperty(propertyName); From 8704319a1faac5be90f6538471c835f33a316af0 Mon Sep 17 00:00:00 2001 From: Ulrich Stark Date: Wed, 26 Nov 2025 20:27:40 +0100 Subject: [PATCH 06/13] fix readonly false positive --- .../src/rules/no-unnecessary-type-assertion.ts | 10 ++++++++++ .../tests/rules/no-unnecessary-type-assertion.test.ts | 7 +++++++ 2 files changed, 17 insertions(+) diff --git a/packages/eslint-plugin/src/rules/no-unnecessary-type-assertion.ts b/packages/eslint-plugin/src/rules/no-unnecessary-type-assertion.ts index e71614510894..e6a57980ccbc 100644 --- a/packages/eslint-plugin/src/rules/no-unnecessary-type-assertion.ts +++ b/packages/eslint-plugin/src/rules/no-unnecessary-type-assertion.ts @@ -242,6 +242,16 @@ export default createRule({ return false; } + for (const prop of uncast.getProperties()) { + const name = prop.getEscapedName(); + if ( + tsutils.isPropertyReadonlyInType(uncast, name, checker) !== + tsutils.isPropertyReadonlyInType(cast, name, checker) + ) { + return false; + } + } + return ( checker.isTypeAssignableTo(uncast, cast) && checker.isTypeAssignableTo(cast, uncast) diff --git a/packages/eslint-plugin/tests/rules/no-unnecessary-type-assertion.test.ts b/packages/eslint-plugin/tests/rules/no-unnecessary-type-assertion.test.ts index e2f0fe44fea7..edbe0e26faed 100644 --- a/packages/eslint-plugin/tests/rules/no-unnecessary-type-assertion.test.ts +++ b/packages/eslint-plugin/tests/rules/no-unnecessary-type-assertion.test.ts @@ -474,6 +474,13 @@ foo(array as string[]); declare const record: Record; const obj = record as { id?: number }; `, + ` +interface Obj { + id: number; +} +declare const obj: Readonly; +const obj2 = obj as Obj; + `, ], invalid: [ From aa7c34f4cd56579b35adf48dbc463099c39cca30 Mon Sep 17 00:00:00 2001 From: Ulrich Stark Date: Wed, 26 Nov 2025 20:28:21 +0100 Subject: [PATCH 07/13] fix true positive --- packages/rule-tester/src/RuleTester.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/rule-tester/src/RuleTester.ts b/packages/rule-tester/src/RuleTester.ts index 723bc6b17227..a18c52824bcd 100644 --- a/packages/rule-tester/src/RuleTester.ts +++ b/packages/rule-tester/src/RuleTester.ts @@ -595,7 +595,7 @@ export class RuleTester extends TestFramework { ruleName, rule, // no need to pass no infer type parameter down to private methods - invalid as InvalidTestCase, + invalid, seenInvalidTestCases, ); } finally { From c41ef134fbc6be886b7395b9f9996cf3a4296f10 Mon Sep 17 00:00:00 2001 From: Ulrich Stark Date: Wed, 26 Nov 2025 20:35:12 +0100 Subject: [PATCH 08/13] fix another false positive --- .../src/rules/no-unnecessary-type-assertion.ts | 8 +++++++- .../tests/rules/no-unnecessary-type-assertion.test.ts | 4 ++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/eslint-plugin/src/rules/no-unnecessary-type-assertion.ts b/packages/eslint-plugin/src/rules/no-unnecessary-type-assertion.ts index e6a57980ccbc..16b794b8746f 100644 --- a/packages/eslint-plugin/src/rules/no-unnecessary-type-assertion.ts +++ b/packages/eslint-plugin/src/rules/no-unnecessary-type-assertion.ts @@ -242,7 +242,13 @@ export default createRule({ return false; } - for (const prop of uncast.getProperties()) { + const uncastProps = uncast.getProperties(); + const castProps = cast.getProperties(); + if (uncastProps.length !== castProps.length) { + return false; + } + + for (const prop of uncastProps) { const name = prop.getEscapedName(); if ( tsutils.isPropertyReadonlyInType(uncast, name, checker) !== diff --git a/packages/eslint-plugin/tests/rules/no-unnecessary-type-assertion.test.ts b/packages/eslint-plugin/tests/rules/no-unnecessary-type-assertion.test.ts index edbe0e26faed..f4ec3fc787c4 100644 --- a/packages/eslint-plugin/tests/rules/no-unnecessary-type-assertion.test.ts +++ b/packages/eslint-plugin/tests/rules/no-unnecessary-type-assertion.test.ts @@ -481,6 +481,10 @@ interface Obj { declare const obj: Readonly; const obj2 = obj as Obj; `, + ` +declare const record: Record; +const obj = record as { [additionalProperties: string]: unknown; id?: number }; + `, ], invalid: [ From 9381ef0bd205a20f53ccd995f93564b4b645160a Mon Sep 17 00:00:00 2001 From: Ulrich Stark Date: Wed, 26 Nov 2025 20:37:31 +0100 Subject: [PATCH 09/13] extract into hasSameProperties function --- .../rules/no-unnecessary-type-assertion.ts | 29 +++++++++++-------- 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/packages/eslint-plugin/src/rules/no-unnecessary-type-assertion.ts b/packages/eslint-plugin/src/rules/no-unnecessary-type-assertion.ts index 16b794b8746f..b21b12628777 100644 --- a/packages/eslint-plugin/src/rules/no-unnecessary-type-assertion.ts +++ b/packages/eslint-plugin/src/rules/no-unnecessary-type-assertion.ts @@ -242,26 +242,31 @@ export default createRule({ return false; } + if (!hasSameProperties(uncast, cast)) { + return false; + } + + return ( + checker.isTypeAssignableTo(uncast, cast) && + checker.isTypeAssignableTo(cast, uncast) + ); + } + + function hasSameProperties(uncast: ts.Type, cast: ts.Type): boolean { const uncastProps = uncast.getProperties(); const castProps = cast.getProperties(); + if (uncastProps.length !== castProps.length) { return false; } - for (const prop of uncastProps) { + return uncastProps.every(prop => { const name = prop.getEscapedName(); - if ( - tsutils.isPropertyReadonlyInType(uncast, name, checker) !== + return ( + tsutils.isPropertyReadonlyInType(uncast, name, checker) === tsutils.isPropertyReadonlyInType(cast, name, checker) - ) { - return false; - } - } - - return ( - checker.isTypeAssignableTo(uncast, cast) && - checker.isTypeAssignableTo(cast, uncast) - ); + ); + }); } function hasIndexSignature(type: ts.Type): boolean { From c29eb5443a6322a2cc9c2e8d760011208dc23548 Mon Sep 17 00:00:00 2001 From: Ulrich Stark Date: Wed, 26 Nov 2025 21:34:25 +0100 Subject: [PATCH 10/13] fix another false positive --- .../rules/no-unnecessary-type-assertion.ts | 13 ++++++++++++ .../no-unnecessary-type-assertion.test.ts | 20 +++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/packages/eslint-plugin/src/rules/no-unnecessary-type-assertion.ts b/packages/eslint-plugin/src/rules/no-unnecessary-type-assertion.ts index b21b12628777..d20e35866d8c 100644 --- a/packages/eslint-plugin/src/rules/no-unnecessary-type-assertion.ts +++ b/packages/eslint-plugin/src/rules/no-unnecessary-type-assertion.ts @@ -246,6 +246,19 @@ export default createRule({ return false; } + const uncastTypeArgs = checker.getTypeArguments( + uncast as ts.TypeReference, + ); + const castTypeArgs = checker.getTypeArguments(cast as ts.TypeReference); + if (uncastTypeArgs.length !== castTypeArgs.length) { + return false; + } + for (let i = 0; i < uncastTypeArgs.length; i++) { + if (uncastTypeArgs[i] !== castTypeArgs[i]) { + return false; + } + } + return ( checker.isTypeAssignableTo(uncast, cast) && checker.isTypeAssignableTo(cast, uncast) diff --git a/packages/eslint-plugin/tests/rules/no-unnecessary-type-assertion.test.ts b/packages/eslint-plugin/tests/rules/no-unnecessary-type-assertion.test.ts index f4ec3fc787c4..2e74d85f0640 100644 --- a/packages/eslint-plugin/tests/rules/no-unnecessary-type-assertion.test.ts +++ b/packages/eslint-plugin/tests/rules/no-unnecessary-type-assertion.test.ts @@ -485,6 +485,26 @@ const obj2 = obj as Obj; declare const record: Record; const obj = record as { [additionalProperties: string]: unknown; id?: number }; `, + ` +interface PropsA { + a?: number; +} +interface PropsB extends PropsA { + b?: string; +} +declare const propsB: PropsB; +const propsA = propsB as PropsA; + `, + ` +interface PropsA { + a?: number; +} +interface PropsB extends PropsA { + b?: string; +} +declare const propsB: PropsB[]; +const propsA = propsB as PropsA[]; + `, ], invalid: [ From 35b60402400e31cd8cbf2f8c26cd0948eb3d530a Mon Sep 17 00:00:00 2001 From: Ulrich Stark Date: Wed, 26 Nov 2025 21:53:13 +0100 Subject: [PATCH 11/13] fix more true positives after merging in main --- .../eslint-plugin/src/rules/explicit-member-accessibility.ts | 2 +- .../src/rules/no-unnecessary-parameter-property-assignment.ts | 2 +- packages/eslint-plugin/src/rules/parameter-properties.ts | 2 +- .../src/util/class-scope-analyzer/extractComputedName.ts | 2 +- packages/eslint-plugin/src/util/collectUnusedVariables.ts | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/eslint-plugin/src/rules/explicit-member-accessibility.ts b/packages/eslint-plugin/src/rules/explicit-member-accessibility.ts index b6ca8af38488..96c382b40bd0 100644 --- a/packages/eslint-plugin/src/rules/explicit-member-accessibility.ts +++ b/packages/eslint-plugin/src/rules/explicit-member-accessibility.ts @@ -362,7 +362,7 @@ export default createRule({ const nodeName = node.parameter.type === AST_NODE_TYPES.Identifier ? node.parameter.name - : (node.parameter.left as TSESTree.Identifier).name; + : node.parameter.left.name; switch (paramPropCheck) { case 'explicit': { diff --git a/packages/eslint-plugin/src/rules/no-unnecessary-parameter-property-assignment.ts b/packages/eslint-plugin/src/rules/no-unnecessary-parameter-property-assignment.ts index 9268e07de8b9..97939030103a 100644 --- a/packages/eslint-plugin/src/rules/no-unnecessary-parameter-property-assignment.ts +++ b/packages/eslint-plugin/src/rules/no-unnecessary-parameter-property-assignment.ts @@ -109,7 +109,7 @@ export default createRule({ ((node.parameter.type === AST_NODE_TYPES.Identifier && // constructor (public foo) {} node.parameter.name === name) || (node.parameter.type === AST_NODE_TYPES.AssignmentPattern && // constructor (public foo = 1) {} - (node.parameter.left as TSESTree.Identifier).name === name)) + node.parameter.left.name === name)) ); } diff --git a/packages/eslint-plugin/src/rules/parameter-properties.ts b/packages/eslint-plugin/src/rules/parameter-properties.ts index ddc83cd0ac18..4e27cf1052cb 100644 --- a/packages/eslint-plugin/src/rules/parameter-properties.ts +++ b/packages/eslint-plugin/src/rules/parameter-properties.ts @@ -110,7 +110,7 @@ export default createRule({ const name = node.parameter.type === AST_NODE_TYPES.Identifier ? node.parameter.name - : (node.parameter.left as TSESTree.Identifier).name; + : node.parameter.left.name; context.report({ node, diff --git a/packages/eslint-plugin/src/util/class-scope-analyzer/extractComputedName.ts b/packages/eslint-plugin/src/util/class-scope-analyzer/extractComputedName.ts index ef6b9e1acff2..eab5855f70f5 100644 --- a/packages/eslint-plugin/src/util/class-scope-analyzer/extractComputedName.ts +++ b/packages/eslint-plugin/src/util/class-scope-analyzer/extractComputedName.ts @@ -62,7 +62,7 @@ export function extractNameForMember(node: MemberNode): ExtractedName | null { const identifier = node.parameter.type === AST_NODE_TYPES.Identifier ? node.parameter - : (node.parameter.left as TSESTree.Identifier); + : node.parameter.left; return extractNonComputedName(identifier); } diff --git a/packages/eslint-plugin/src/util/collectUnusedVariables.ts b/packages/eslint-plugin/src/util/collectUnusedVariables.ts index d4d04fefb960..ac91954223d2 100644 --- a/packages/eslint-plugin/src/util/collectUnusedVariables.ts +++ b/packages/eslint-plugin/src/util/collectUnusedVariables.ts @@ -144,7 +144,7 @@ class UnusedVarsVisitor extends Visitor { let identifier: TSESTree.Identifier; switch (node.parameter.type) { case AST_NODE_TYPES.AssignmentPattern: - identifier = node.parameter.left as TSESTree.Identifier; + identifier = node.parameter.left; break; case AST_NODE_TYPES.Identifier: From 3aabb2ba9e0550f6a41911cd54ae86bd0dba91b9 Mon Sep 17 00:00:00 2001 From: Ulrich Stark Date: Wed, 26 Nov 2025 22:12:41 +0100 Subject: [PATCH 12/13] increase test coverage by adding another valid test case --- .../tests/rules/no-unnecessary-type-assertion.test.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/eslint-plugin/tests/rules/no-unnecessary-type-assertion.test.ts b/packages/eslint-plugin/tests/rules/no-unnecessary-type-assertion.test.ts index 2e74d85f0640..f32fc2c8d5c0 100644 --- a/packages/eslint-plugin/tests/rules/no-unnecessary-type-assertion.test.ts +++ b/packages/eslint-plugin/tests/rules/no-unnecessary-type-assertion.test.ts @@ -505,6 +505,16 @@ interface PropsB extends PropsA { declare const propsB: PropsB[]; const propsA = propsB as PropsA[]; `, + ` +class Box { + value: T; +} +class PairBox { + value: T; +} +declare const pairBox: PairBox; +const box = pairBox as Box; + `, ], invalid: [ From 1e6a961e220b8e7aa0187799ba79c1e159ca5565 Mon Sep 17 00:00:00 2001 From: Ulrich Stark Date: Wed, 10 Dec 2025 18:49:23 +0100 Subject: [PATCH 13/13] support more double assertion cases --- .../rules/no-unnecessary-type-assertion.ts | 71 +++++++++++++------ .../no-unnecessary-type-assertion.test.ts | 30 ++++++++ 2 files changed, 79 insertions(+), 22 deletions(-) diff --git a/packages/eslint-plugin/src/rules/no-unnecessary-type-assertion.ts b/packages/eslint-plugin/src/rules/no-unnecessary-type-assertion.ts index d20e35866d8c..e55798e68a13 100644 --- a/packages/eslint-plugin/src/rules/no-unnecessary-type-assertion.ts +++ b/packages/eslint-plugin/src/rules/no-unnecessary-type-assertion.ts @@ -311,6 +311,43 @@ export default createRule({ return current; } + function isDoubleAssertionUnnecessary( + node: TSESTree.TSAsExpression | TSESTree.TSTypeAssertion, + contextualType: ts.Type | undefined, + ): boolean { + const innerExpression = node.expression; + if ( + innerExpression.type !== AST_NODE_TYPES.TSAsExpression && + innerExpression.type !== AST_NODE_TYPES.TSTypeAssertion + ) { + return false; + } + + const originalExpr = getOriginalExpression(node); + const originalType = services.getTypeAtLocation(originalExpr); + const castType = services.getTypeAtLocation(node); + + if ( + isTypeUnchanged(innerExpression, originalType, castType) && + !isTypeFlagSet(castType, ts.TypeFlags.Any) + ) { + return true; + } + + if (contextualType) { + const intermediateType = services.getTypeAtLocation(innerExpression); + if ( + (isTypeFlagSet(intermediateType, ts.TypeFlags.Any) || + isTypeFlagSet(intermediateType, ts.TypeFlags.Unknown)) && + checker.isTypeAssignableTo(originalType, contextualType) + ) { + return true; + } + } + + return false; + } + function isConceptuallyLiteral(node: TSESTree.Node): boolean { switch (node.type) { case AST_NODE_TYPES.Literal: @@ -440,31 +477,21 @@ export default createRule({ return; } - if ( - node.expression.type === AST_NODE_TYPES.TSAsExpression || - node.expression.type === AST_NODE_TYPES.TSTypeAssertion - ) { + if (isDoubleAssertionUnnecessary(node, contextualType)) { const originalExpr = getOriginalExpression(node); - const originalType = services.getTypeAtLocation(originalExpr); - - if ( - isTypeUnchanged(node.expression, originalType, castType) && - !isTypeFlagSet(castType, ts.TypeFlags.Any) - ) { - context.report({ - node, - messageId: 'unnecessaryAssertion', - fix(fixer) { - let text = context.sourceCode.getText(originalExpr); + context.report({ + node, + messageId: 'unnecessaryAssertion', + fix(fixer) { + let text = context.sourceCode.getText(originalExpr); - if (originalExpr.type === AST_NODE_TYPES.ObjectExpression) { - text = `(${text})`; - } + if (originalExpr.type === AST_NODE_TYPES.ObjectExpression) { + text = `(${text})`; + } - return fixer.replaceText(node, text); - }, - }); - } + return fixer.replaceText(node, text); + }, + }); } }, TSNonNullExpression(node): void { diff --git a/packages/eslint-plugin/tests/rules/no-unnecessary-type-assertion.test.ts b/packages/eslint-plugin/tests/rules/no-unnecessary-type-assertion.test.ts index f32fc2c8d5c0..f976d6f7ca53 100644 --- a/packages/eslint-plugin/tests/rules/no-unnecessary-type-assertion.test.ts +++ b/packages/eslint-plugin/tests/rules/no-unnecessary-type-assertion.test.ts @@ -1761,5 +1761,35 @@ interface Props { const fn = (): Props => ({ a: 1 }); `, }, + { + code: ` +declare function fn(param: number): void; +fn(42 as unknown as number); + `, + errors: [ + { + messageId: 'unnecessaryAssertion', + }, + ], + output: ` +declare function fn(param: number): void; +fn(42); + `, + }, + { + code: ` +declare function fn(param: number): void; +fn(42 as any as number); + `, + errors: [ + { + messageId: 'unnecessaryAssertion', + }, + ], + output: ` +declare function fn(param: number): void; +fn(42); + `, + }, ], });