This article is the English translation of the article "Biome v2の型推論を試して限界を知る" from Zenn. Thank the original author Uhyo for their write-up!
Hello everyone. The other day, Biome v2 was released and became a hot topic. One of the new features in Biome v2 is type inference.
Until now, type-aware linting for TypeScript code has been provided by TypeScript-ESLint. This has the disadvantage of being heavy because it uses the actual TypeScript compiler to obtain type information. While TypeScript itself is working on performance improvements through porting to Go and other measures, Biome has taken a different approach to this problem. That is, to perform type inference independently without relying on the official TypeScript compiler.
However, the TypeScript compiler is an extremely complex system, and it is almost impossible to completely reproduce its type inference results with a separate implementation. Therefore, Biome's type inference also cannot perfectly replicate the behavior of the TypeScript compiler. The Biome v2 release notes state that, using the noFloatingPromises
rule as an example, it can detect about 75% of the cases compared to using the actual TypeScript compiler.
Therefore, in this article, I will try out the type inference feature of Biome v2 and examine how well it can infer types compared to the real TypeScript compiler.
Note: This article examines the version at the time of writing (2.0.4). Biome has announced that it will improve type inference in future versions.
Simple Example
This time, I will investigate using the noFloatingPromises
rule. This rule detects code where a Promise is not await
ed. First, let's look at a simple example.
async function foo() {
return 3.14;
}
export async function main() {
foo();
}
In this code, the foo
function returns a Promise, but the main
function does not await
its result. Therefore, a lint error is expected.
Running biome lint
on this code produces the following error:
index.ts:6:3 lint/nursery/noFloatingPromises FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
ℹ A "floating" Promise was found, meaning it is not properly handled and could lead to ignored errors or unexpected behavior.
5 │ export async function main() {
> 6 │ foo();
│ ^^^^^^
7 │ }
ℹ This happens when a Promise is not awaited, lacks a `.catch` or `.then` rejection handler, or is not explicitly ignored using the `void` operator.
ℹ Unsafe fix: Add await operator.
6 │ ··await·foo();
│ ++++++
It detects that the return value of foo()
is a Promise and points out that await
is necessary. Although foo()
has no explicit type annotation, Biome infers the function's return value and determines that it is a Promise.
From here, we will gradually make the inference more difficult.
Using new Promise
In the example above, because foo
was defined as an async function
, it was very clear that the return value was a Promise. Next, let's try using new Promise
.
function foo() {
return new Promise<number>((resolve) => {
setTimeout(() => {
resolve(3.14);
});
});
}
export async function main() {
foo();
}
In this example, the return value of the foo
function is still a Promise. However, it uses new Promise
without the async
keyword.
Running biome lint
on this code did not detect any lint errors.
Unfortunately, in this case, it seems that Biome cannot recognize that the function's return value is a Promise. In this case, since there is no type annotation for the return value of foo
, to find the type of foo
, it is necessary to look for the return
statement inside foo
, confirm that its value is new Promise
, and then check that the type of this expression is a Promise. However, it seems that Biome cannot perform this kind of type inference.
Explicitly Adding a Type Annotation
Now, let's add a type annotation to the function's return value.
function foo(): Promise<number> {
return new Promise<number>((resolve) => {
setTimeout(() => {
resolve(3.14);
});
});
}
export async function main() {
foo();
}
With this, Biome was able to detect the lint error.
Although I haven't looked at the internal implementation of Biome's type inference, it seems that it does not examine the content of a function to determine its return type. To utilize Biome's type inference, it is important to explicitly state the return type of functions.
Trying Out Promise
Now, let's go back to using async
functions for clarity and try various things.
async function foo(): Promise<number> {
return 3.14;
}
export async function main() {
console.log(foo());
}
When running biome lint
on this example, surprisingly, no error was detected. I was personally a bit surprised by this.
However, the official documentation for the noFloatingPromises
rule states the following about its behavior:
This rule will report Promise-valued statements that are not treated in one of the following ways:
- Calling its
.then()
method with two arguments- Calling its
.catch()
method with one argumentawait
ing itreturn
ing itvoid
ing it
Since it refers to "Promise-valued statements", it can be interpreted that when a Promise is used as an expression in something, it is not a target, and the detection is limited to cases where a Promise is left alone, like foo();
.
Perhaps for this reason, even when a Promise is assigned to a variable as shown below, no lint error was detected.
async function foo(): Promise<number> {
return 3.14;
}
export async function main() {
const p = foo();
console.log(p);
}
Through an Object
I also tried the case of an object method instead of a function.
const obj = {
foo: async () => {
return 3.14;
},
}
export async function main() {
obj.foo();
}
In this case, a lint error was detected. It seems that through some inference, it understood that obj.foo
is a function that returns a Promise.
By the way, the lint error disappears as shown below (although an error for using any
appears instead).
const obj: { foo: any; } = {
foo: async () => {
return 3.14;
},
}
export async function main() {
obj.foo();
}
From this, it can be seen that it properly infers the "type of obj" and through that, infers the type of the return value of obj.foo
.
Trying Difficult Types
Now, let's be mean and try some of TypeScript's difficult types.
Generics
First, here is an example using generics.
function id<T>(x: T): T {
return x;
}
async function foo(): Promise<number> {
return 3.14;
}
export async function main() {
id(foo());
}
In this example, the return value of id(foo())
will be a Promise, but without performing generic type inference, it cannot understand that.
I was most surprised by the result here, but surprisingly, Biome detected a lint error for this example. This means it was able to recognize that the return value of id(foo())
is a Promise. The lint error is correctly detected for id
and not foo()
.
The inference rules for generics in TypeScript are very complex, so I don't think Biome can reproduce all of them, but it seems that in simple cases like this example, it can perform type inference using generics.
Lookup Types
A lookup type is a type with a syntax like T[K]
.
interface Obj {
noPromise: number;
yesPromise: Promise<number>;
}
const obj: Obj = {
noPromise: 3.14,
yesPromise: Promise.resolve(3.14),
}
function foo<K extends keyof Obj>(key: K): Obj[K] {
return obj[key];
}
export async function main() {
foo('noPromise');
foo('yesPromise');
}
In this case, the return value of foo('noPromise')
is of type number
, but the return value of foo('yesPromise')
is of type Promise<number>
. Can Biome figure this out?
The answer is, unfortunately, no lint error was detected for foo('yesPromise')
. It seems that it does not support calculations of lookup types.
By the way, even a simple case like the one below without generics was not possible.
interface Obj {
noPromise: number;
yesPromise: Promise<number>;
}
async function foo(): Obj["yesPromise"] {
return 3.14;
}
export async function main() {
foo();
}
Mapped Types and Conditional Types
Since lookup types were not possible, I thought the rest would be impossible too, but I tried them anyway. As expected, it was not possible.
// Mapped Type
type Raw = {
noPromise: number;
yesPromise: Promise<number>;
}
type Obj = {
[K in keyof Raw]: () => Raw[K];
}
const obj: Obj = {
noPromise: () => 42,
yesPromise: () => Promise.resolve(3.14),
}
export async function main() {
obj.yesPromise();
}
// Conditional Type
function foo<K extends string | number>(key: K): K extends string ? number : Promise<number> {
if (typeof key === 'string') {
return 42 as any;
} else {
return Promise.resolve(42) as any;
}
}
export async function main() {
foo(123);
}
Union and Intersection Types
Union types are important in practical TypeScript. Let's try a case involving them.
function foo(): number | Promise<number> {
if (Math.random() > 0.5) {
return 42;
}
return new Promise((resolve) => {
setTimeout(() => resolve(42), 1000);
});
}
export async function main() {
foo(123);
}
In this case, foo(123)
may or may not be a Promise.
For this example, Biome detected a lint error. This means it can recognize the possibility of a Promise as part of a union type.
Now, let's also try an intersection type.
function foo(): Promise<number> & { abort: () => void} {
return {} as any; // omitted
}
export async function main() {
foo();
}
For this one, no lint error was detected. It seems that it cannot recognize the presence of a Promise as part of an intersection type. However, whether this should be a detection target may be a matter of judgment. Also, since there is no essential difficulty in detection, it seems that it could be supported relatively easily.
Using a Type Alias
Will it be detected if a type alias is used for the Promise
type?
type PromiseNumber = Promise<number>;
function foo(): PromiseNumber {
return new Promise((resolve) => {
setTimeout(() => resolve(42), 1000);
});
}
export async function main() {
foo();
}
In this example, it was detected. It seems that while complex type calculations are not possible, type aliases can be recognized.
Summary
In this article, I investigated the performance of Biome v2's "type inference" at the time of writing.
As a result, it was found that the performance is quite modest compared to type inference by a type checker, such as the inability to perform type inference in the return new Promise
example, requiring a type annotation.
Still, there were behaviors that could be called inference, such as the internal handling of object and function types, the ability to handle generics, and the recognition that the return value of an async
function is a Promise
type even without a type annotation. It feels like it was created by properly facing the concept of "type" rather than something else disguised as type inference.
However, even when type annotations are explicitly stated, there seem to be limits to the calculation of types. It seemed impossible to calculate things like lookup types and conditional types.
It is surprising that even with such modest performance, it is sufficient to detect 75% of cases, as stated in the official blog.
I had a concern about Biome and other TypeScript-independent "type inference" features. That is, that a coding rule that does not fully utilize TypeScript's type system and only uses descriptions that Biome and others can understand might become widespread. If that happens, TypeScript's type system will essentially be forked.
Although Biome is still in the process of evolution, what do you think after seeing the results of this verification? Did it make you want to change your TypeScript practices to match Biome?
As for me, I would like to continue to watch how things will develop.
Top comments (0)