Skip to content

Instantly share code, notes, and snippets.

@Conaclos
Last active June 11, 2024 15:19
Show Gist options
  • Save Conaclos/f7d60dda517bb9e510246728e532545d to your computer and use it in GitHub Desktop.
Save Conaclos/f7d60dda517bb9e510246728e532545d to your computer and use it in GitHub Desktop.

Biome's Type Synthesiser

Multiple rules from TypeScript ESLint requires type information. Moreover, several linter rules could be enhanced by using type information.

TypeScript ESLint uses the TypeScript Compiler API to get types. This architecture has the advantage of using the TypeScript Compiler. However, it has several drawbacks:

  • TSC is slow, and using the Compiler API makes the slowness even more noticeable. In fact, it is so slow that TypeScript ESLint provides presets with and without the rules that require type information.
  • Biome and TSC use their own AST, which makes interoperability difficult.

This is why we think it is important to implement our own type synthesiser. If we have a fast type synthesiser, then we could enhance many lint rules with a marginal performance overhead.

Note that we are not trying to implement a full-fledged type system or a type checker like TypeScript. Many attempt, even the most promising failed.

We believe that the Biome type synthesiser doesn't need to be perfect or handle complex TypeScript types. Even a rudimentary type synthesiser could be valuable to many lint rules.

Background

Type synthesis

TSC synthesizes types with a bottom-up approach. It first synthesizes the leaves of an expression and build on this knowledge to synthesize the types of the expression.

Type synthesis may require the evaluation of constant expressions. For example, the type of 1 + 1 is 2.

Synthesizing a type may also require a type environment that retains known information about the symbols. For instance if the type environment knows that s is a string, then the type of the expression s + 0 is a string.

Referenced symbols can be imports. In this case, we need to access to the exported symbol of the imported file to retrieve its type.

Synthesized type should be distinguished values from the AST. However, we should have a way of requesting the node of a given type if it makes sense. For instance a class type can be associated to a class declaration.

Type inference

To start we could use a type inference that infers the type of variable based on the expression that is assigned to it. An exception could be made for callbacks, where the type of the callback is inferred according to where it is passed.

In the following example the type of x is inferred as string.

declare let y: string;
type x = y;

In the following example the type of the callback is inferred as (a: string) => string.

declare function map(arr: string[], f: (a: string) => string): string[];

map([""], (s) => s + " ");

Explicit Type Boundary

The adoption of TypeScript and Flow by big projects highlighted the need of more performant type checkers and tools that rely on them.

To increase their performance, type checkers and tools adopted a new architecture in the past months or even years in the case of Flow.

Flow adopted the Type First architecture. TypeScript 5.5 introduces an Isolated Declaration mode. Tools such as documentation generators for JSR requires avoiding Slow Types.

All these approaches are roughly equivalent. They reduce the work required to synthesize the type of expressions by requiring explicit type annotations on the exported items of a module. Thus, synthesizing an expression that relies on an imported item doesn't require synthesizing the type of the imported item.

We could even go beyond and also consider any control flow root (class, function, ...) as a type boundary. Basically we could assume explicit types for function, class, method declarations, but callbacks.

Note that our type system should not throw an error if a type boundary is not explicit. In this case we could say that we don't know the type of the item. The linter should still be able to operate with partial type info.

In the following example, the return type of f is not known. Thus, the type of x is unknown.

declare function f();

let x = f();

Implementation hints

We could first write a visitor that synthesizes the type of exported items of a module (file). This will mainly require to design data structures to represent the types. Type synthesis should be very simple because we rely on explicit type for exported items, but for constant expressions that will require some work. I am unsure at the moment if we need the semantic model for type synthesis of exported items. This will require some investigation.

Another visitor could synthesize all items of a file. To do that, we will need to resolve imports and extract the type synthesis of the first visitor for the imported modules. Import resolution could also allow implementing other rules such as the rules of the ESLint Import plugin.

Note that we could avoid synthesizing all items of a file, by just synthesizing the required items. For instance, when you synthesize the types of local variables in a function, you don't need to synthesize the types of local variables in other functions. Once synthesized, the result could be cached.

Rules requiring type information

The first subsection presents the rule we would like to implement in a first version of our type synthesiser. The second subsection presents rules that we are interested in implementing in the future.

MVP rules

Other rules

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment