fix(linter/arrow-body-style): keep type assertions when adding a block body (#27978)

Closes #27977

With `arrow-body-style: ["error", "always"]`, the fix unwrapped the
concise body with `get_inner_expression`, which strips TS wrappers
together with the parentheses, so the type assertion was lost. Now only
the parentheses around the leading object literal are removed (the same
tokens ESLint removes) and the rest of the expression is kept.

| Input | Before | After |
| --- | --- | --- |
| `() => <T>{ id }` | `() => {return { id }}` | `() => {return <T>{ id
}}` |
| `() => <T>({ id })` | `() => {return { id }}` | `() => {return <T>({
id })}` |
| `() => ({ id }) as T` | `() => {return { id }}` | `() => {return { id
} as T}` |
| `() => ({ id }) satisfies T` | `() => {return { id }}` | `() =>
{return { id } satisfies T}` |

Plain `() => ({ id })` still becomes `() => {return { id }}`.

Tests: added a `.ts` test (`test_typescript`) with pass/fail/fix cases
(also `({ id })!`, `(({ id }) as T)` and
`requireReturnForObjectLiteral`) and its snapshot. `cargo test
--all-features -p oxc_linter` and `just lint -p oxc_linter` pass.

AI disclosure: this PR was prepared with the help of an AI assistant
(Claude).
This commit is contained in:
Yan Almeida authored and GitHub committed 2026-10-19 22:31:59 +00:00
1 parent 853fca45b1
commit 0298062de2
2 files changed
+161 -11

No files matched your search

@@ -4,7 +4,9 @@ use serde_json::Value;
use oxc_ast::{
AstKind,
ast::{ArrowFunctionExpression, Expression, ReturnStatement, Statement},
ast::{
ArrowFunctionExpression, Expression, ParenthesizedExpression, ReturnStatement, Statement,
},
};
use oxc_diagnostics::OxcDiagnostic;
use oxc_macros::declare_oxc_lint;
@@ -318,21 +320,37 @@ impl ArrowBodyStyle {
return fixer.noop();
};
let expr_text = ctx.source_range(expr.span());
// Check if the expression is a parenthesized object literal: `() => ({ ... })`
// In that case, we need to remove the outer parens when converting to block:
// `() => ({ ... })` → `() => { return { ... } }`
let inner_expr = expr.get_inner_expression();
if matches!(inner_expr, Expression::ObjectExpression(_)) {
let inner_text = ctx.source_range(inner_expr.span());
return fixer.replace(body.span(), format!("{{return {inner_text}}}"));
// Only the parens around the object literal can be dropped; a type assertion wrapping it
// must be kept: `() => ({ ... }) as T` → `() => { return { ... } as T }`
if let Some(paren) = Self::leading_parenthesized_object(expr) {
let object_text = ctx.source_range(paren.expression.without_parentheses().span());
let rest_text = ctx.source_range(Span::new(paren.span.end, expr.span().end));
return fixer.replace(body.span(), format!("{{return {object_text}{rest_text}}}"));
}
// For all other expressions, just wrap in `{ return ... }`
let expr_text = ctx.source_range(expr.span());
fixer.replace(body.span(), format!("{{return {expr_text}}}"))
}
/// Find the parenthesized object literal a concise body starts with, looking through
/// postfix TS wrappers: `({ ... })`, `({ ... }) as T`, `({ ... }) satisfies T`, `({ ... })!`
fn leading_parenthesized_object<'b, 'a>(
expr: &'b Expression<'a>,
) -> Option<&'b ParenthesizedExpression<'a>> {
match expr {
Expression::ParenthesizedExpression(paren) => {
matches!(paren.expression.without_parentheses(), Expression::ObjectExpression(_))
.then_some(&**paren)
}
Expression::TSAsExpression(e) => Self::leading_parenthesized_object(&e.expression),
Expression::TSSatisfiesExpression(e) => {
Self::leading_parenthesized_object(&e.expression)
}
Expression::TSNonNullExpression(e) => Self::leading_parenthesized_object(&e.expression),
_ => None,
}
}
/// Fix: Convert block body to concise body
/// `() => { return expr }` → `() => expr`
fn fix_block_to_concise<'a>(
@@ -1104,3 +1122,76 @@ var foo = () =>
.expect_fix(fix)
.test_and_snapshot();
}
#[test]
fn test_typescript() {
use crate::tester::Tester;
let pass = vec![
("var foo = () => { return <Foo>{ id }; };", Some(serde_json::json!(["always"]))),
("var foo = () => { return { id } as Foo; };", Some(serde_json::json!(["always"]))),
];
let fail = vec![
("var foo = () => <Foo>{ id };", Some(serde_json::json!(["always"]))),
("var foo = () => <Foo>({ id });", Some(serde_json::json!(["always"]))),
("var foo = () => ({ id }) as Foo;", Some(serde_json::json!(["always"]))),
("var foo = () => ({ id }) satisfies Foo;", Some(serde_json::json!(["always"]))),
("var foo = () => ({ id })!;", Some(serde_json::json!(["always"]))),
("var foo = () => (({ id }) as Foo);", Some(serde_json::json!(["always"]))),
("var foo = () => <Foo>JSON.parse(id);", Some(serde_json::json!(["always"]))),
(
"var foo = () => ({ id }) as Foo;",
Some(serde_json::json!(["as-needed", { "requireReturnForObjectLiteral": true }])),
),
];
let fix = vec![
(
"var foo = () => <Foo>{ id };",
"var foo = () => {return <Foo>{ id }};",
Some(serde_json::json!(["always"])),
),
(
"var foo = () => <Foo>({ id });",
"var foo = () => {return <Foo>({ id })};",
Some(serde_json::json!(["always"])),
),
(
"var foo = () => ({ id }) as Foo;",
"var foo = () => {return { id } as Foo};",
Some(serde_json::json!(["always"])),
),
(
"var foo = () => ({ id }) satisfies Foo;",
"var foo = () => {return { id } satisfies Foo};",
Some(serde_json::json!(["always"])),
),
(
"var foo = () => ({ id })!;",
"var foo = () => {return { id }!};",
Some(serde_json::json!(["always"])),
),
(
"var foo = () => (({ id }) as Foo);",
"var foo = () => {return (({ id }) as Foo)};",
Some(serde_json::json!(["always"])),
),
(
"var foo = () => <Foo>JSON.parse(id);",
"var foo = () => {return <Foo>JSON.parse(id)};",
Some(serde_json::json!(["always"])),
),
(
"var foo = () => ({ id }) as Foo;",
"var foo = () => {return { id } as Foo};",
Some(serde_json::json!(["as-needed", { "requireReturnForObjectLiteral": true }])),
),
];
Tester::new(ArrowBodyStyle::NAME, ArrowBodyStyle::PLUGIN, pass, fail)
.change_rule_path_extension("ts")
.with_snapshot_suffix("ts")
.expect_fix(fix)
.test_and_snapshot();
}
@@ -0,0 +1,59 @@
---
source: crates/oxc_linter/src/tester.rs
---
⚠ eslint(arrow-body-style): Expected block statement surrounding arrow body.
╭─[arrow_body_style.ts:1:17]
1 │ var foo = () => <Foo>{ id };
· ───────────
╰────
help: Surround the arrow body with braces and use a return statement.
⚠ eslint(arrow-body-style): Expected block statement surrounding arrow body.
╭─[arrow_body_style.ts:1:17]
1 │ var foo = () => <Foo>({ id });
· ─────────────
╰────
help: Surround the arrow body with braces and use a return statement.
⚠ eslint(arrow-body-style): Expected block statement surrounding arrow body.
╭─[arrow_body_style.ts:1:17]
1 │ var foo = () => ({ id }) as Foo;
· ───────────────
╰────
help: Surround the arrow body with braces and use a return statement.
⚠ eslint(arrow-body-style): Expected block statement surrounding arrow body.
╭─[arrow_body_style.ts:1:17]
1 │ var foo = () => ({ id }) satisfies Foo;
· ──────────────────────
╰────
help: Surround the arrow body with braces and use a return statement.
⚠ eslint(arrow-body-style): Expected block statement surrounding arrow body.
╭─[arrow_body_style.ts:1:17]
1 │ var foo = () => ({ id })!;
· ─────────
╰────
help: Surround the arrow body with braces and use a return statement.
⚠ eslint(arrow-body-style): Expected block statement surrounding arrow body.
╭─[arrow_body_style.ts:1:17]
1 │ var foo = () => (({ id }) as Foo);
· ─────────────────
╰────
help: Surround the arrow body with braces and use a return statement.
⚠ eslint(arrow-body-style): Expected block statement surrounding arrow body.
╭─[arrow_body_style.ts:1:17]
1 │ var foo = () => <Foo>JSON.parse(id);
· ───────────────────
╰────
help: Surround the arrow body with braces and use a return statement.
⚠ eslint(arrow-body-style): Expected block statement surrounding arrow body.
╭─[arrow_body_style.ts:1:17]
1 │ var foo = () => ({ id }) as Foo;
· ───────────────
╰────
help: Surround the arrow body with braces and use a return statement.