Skip to content

Commit 23ba25f

Browse files
siketyanclaude
andauthored
fix(type_inference): skip non-generic references in substitutions (#11814)
Co-authored-by: Claude <noreply@anthropic.com>
1 parent e25d363 commit 23ba25f

6 files changed

Lines changed: 149 additions & 0 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"@biomejs/biome": patch
3+
---
4+
5+
Fixed type inference through generic type aliases that instantiate another generic type with a nested generic argument, such as `type Nested<T> = Box<Wrapper<T>>`. Type-aware rules now resolve members of such types:
6+
7+
```ts
8+
declare const nested: Nested<number>;
9+
// noUnnecessaryConditions now reports that `??` is unnecessary.
10+
const inner = nested.value.inner ?? 1;
11+
```

‎.changeset/quiet-alias-lookup.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@biomejs/biome": patch
3+
---
4+
5+
Fixed [#11810](https://github.com/biomejs/biome/issues/11810) and [#11813](https://github.com/biomejs/biome/issues/11813): type-aware rules such as [`noUnnecessaryConditions`](https://biomejs.dev/linter/rules/no-unnecessary-conditions/) and [`noFloatingPromises`](https://biomejs.dev/linter/rules/no-floating-promises/) no longer take several seconds when a member is accessed on a recursive generic type alias, such as react-hook-form's `FieldPathValue` or zustand's `Mutate`.
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
interface Box<U> {
2+
value: U;
3+
}
4+
interface Wrapper<X> {
5+
inner: X;
6+
}
7+
8+
type Nested<T> = Box<Wrapper<T>>;
9+
declare const nested: Nested<number>;
10+
export const inner = nested.value.inner ?? 1;
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
---
2+
source: crates/biome_js_analyze/tests/spec_tests.rs
3+
expression: nestedGenericAliasInvalid.ts
4+
---
5+
# Input
6+
```ts
7+
interface Box<U> {
8+
value: U;
9+
}
10+
interface Wrapper<X> {
11+
inner: X;
12+
}
13+
14+
type Nested<T> = Box<Wrapper<T>>;
15+
declare const nested: Nested<number>;
16+
export const inner = nested.value.inner ?? 1;
17+
18+
```
19+
20+
# Diagnostics
21+
```
22+
nestedGenericAliasInvalid.ts:10:22 lint/suspicious/noUnnecessaryConditions ━━━━━━━━━━━━━━━━━━━━━━━━━
23+
24+
! Unnecessary nullish coalescing.
25+
26+
8 │ type Nested<T> = Box<Wrapper<T>>;
27+
9 │ declare const nested: Nested<number>;
28+
> 10 │ export const inner = nested.value.inner ?? 1;
29+
│ ^^^^^^^^^^^^^^^^^^
30+
11 │
31+
32+
i Drop ?? and the fallback expression.
33+
34+
8 │ type Nested<T> = Box<Wrapper<T>>;
35+
9 │ declare const nested: Nested<number>;
36+
> 10 │ export const inner = nested.value.inner ?? 1;
37+
│ ^^
38+
11 │
39+
40+
i The left-hand side is guaranteed to be non-nullish, so the fallback is unreachable.
41+
42+
43+
```

‎crates/biome_module_graph/benches/type_inference.rs‎

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,62 @@ fn bench_cyclic_declaration_promise_lookup(bencher: Bencher) {
234234
});
235235
}
236236

237+
const RECURSIVE_ALIAS_CASES: &[(&str, &str, &str)] = &[
238+
// A recursive alias whose branches collapse to a self-instantiation with
239+
// `infer` parameters, as in react-hook-form's `FieldPathValue`.
240+
// See https://github.com/biomejs/biome/issues/11810.
241+
(
242+
"path_value",
243+
r#"
244+
type PathValue<T, P extends string> = P extends `${infer K}.${infer R}`
245+
? K extends keyof T
246+
? PathValue<T[K], R>
247+
: T extends ReadonlyArray<infer V>
248+
? PathValue<V, R>
249+
: never
250+
: P extends keyof T
251+
? T[P]
252+
: never;
253+
declare function useController<T, N extends string>(props: {
254+
name: N;
255+
}): { field: { value: PathValue<T, N> } };
256+
const { field } = useController<{ items: string[] }, "items">({ name: "items" });
257+
field.value.length;
258+
"#,
259+
"field.value.length",
260+
),
261+
// A recursive alias that re-instantiates itself with its own arguments,
262+
// as in zustand's `Mutate<StoreApi<T>, []>`.
263+
// See https://github.com/biomejs/biome/issues/11813.
264+
(
265+
"self_instantiation",
266+
r#"
267+
interface Api { get(): number }
268+
type Rec<T, L> = L extends [] ? T : Rec<T, []>;
269+
declare const api: Rec<Api, []>;
270+
api.get();
271+
"#,
272+
"api.get()",
273+
),
274+
];
275+
276+
fn recursive_alias_cases() -> impl Iterator<Item = &'static str> {
277+
RECURSIVE_ALIAS_CASES.iter().map(|(name, _, _)| *name)
278+
}
279+
280+
#[divan::bench(name = "bench_recursive_alias_member_lookup", args = recursive_alias_cases())]
281+
fn bench_recursive_alias_member_lookup(bencher: Bencher, name: &str) {
282+
bencher
283+
.with_inputs(|| recursive_alias_member_lookup_input(name))
284+
.bench_local_values(|(db, module, range)| {
285+
let input = ExpressionTypeInput::new(&db, module, range);
286+
let ty = infer_expression_type(&db, input).expect("member access must have a type");
287+
let input = NormalizeTypeInput::new(&db, module, ty);
288+
divan::black_box(normalize_type(&db, input));
289+
db
290+
});
291+
}
292+
237293
#[divan::bench(name = "bench_distinct_local_type_lookup_queries")]
238294
fn bench_distinct_local_type_lookup_queries(bencher: Bencher) {
239295
bencher
@@ -655,6 +711,27 @@ fn cyclic_declaration_promise_lookup_input() -> (WorkspaceDb, ModuleInfo, TextRa
655711
(db, consumer, range)
656712
}
657713

714+
fn recursive_alias_member_lookup_input(name: &str) -> (WorkspaceDb, ModuleInfo, TextRange) {
715+
let (_, source, expression) = RECURSIVE_ALIAS_CASES
716+
.iter()
717+
.find(|(case_name, _, _)| *case_name == name)
718+
.expect("cannot find test case");
719+
720+
let (db, module) = build_source_db("recursive_alias.ts", source);
721+
let ModuleInfoKind::Js(info) = module.kind(&db) else {
722+
panic!("module must contain JavaScript information");
723+
};
724+
let range = info
725+
.raw_expressions
726+
.keys()
727+
.copied()
728+
.find(|range| {
729+
source.get(usize::from(range.start())..usize::from(range.end())) == Some(*expression)
730+
})
731+
.expect("member access must be collected");
732+
(db, module, range)
733+
}
734+
658735
fn expression_query_inputs(count: usize) -> (WorkspaceDb, ModuleInfo, Vec<TextRange>) {
659736
let mut source = String::new();
660737
for index in 0..count {

‎crates/biome_module_graph/src/db/type_inference/lookup.rs‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -487,6 +487,9 @@ pub(in crate::db) fn substitutions_for_instance<'db>(
487487
let mut substitutions = inherited.to_vec();
488488
for (declared, replacement) in declared_parameters.iter().zip(type_parameters) {
489489
let declared = apply_substitutions(db, *declared, inherited);
490+
if !declared.is_generic_reference(db) {
491+
continue;
492+
}
490493
let replacement = apply_substitutions(db, *replacement, inherited);
491494
let declared_instance = InferredTypeData::instance_of(db, declared, Box::default());
492495
if declared_instance != declared {

0 commit comments

Comments
 (0)