Skip to content

Commit aa649b5

Browse files
authored
fix(lint): noMisleadingReturnType reads type assertions as their asserted type (#10719)
1 parent a66a42c commit aa649b5

14 files changed

Lines changed: 276 additions & 13 deletions

File tree

.changeset/curly-pillows-attack.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
"@biomejs/biome": patch
3+
---
4+
5+
Fixed [`noMisleadingReturnType`](https://biomejs.dev/linter/rules/no-misleading-return-type/) false positive on returns that use a widening type assertion: `"a" as string` is no longer reported as misleading. The rule now also reports a literal-pinning assertion such as `false as false`, matching the existing `as const` behavior.
6+
7+
```ts
8+
// No longer flagged (returns are `string`):
9+
function getValue(b: boolean): string {
10+
if (b) return "a" as string;
11+
return "b" as string;
12+
}
13+
14+
// Now also reported, like `as const` (returns `false`):
15+
function isReady(): boolean {
16+
return false as false;
17+
}
18+
```
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"@biomejs/biome": patch
3+
---
4+
5+
Fixed [`useAwaitThenable`](https://biomejs.dev/linter/rules/use-await-thenable/) false positive when awaiting a custom thenable that is not the global `Promise`. A value with a callable `then` member is now recognized as awaitable.
6+
7+
```ts
8+
interface Thenable<T> { then(onfulfilled: (value: T) => void): void; }
9+
declare const t: Thenable<number>;
10+
async function f() {
11+
await t;
12+
}
13+
```

crates/biome_js_analyze/src/lint/nursery/no_misleading_return_type.rs

Lines changed: 37 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,9 @@ use biome_js_syntax::{
1616
TsReferenceType, TsTypeAliasDeclaration, TsTypeAssertionExpression,
1717
};
1818
use biome_js_semantic::ScopeId;
19-
use biome_js_type_info::{Class, Literal, Type, TypeData, TypeMemberKind, TypeReferenceQualifier};
19+
use biome_js_type_info::{
20+
Class, Literal, Type, TypeData, TypeMemberKind, TypeReferenceQualifier,
21+
};
2022
use biome_rowan::{AstNode, Text, TextRange, declare_node_union};
2123
use biome_rule_options::no_misleading_return_type::NoMisleadingReturnTypeOptions;
2224
use smallvec::{SmallVec, smallvec};
@@ -892,12 +894,18 @@ struct ReturnInfo {
892894
/// TypeScript `object` keyword, such as members, tuples, functions, or
893895
/// class instances.
894896
has_narrower_than_object: bool,
897+
/// Whether a return pins its type with a non-`const` `as`/`<>` assertion,
898+
/// so its literal does not widen.
899+
has_pinning_assertion: bool,
895900
}
896901

897902
impl ReturnInfo {
898-
/// Single-return whose inferred type is a primitive literal and no `as const`.
903+
/// Single-return whose inferred type is a primitive literal and no `as const` or pinning assertion.
899904
fn is_single_primitive_literal(&self) -> bool {
900-
self.types.len() == 1 && !self.has_any_const && is_literal_of_primitive(&self.types[0])
905+
self.types.len() == 1
906+
&& !self.has_any_const
907+
&& !self.has_pinning_assertion
908+
&& is_literal_of_primitive(&self.types[0])
901909
}
902910

903911
/// Every return carries an object-wide cast target (and no `as const`).
@@ -934,6 +942,7 @@ fn collect_return_info(
934942
info.has_narrower_than_object = true;
935943
}
936944
}
945+
info.has_pinning_assertion |= is_pinned_by_assertion(expr);
937946
info.types.push(infer_expression_type(ctx, expr));
938947
}
939948
}
@@ -967,6 +976,7 @@ fn collect_block_returns(
967976
info.has_narrower_than_object = true;
968977
}
969978
}
979+
info.has_pinning_assertion |= is_pinned_by_assertion(&expr);
970980
info.types.push(infer_expression_type(ctx, &expr));
971981
}
972982
}
@@ -1524,11 +1534,17 @@ fn unwrap_type_wrappers(expr: &AnyJsExpression) -> AnyJsExpression {
15241534
let mut current = expr.clone();
15251535
loop {
15261536
if let Some(cast) = AnyTsCastExpression::cast(current.syntax().clone()) {
1527-
let Some(inner) = cast.inner_expression() else {
1528-
return current;
1529-
};
1530-
current = inner;
1531-
continue;
1537+
// Only `satisfies` and `as const` are transparent; keep a widening
1538+
// `as T` so its target type is read, not the inner literal.
1539+
let is_transparent = matches!(current, AnyJsExpression::TsSatisfiesExpression(_))
1540+
|| is_const_reference_type(&cast.cast_type());
1541+
if is_transparent {
1542+
let Some(inner) = cast.inner_expression() else {
1543+
return current;
1544+
};
1545+
current = inner;
1546+
continue;
1547+
}
15321548
}
15331549
match &current {
15341550
AnyJsExpression::JsParenthesizedExpression(e) => match e.expression() {
@@ -1540,6 +1556,15 @@ fn unwrap_type_wrappers(expr: &AnyJsExpression) -> AnyJsExpression {
15401556
}
15411557
}
15421558

1559+
/// Whether the return keeps a non-`const` `as`/`<>` cast after `satisfies` and
1560+
/// `as const` are unwrapped.
1561+
fn is_pinned_by_assertion(expr: &AnyJsExpression) -> bool {
1562+
matches!(
1563+
unwrap_type_wrappers(expr),
1564+
AnyJsExpression::TsAsExpression(_) | AnyJsExpression::TsTypeAssertionExpression(_)
1565+
)
1566+
}
1567+
15431568
fn has_const_assertion(expr: &AnyJsExpression) -> bool {
15441569
let mut current = expr.clone();
15451570
loop {
@@ -1974,7 +1999,9 @@ fn is_union_wider(annotated: &Type, inferred: &Type) -> bool {
19741999
fn types_match(a: &Type, b: &Type) -> bool {
19752000
let mut a = a.clone();
19762001
let mut b = b.clone();
1977-
loop {
2002+
// Resolving `InstanceOf` bases loops forever on a circular alias (`type R = R`),
2003+
// so bound the walk like the rule's other type traversals.
2004+
for _ in 0..MAX_TYPE_TRAVERSAL_ITERATIONS {
19782005
match (&*a, &*b) {
19792006
(TypeData::String, TypeData::String)
19802007
| (TypeData::Number, TypeData::Number)
@@ -2028,4 +2055,5 @@ fn types_match(a: &Type, b: &Type) -> bool {
20282055
_ => return false,
20292056
}
20302057
}
2058+
false
20312059
}

crates/biome_js_analyze/src/lint/nursery/use_await_thenable.rs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,8 +64,9 @@ impl Rule for UseAwaitThenable {
6464
// Uncomment the following line for debugging convenience:
6565
//let printed = format!("type of {expression:?} = {ty:?}");
6666

67-
let is_maybe_promise =
68-
ty.is_promise_instance() || ty.has_variant(|ty| ty.is_promise_instance());
67+
let is_maybe_promise = ty.is_promise_instance()
68+
|| ty.is_thenable()
69+
|| ty.has_variant(|ty| ty.is_promise_instance() || ty.is_thenable());
6970
(ty.is_inferred() && !is_maybe_promise).then_some(())
7071
}
7172

crates/biome_js_analyze/tests/specs/nursery/noMisleadingReturnType/invalid.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,3 +204,8 @@ async function asyncUnionBothReturns(b: boolean): Promise<string | null> {
204204
function partialAbsorbUnion(b: boolean): "a" | "b" | string | null { if (b) return "a"; return null; }
205205

206206
function crossPrimitiveUnion(): "a" | string | 1 { return "a"; }
207+
208+
function literalAssertionNarrows(b: boolean): string { if (b) return "a" as "a"; return "b" as "b"; }
209+
function satisfiesDoesNotWiden(b: boolean): string { if (b) return "a" satisfies string; return "b" satisfies string; }
210+
function doubleCastNarrows(b: boolean): string { if (b) return "a" as unknown as "a"; return "b" as unknown as "b"; }
211+
function singleAssertedBooleanLiteral(): boolean { return false as false; }

crates/biome_js_analyze/tests/specs/nursery/noMisleadingReturnType/invalid.ts.snap

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,11 @@ function partialAbsorbUnion(b: boolean): "a" | "b" | string | null { if (b) retu
211211
212212
function crossPrimitiveUnion(): "a" | string | 1 { return "a"; }
213213
214+
function literalAssertionNarrows(b: boolean): string { if (b) return "a" as "a"; return "b" as "b"; }
215+
function satisfiesDoesNotWiden(b: boolean): string { if (b) return "a" satisfies string; return "b" satisfies string; }
216+
function doubleCastNarrows(b: boolean): string { if (b) return "a" as unknown as "a"; return "b" as unknown as "b"; }
217+
function singleAssertedBooleanLiteral(): boolean { return false as false; }
218+
214219
```
215220
216221
# Diagnostics
@@ -2394,6 +2399,7 @@ invalid.ts:206:31 lint/nursery/noMisleadingReturnType ━━━━━━━━
23942399
> 206function crossPrimitiveUnion(): "a" | string | 1 { return "a"; }
23952400
^^^^^^^^^^^^^^^^^^
23962401
207
2402+
208function literalAssertionNarrows(b: boolean): string { if (b) return "a" as "a"; return "b" as "b"; }
23972403
23982404
i A wider return type hides the precise types that callers could rely on.
23992405
@@ -2405,3 +2411,93 @@ invalid.ts:206:31 lint/nursery/noMisleadingReturnType ━━━━━━━━
24052411
24062412
24072413
```
2414+
2415+
```
2416+
invalid.ts:208:45 lint/nursery/noMisleadingReturnType ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
2417+
2418+
i The return type annotation is wider than what the function actually returns.
2419+
2420+
206 │ function crossPrimitiveUnion(): "a" | string | 1 { return "a"; }
2421+
207
2422+
> 208function literalAssertionNarrows(b: boolean): string { if (b) return "a" as "a"; return "b" as "b"; }
2423+
^^^^^^^^
2424+
209function satisfiesDoesNotWiden(b: boolean): string { if (b) return "a" satisfies string; return "b" satisfies string; }
2425+
210function doubleCastNarrows(b: boolean): string { if (b) return "a" as unknown as "a"; return "b" as unknown as "b"; }
2426+
2427+
i A wider return type hides the precise types that callers could rely on.
2428+
2429+
i Consider using "a" | "b" as the return type.
2430+
2431+
i This rule is still being actively worked on, so it may be missing features or have rough edges. Visit https://github.com/biomejs/biome/issues/9810 for more information or to report possible bugs.
2432+
2433+
i This rule belongs to the nursery group, which means it is not yet stable and may change in the future. Visit https://biomejs.dev/linter/#nursery for more information.
2434+
2435+
2436+
```
2437+
2438+
```
2439+
invalid.ts:209:43 lint/nursery/noMisleadingReturnType ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
2440+
2441+
i The return type annotation is wider than what the function actually returns.
2442+
2443+
208 │ function literalAssertionNarrows(b: boolean): string { if (b) return "a" as "a"; return "b" as "b"; }
2444+
> 209function satisfiesDoesNotWiden(b: boolean): string { if (b) return "a" satisfies string; return "b" satisfies string; }
2445+
^^^^^^^^
2446+
210function doubleCastNarrows(b: boolean): string { if (b) return "a" as unknown as "a"; return "b" as unknown as "b"; }
2447+
211function singleAssertedBooleanLiteral(): boolean { return false as false; }
2448+
2449+
i A wider return type hides the precise types that callers could rely on.
2450+
2451+
i Consider using "a" | "b" as the return type.
2452+
2453+
i This rule is still being actively worked on, so it may be missing features or have rough edges. Visit https://github.com/biomejs/biome/issues/9810 for more information or to report possible bugs.
2454+
2455+
i This rule belongs to the nursery group, which means it is not yet stable and may change in the future. Visit https://biomejs.dev/linter/#nursery for more information.
2456+
2457+
2458+
```
2459+
2460+
```
2461+
invalid.ts:210:39 lint/nursery/noMisleadingReturnType ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
2462+
2463+
i The return type annotation is wider than what the function actually returns.
2464+
2465+
208 │ function literalAssertionNarrows(b: boolean): string { if (b) return "a" as "a"; return "b" as "b"; }
2466+
209function satisfiesDoesNotWiden(b: boolean): string { if (b) return "a" satisfies string; return "b" satisfies string; }
2467+
> 210function doubleCastNarrows(b: boolean): string { if (b) return "a" as unknown as "a"; return "b" as unknown as "b"; }
2468+
^^^^^^^^
2469+
211function singleAssertedBooleanLiteral(): boolean { return false as false; }
2470+
212
2471+
2472+
i A wider return type hides the precise types that callers could rely on.
2473+
2474+
i Consider using "a" | "b" as the return type.
2475+
2476+
i This rule is still being actively worked on, so it may be missing features or have rough edges. Visit https://github.com/biomejs/biome/issues/9810 for more information or to report possible bugs.
2477+
2478+
i This rule belongs to the nursery group, which means it is not yet stable and may change in the future. Visit https://biomejs.dev/linter/#nursery for more information.
2479+
2480+
2481+
```
2482+
2483+
```
2484+
invalid.ts:211:40 lint/nursery/noMisleadingReturnType ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
2485+
2486+
i The return type annotation is wider than what the function actually returns.
2487+
2488+
209 │ function satisfiesDoesNotWiden(b: boolean): string { if (b) return "a" satisfies string; return "b" satisfies string; }
2489+
210function doubleCastNarrows(b: boolean): string { if (b) return "a" as unknown as "a"; return "b" as unknown as "b"; }
2490+
> 211function singleAssertedBooleanLiteral(): boolean { return false as false; }
2491+
^^^^^^^^^
2492+
212
2493+
2494+
i A wider return type hides the precise types that callers could rely on.
2495+
2496+
i Consider using false as the return type.
2497+
2498+
i This rule is still being actively worked on, so it may be missing features or have rough edges. Visit https://github.com/biomejs/biome/issues/9810 for more information or to report possible bugs.
2499+
2500+
i This rule belongs to the nursery group, which means it is not yet stable and may change in the future. Visit https://biomejs.dev/linter/#nursery for more information.
2501+
2502+
2503+
```

crates/biome_js_analyze/tests/specs/nursery/noMisleadingReturnType/valid.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -309,3 +309,9 @@ function collapseNumber(): 1 | 2 | number { return 1; }
309309
function collapseBigint(): 1n | bigint { return 1n; }
310310

311311
function collapseMismatchLiteral(): "a" | string { return "b"; }
312+
313+
function widenStringAssertion(b: boolean): string { if (b) return "a" as string; return "b" as string; }
314+
function widenSatisfiesAssertion(b: boolean): string { if (b) return ("a" as string) satisfies string; return "b" as string; }
315+
type WidenedAlias = string;
316+
function widenAliasAssertion(b: boolean): WidenedAlias { if (b) return "a" as WidenedAlias; return "b" as WidenedAlias; }
317+
function assertedBooleanLiteralsCollapse(b: boolean): boolean { if (b) return true as true; return false as false; }

crates/biome_js_analyze/tests/specs/nursery/noMisleadingReturnType/valid.ts.snap

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -316,4 +316,10 @@ function collapseBigint(): 1n | bigint { return 1n; }
316316
317317
function collapseMismatchLiteral(): "a" | string { return "b"; }
318318
319+
function widenStringAssertion(b: boolean): string { if (b) return "a" as string; return "b" as string; }
320+
function widenSatisfiesAssertion(b: boolean): string { if (b) return ("a" as string) satisfies string; return "b" as string; }
321+
type WidenedAlias = string;
322+
function widenAliasAssertion(b: boolean): WidenedAlias { if (b) return "a" as WidenedAlias; return "b" as WidenedAlias; }
323+
function assertedBooleanLiteralsCollapse(b: boolean): boolean { if (b) return true as true; return false as false; }
324+
319325
```
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
/* should generate diagnostics */
2+
3+
// A non-callable `then` member does not make a thenable, so `await` is still reported.
4+
interface NotThenable { then: number; }
5+
declare const value: unknown;
6+
async function awaitNonCallableThen(): Promise<void> {
7+
await (value as NotThenable);
8+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
---
2+
source: crates/biome_js_analyze/tests/spec_tests.rs
3+
expression: thenableInvalid.ts
4+
---
5+
# Input
6+
```ts
7+
/* should generate diagnostics */
8+
9+
// A non-callable `then` member does not make a thenable, so `await` is still reported.
10+
interface NotThenable { then: number; }
11+
declare const value: unknown;
12+
async function awaitNonCallableThen(): Promise<void> {
13+
await (value as NotThenable);
14+
}
15+
16+
```
17+
18+
# Diagnostics
19+
```
20+
thenableInvalid.ts:7:5 lint/nursery/useAwaitThenable ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
21+
22+
i `await` used on a non-Promise value.
23+
24+
5 │ declare const value: unknown;
25+
6 │ async function awaitNonCallableThen(): Promise<void> {
26+
> 7await (value as NotThenable);
27+
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
28+
8}
29+
9 │
30+
31+
i This may happen if you accidentally used `await` on a synchronous value.
32+
33+
i Please ensure the value is not a custom "thenable" implementation before removing the `await`: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise#thenables
34+
35+
i This rule belongs to the nursery group, which means it is not yet stable and may change in the future. Visit https://biomejs.dev/linter/#nursery for more information.
36+
37+
38+
```

0 commit comments

Comments
 (0)