fix(eslint-plugin): [no-unnecessary-type-assertion] parenthesize object literal at left edge of expression statement#12443
Conversation
…ct literal at left edge of expression statement
When a `<T>{ ... }` assertion sits at the left edge of a larger expression
statement (binary/logical/conditional/sequence operand, including nested
ones), removing the angle brackets left a leading `{` that the parser reads
as a block — producing invalid output or silently changing semantics.
Generalize the existing concise-arrow-body / bare-statement check with an
`isAtExpressionStatementStart` helper that walks up the ancestors keeping the
node as the statement's leading token (via source-position comparison), so the
fixer wraps the object literal in parentheses in those positions too. A leading
keyword/operator (e.g. `new`) shifts the start, so those are correctly left
untouched, and an already-parenthesized ancestor needs no extra parentheses.
…object-literal assertion fixes Add invalid cases for typescript-eslint#12418 where `<T>{ ... }` is at the left edge of an expression statement (binary, logical, conditional, sequence and nested binary), a parenthesized-sequence case that must not be double-wrapped, and a right-operand case that must be left unwrapped.
|
Thanks for the PR, @zaewc! typescript-eslint is a 100% community driven project, and we are incredibly grateful that you are contributing to that community. The core maintainers work on this in their personal time, so please understand that it may not be possible for them to review your work immediately. Thanks again! 🙏 Please, if you or your company is finding typescript-eslint valuable, help us sustain the project by sponsoring it transparently on https://opencollective.com/typescript-eslint. |
✅ Deploy Preview for typescript-eslint ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
|
View your CI Pipeline Execution ↗ for commit 30053a9
💡 Verify your cache is correct by running tasks in a sandbox. Read docs ↗ ☁️ Nx Cloud last updated this comment at |
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #12443 +/- ##
==========================================
+ Coverage 87.00% 94.69% +7.68%
==========================================
Files 513 222 -291
Lines 16570 11417 -5153
Branches 5175 3804 -1371
==========================================
- Hits 14417 10811 -3606
+ Misses 1463 258 -1205
+ Partials 690 348 -342
Flags with carried forward coverage won't be shown. Click here to find out more.
🚀 New features to boost your workflow:
|
…equence snippet in noFormat
prettier rewrites the bare `<T>{ ... }, foo();` statement by adding
parentheses, which the internal plugin-test-formatting rule flags in CI.
Wrap it in noFormat to keep the intentionally-unparenthesized left-edge
sequence so the case still exercises the fixer.
|
I think that this logic might not be exactly right; rather than looking for an ObjectExpression, I think we might need to look for a leading <number>{ lol: 32 as number }.lol;BTW, technically these abominations exist too: <number>function Fun() { }.length;
<number>class Clazz { }.length;so maybe we want to look for whenever the leftmost token of the assertion's child is one of the forbidden tokens Would you mind playing around with this? |
Where does this come into play? I don't see anything in the code or any test case related to this? Are you talking about some crazy thing like this? class Foo {
constructor() {
<number>new.target.length
}
} |
…`/`function`/`class` token instead of object literal
The assertion binds looser than member access, so its operand can be a
member expression (e.g. `<number>{ a: 1 as number }.a`) whose leading token
is `{`, `function`, or `class` — each of which leads the statement after the
fix and is parsed as a block / function or class declaration. Key off the
first operand token rather than `node.expression` being an `ObjectExpression`,
which also subsumes the previous parenthesized-operand guard.
…ess operands led by `{`/`function`/`class`
Add invalid cases where the assertion wraps a member expression whose first
token is `{`, `function`, or `class`, confirming the operand is parenthesized
so the fix output stays a valid expression statement.
|
Good catch, you're right |
|
I think we need to do the traversal for arrow function expressions too, actually 🤔 Here's another fun test case: const foo = () => <number>{ lol: 123 as number }.lol + 54321; |
| // statement (or is a concise arrow body), that token would lead the | ||
| // statement and be parsed as a block / function or class declaration. | ||
| // Wrap the operand in parentheses to keep it an expression. | ||
| const firstOperandToken = |
There was a problem hiding this comment.
This is getting pretty convoluted and it's conflating distinct cases. Can we clean it up a bit?
WDYT of a structure roughly like this?
const firstOperandToken = nullThrows(
context.sourceCode.getTokenAfter(closingAngleBracket),
NullThrowsReasons.MissingToken('token', 'after type assertion'),
);
const firstTokenBreaksExpressionStatement =
['{', 'function', 'class'].includes(firstOperandToken.value) &&
isAtExpressionStatementStart(node, context.sourceCode);
const firstTokenBreaksArrowFunctionBody =
firstOperandToken.value === '{' &&
isAtArrowFunctionBodyStart(node, context.sourceCode);
const needsParens =
firstTokenBreaksExpressionStatement ||
firstTokenBreaksArrowFunctionBody;(or, with better short-circuiting)
const firstOperandToken = nullThrows(
context.sourceCode.getTokenAfter(closingAngleBracket),
NullThrowsReasons.MissingToken('token', 'after type assertion'),
);
const needsParens = (() => {
if (
['{', 'function', 'class'].includes(firstOperandToken.value) &&
isAtExpressionStatementStart(node, context.sourceCode)
) {
// won't be interpreted as an expression if statement begins with
// one of these tokens.
return true;
}
if (
firstOperandToken.value === '{' &&
isAtArrowFunctionBodyStart(node, context.sourceCode)
) {
// will be interpreted as arrow function with block body if
// not parenthesized
return true;
}
return false;
})();There was a problem hiding this comment.
Thanks for the suggestion
| NullThrowsReasons, | ||
| } from '../util'; | ||
|
|
||
| function isAtExpressionStatementStart( |
There was a problem hiding this comment.
This function as written has some repetition that doesn't seem necessary. Can we DRY it up?
I have this passing all test cases:
function isAtExpressionStatementStart(
node: TSESTree.Node,
sourceCode: TSESLint.SourceCode,
): boolean {
let current: TSESTree.Node = node;
while (true) {
if (current.parent == null) {
return false;
}
if (current.parent.range[0] !== current.range[0]) {
// node is not at the left edge of its parent
return false;
}
if (current.parent.type === AST_NODE_TYPES.ExpressionStatement) {
return true;
}
current = current.parent;
}
}Could you also verify if the isParenthesized() check is necessary (either by including a test case demonstrating its necessity or by removing it if it's not necessary)?
Thanks!
There was a problem hiding this comment.
Adopted your DRYer while (true) version.
On isParenthesized, it's not needed in the expression-statement walk, a wrapping ( shifts a parent's range[0], so the left-edge position check already ends the walk.
The existing (<{ a: string }>{ a: 'foo' }, 1) test demonstrates this. I did keep it in isAtArrowFunctionBodyStart, because an arrow function's range starts at its parameters, so the position check can't detect a ( wrapping the body (e.g. () => (<T>{ ... })). that case is covered by the existing [].map(() => (<{…}>{…})) test.
…dge detection into statement/arrow helpers and traverse arrow bodies
Address review feedback:
- Separate the two distinct concerns into `isAtExpressionStatementStart` and
`isAtArrowFunctionBodyStart`, and compute `needsParens` from explicit
`breaksExpressionStatement` / `breaksArrowFunctionBody` flags.
- Traverse left-edge ancestors for concise arrow bodies too, so cases like
`() => <number>{ a: 1 as number }.a + b` are parenthesized.
- Drop the redundant `isParenthesized` check from the statement walk: a
wrapping `(` shifts a parent's start, so the existing left-edge position
check already ends the walk (the `(<T>{ ... }, 1)` case demonstrates this).
The arrow walk keeps the check, since an arrow's range starts at its
parameters and so can't detect a wrapping `(`.
…row body left-edge assertion
Add a case where the object-literal assertion leads a concise arrow body
through a binary operand, so the fix must parenthesize it to avoid the `{`
being parsed as a block body.
kirkwaiblinger
left a comment
There was a problem hiding this comment.
I think this looks good and is very thorough! Thanks for working on this!
| datasource | package | from | to | | ---------- | -------------------------------- | ------ | ------ | | npm | @typescript-eslint/eslint-plugin | 8.61.0 | 8.62.1 | | npm | @typescript-eslint/parser | 8.61.0 | 8.62.1 | ## [v8.62.1](https://github.com/typescript-eslint/typescript-eslint/blob/HEAD/packages/eslint-plugin/CHANGELOG.md#8621-2026-06-29) ##### 🩹 Fixes - **eslint-plugin:** \[no-unnecessary-type-assertion] parenthesize object literal at left edge of expression statement ([#12443](typescript-eslint/typescript-eslint#12443), [#12418](typescript-eslint/typescript-eslint#12418)) - **eslint-plugin:** \[no-unnecessary-boolean-literal-compare] preserve boolean result in fixer for nullable true comparisons ([#12365](typescript-eslint/typescript-eslint#12365)) - **eslint-plugin:** \[prefer-optional-chain] use suggestion instead of autofix for trailing binary operator ([#12328](typescript-eslint/typescript-eslint#12328)) ##### ❤️ Thank You - Kirk Waiblinger [@kirkwaiblinger](https://github.com/kirkwaiblinger) - mdm317 - Patrick Aleite - 송재욱 See [GitHub Releases](https://github.com/typescript-eslint/typescript-eslint/releases/tag/v8.62.1) for more information. You can read about our [versioning strategy](https://typescript-eslint.io/users/versioning) and [releases](https://typescript-eslint.io/users/releases) on our website. ## [v8.62.0](https://github.com/typescript-eslint/typescript-eslint/blob/HEAD/packages/eslint-plugin/CHANGELOG.md#8620-2026-06-22) ##### 🚀 Features - remove redundant package.json "files" ([#12444](typescript-eslint/typescript-eslint#12444)) ##### ❤️ Thank You - Kirk Waiblinger [@kirkwaiblinger](https://github.com/kirkwaiblinger) See [GitHub Releases](https://github.com/typescript-eslint/typescript-eslint/releases/tag/v8.62.0) for more information. You can read about our [versioning strategy](https://typescript-eslint.io/users/versioning) and [releases](https://typescript-eslint.io/users/releases) on our website. ## [v8.61.1](https://github.com/typescript-eslint/typescript-eslint/blob/HEAD/packages/eslint-plugin/CHANGELOG.md#8611-2026-06-15) ##### 🩹 Fixes - **eslint-plugin:** \[no-unnecessary-template-expression] respect ECMAScript line terminators ([#12388](typescript-eslint/typescript-eslint#12388)) - **eslint-plugin:** \[no-unnecessary-boolean-literal-compare] fix precedence bug in autofix ([#12413](typescript-eslint/typescript-eslint#12413)) - **eslint-plugin:** \[no-unnecessary-type-assertion] wrap object literal in parens when removing TSTypeAssertion in arrow body ([#12394](typescript-eslint/typescript-eslint#12394), [#12393](typescript-eslint/typescript-eslint#12393)) - **eslint-plugin:** \[no-unnecessary-type-assertion] avoid false positive for template literal expressions ([#12281](typescript-eslint/typescript-eslint#12281)) - **eslint-plugin:** \[consistent-indexed-object-style] do not remove comments when fixing ([#12396](typescript-eslint/typescript-eslint#12396), [#10577](typescript-eslint/typescript-eslint#10577)) ##### ❤️ Thank You - Anas [@anasm266](https://github.com/anasm266) - Deftera [@Deftera186](https://github.com/Deftera186) - Kirk Waiblinger [@kirkwaiblinger](https://github.com/kirkwaiblinger) - lumir - Sarath Francis [@sarathfrancis90](https://github.com/sarathfrancis90) See [GitHub Releases](https://github.com/typescript-eslint/typescript-eslint/releases/tag/v8.61.1) for more information. You can read about our [versioning strategy](https://typescript-eslint.io/users/versioning) and [releases](https://typescript-eslint.io/users/releases) on our website.
| datasource | package | from | to | | ---------- | -------------------------------- | ------ | ------ | | npm | @typescript-eslint/eslint-plugin | 8.61.0 | 8.62.1 | | npm | @typescript-eslint/parser | 8.61.0 | 8.62.1 | ## [v8.62.1](https://github.com/typescript-eslint/typescript-eslint/blob/HEAD/packages/eslint-plugin/CHANGELOG.md#8621-2026-06-29) ##### 🩹 Fixes - **eslint-plugin:** \[no-unnecessary-type-assertion] parenthesize object literal at left edge of expression statement ([#12443](typescript-eslint/typescript-eslint#12443), [#12418](typescript-eslint/typescript-eslint#12418)) - **eslint-plugin:** \[no-unnecessary-boolean-literal-compare] preserve boolean result in fixer for nullable true comparisons ([#12365](typescript-eslint/typescript-eslint#12365)) - **eslint-plugin:** \[prefer-optional-chain] use suggestion instead of autofix for trailing binary operator ([#12328](typescript-eslint/typescript-eslint#12328)) ##### ❤️ Thank You - Kirk Waiblinger [@kirkwaiblinger](https://github.com/kirkwaiblinger) - mdm317 - Patrick Aleite - 송재욱 See [GitHub Releases](https://github.com/typescript-eslint/typescript-eslint/releases/tag/v8.62.1) for more information. You can read about our [versioning strategy](https://typescript-eslint.io/users/versioning) and [releases](https://typescript-eslint.io/users/releases) on our website. ## [v8.62.0](https://github.com/typescript-eslint/typescript-eslint/blob/HEAD/packages/eslint-plugin/CHANGELOG.md#8620-2026-06-22) ##### 🚀 Features - remove redundant package.json "files" ([#12444](typescript-eslint/typescript-eslint#12444)) ##### ❤️ Thank You - Kirk Waiblinger [@kirkwaiblinger](https://github.com/kirkwaiblinger) See [GitHub Releases](https://github.com/typescript-eslint/typescript-eslint/releases/tag/v8.62.0) for more information. You can read about our [versioning strategy](https://typescript-eslint.io/users/versioning) and [releases](https://typescript-eslint.io/users/releases) on our website. ## [v8.61.1](https://github.com/typescript-eslint/typescript-eslint/blob/HEAD/packages/eslint-plugin/CHANGELOG.md#8611-2026-06-15) ##### 🩹 Fixes - **eslint-plugin:** \[no-unnecessary-template-expression] respect ECMAScript line terminators ([#12388](typescript-eslint/typescript-eslint#12388)) - **eslint-plugin:** \[no-unnecessary-boolean-literal-compare] fix precedence bug in autofix ([#12413](typescript-eslint/typescript-eslint#12413)) - **eslint-plugin:** \[no-unnecessary-type-assertion] wrap object literal in parens when removing TSTypeAssertion in arrow body ([#12394](typescript-eslint/typescript-eslint#12394), [#12393](typescript-eslint/typescript-eslint#12393)) - **eslint-plugin:** \[no-unnecessary-type-assertion] avoid false positive for template literal expressions ([#12281](typescript-eslint/typescript-eslint#12281)) - **eslint-plugin:** \[consistent-indexed-object-style] do not remove comments when fixing ([#12396](typescript-eslint/typescript-eslint#12396), [#10577](typescript-eslint/typescript-eslint#10577)) ##### ❤️ Thank You - Anas [@anasm266](https://github.com/anasm266) - Deftera [@Deftera186](https://github.com/Deftera186) - Kirk Waiblinger [@kirkwaiblinger](https://github.com/kirkwaiblinger) - lumir - Sarath Francis [@sarathfrancis90](https://github.com/sarathfrancis90) See [GitHub Releases](https://github.com/typescript-eslint/typescript-eslint/releases/tag/v8.61.1) for more information. You can read about our [versioning strategy](https://typescript-eslint.io/users/versioning) and [releases](https://typescript-eslint.io/users/releases) on our website.

PR Checklist
Overview
Follow-up to #12394. When a
<T>{ ... }assertion is at the left edge of a larger expression statement, removing the<...>left a leading{that is parsed as a block (or silently changed semantics):Replaced the direct ExpressionStatement parent check with an
isAtExpressionStatementStarthelper that walks up the ancestors keeping the node as the statement's leading token, so the fixer wraps the object literal in parens in these positions too (binary, logical, conditional, sequence, and nested). A leading keyword like new stops the walk, and an already-parenthesized ancestor isn't double wrapped.