Skip to content

fix(eslint-plugin): [no-unnecessary-type-assertion] parenthesize object literal at left edge of expression statement#12443

Merged
JoshuaKGoldberg merged 7 commits into
typescript-eslint:mainfrom
zaewc:fix/12418-tsa-leftedge-parens
Jun 28, 2026
Merged

fix(eslint-plugin): [no-unnecessary-type-assertion] parenthesize object literal at left edge of expression statement#12443
JoshuaKGoldberg merged 7 commits into
typescript-eslint:mainfrom
zaewc:fix/12418-tsa-leftedge-parens

Conversation

@zaewc

@zaewc zaewc commented Jun 22, 2026

Copy link
Copy Markdown
Contributor

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):

<{ a: number }>{ a: 1 } + 1;    // { a: 1 } + 1;
<{ a: number }>{ a: 1 }, foo();    // { a: 1 }, foo();

Replaced the direct ExpressionStatement parent check with an isAtExpressionStatementStart helper 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.

zaewc added 2 commits June 22, 2026 10:43
…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.
@typescript-eslint

Copy link
Copy Markdown
Contributor

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.

@netlify

netlify Bot commented Jun 22, 2026

Copy link
Copy Markdown

Deploy Preview for typescript-eslint ready!

Name Link
🔨 Latest commit 30053a9
🔍 Latest deploy log https://app.netlify.com/projects/typescript-eslint/deploys/6a39aee5ae99d50008add924
😎 Deploy Preview https://deploy-preview-12443--typescript-eslint.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
Lighthouse
Lighthouse
1 paths audited
Performance: 99 (no change from production)
Accessibility: 97 (no change from production)
Best Practices: 100 (no change from production)
SEO: 90 (no change from production)
PWA: 80 (no change from production)
View the detailed breakdown and full score reports
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

@nx-cloud

nx-cloud Bot commented Jun 22, 2026

Copy link
Copy Markdown

View your CI Pipeline Execution ↗ for commit 30053a9

Command Status Duration Result
nx run-many -t lint --projects=eslint-plugin --... ✅ Succeeded 58s View ↗
nx run-many -t typecheck:tsgo ✅ Succeeded 57s View ↗
nx run-many -t lint --projects=typescript-estre... ✅ Succeeded 54s View ↗
nx run-many -t typecheck ✅ Succeeded 43s View ↗
nx run-many -t lint --projects=ast-spec,utils,s... ✅ Succeeded 31s View ↗
nx run-many -t lint --projects=parser,type-util... ✅ Succeeded 22s View ↗
nx run integration-tests:test ✅ Succeeded 4s View ↗
nx run types:build ✅ Succeeded 1s View ↗
Additional runs (12) ✅ Succeeded ... View ↗

💡 Verify your cache is correct by running tasks in a sandbox. Read docs ↗


☁️ Nx Cloud last updated this comment at 2026-06-22 21:56:53 UTC

@codecov

codecov Bot commented Jun 22, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 92.59259% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 94.69%. Comparing base (b784054) to head (30053a9).
⚠️ Report is 4 commits behind head on main.

Files with missing lines Patch % Lines
...-plugin/src/rules/no-unnecessary-type-assertion.ts 92.59% 1 Missing and 1 partial ⚠️
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     
Flag Coverage Δ
unittest 94.69% <92.59%> (+7.68%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
...-plugin/src/rules/no-unnecessary-type-assertion.ts 95.67% <92.59%> (-0.26%) ⬇️

... and 291 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

…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.
@kirkwaiblinger

kirkwaiblinger commented Jun 22, 2026

Copy link
Copy Markdown
Member

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 { token. Consider something like this, which currently still has the fixer bug:

<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 {, function, or class?

Would you mind playing around with this?

@kirkwaiblinger

Copy link
Copy Markdown
Member

A leading keyword like new stops the walk

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
  }
}

zaewc added 2 commits June 22, 2026 20:36
…`/`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.
@zaewc

zaewc commented Jun 22, 2026

Copy link
Copy Markdown
Contributor Author

@kirkwaiblinger

Good catch, you're right

@kirkwaiblinger

Copy link
Copy Markdown
Member

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 =

@kirkwaiblinger kirkwaiblinger Jun 22, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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;
          })();

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the suggestion

NullThrowsReasons,
} from '../util';

function isAtExpressionStatementStart(

@kirkwaiblinger kirkwaiblinger Jun 22, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

zaewc added 2 commits June 23, 2026 06:53
…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 kirkwaiblinger left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this looks good and is very thorough! Thanks for working on this!

@kirkwaiblinger kirkwaiblinger added the 1 approval >=1 team member has approved this PR; we're now leaving it open for more reviews before we merge label Jun 22, 2026
@JoshuaKGoldberg JoshuaKGoldberg merged commit 4ecca6d into typescript-eslint:main Jun 28, 2026
44 checks passed
renovate Bot added a commit to andrei-picus-tink/auto-renovate that referenced this pull request Jul 1, 2026
| 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.
renovate Bot added a commit to andrei-picus-tink/auto-renovate that referenced this pull request Jul 4, 2026
| 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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

1 approval >=1 team member has approved this PR; we're now leaving it open for more reviews before we merge

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bug: [no-unnecessary-type-assertion] autofix produces invalid code for TSTypeAssertion of ObjectExpression at left edge of an ExpressionStatement

3 participants