One of 2023’s biggest trends for web tooling was rewriting existing tooling in Rust. Rust is a wonderful programming language that allows for shockingly fast binaries which still interop well with other web tools courtesy of WebAssembly. The speedups seen in tools such as swc and Turbopack are very exciting for fast development experiences.
Prettier even awarded a $20,000 bounty to Biome for achieving >95% compatibility with the formatting parts of Prettier!
But! It’s a misconception to think that Rust-based linters are a complete and total replacement for ESLint today. There are always tradeoffs when switching tooling. In this case, the positive performance advantages come with a negative feature gap: type-checked linting.
Recap: Type-Checked Linting
Traditionally, lint rules in linters such as ESLint only have visibility into one source code file at a time. This makes them fast and theoretically cacheable and parallelizable.
typescript-eslint introduces the concept of linting with type information. By calling to TypeScript’s type checking APIs, lint rules can make much more informed decisions on code based on types informed by potentially any other file in your project.
Type-checked lint rules can be significantly more capable than traditional lint rules. For example:
awaits unnecessarily used on non-Thenable (non-Promise) values
@typescript-eslint/no-floating-promiseslets you know if you create a Promise and forget to handle it safely
@typescript-eslint/no-for-in-array: flags accidental unsafe
for...initerations over arrays (instead of
Each of those rules is only practically useful when they can use type information to determine when to report issues. Without type information, they wouldn’t be able to understand the type of any value imported from another module.
💡 Lint rules are explained more in typescript-eslint’s “ASTs and typescript-eslint”.
Type-Checked Linting Performance
The main downside of type-checked linting is performance.
Typed lint rules necessitate calling to an API such as TypeScript’s for type information, which generally need to read all files to see which ones impact types of any other file.
That means linting performance will often be worse than that of running
tsc on your entire project.
We’re actively working on this in typescript-eslint.
Our Performance Troubleshooting docs have some suggestions, and we’re very hopeful that our
EXPERIMENTAL_useProjectService option will land as stable in 2024.
TypeScript itself has also also been investing in better performance. Project references can significantly help with larger projects. TypeScript’s upcoming isolated declarations mode looks like it can also significantly improve performance on larger projects.
But even if all those speedups work perfectly then type-checked linting will by design still be orders of magnitude slower than traditional linting. The act of inferring types from many files in a project is inherently much slower than a traditional lint rule looking at a single file at a time.
Our experience has been that the the majority of codebases benefit from the slower, more in-depth type checking of typed lint rules. Most of the time, when we’ve seen projects with slow type-checked linting, the root cause was either a misconfiguration of typescript-eslint (see our Performance Troubleshooting) or slow TypeScript types.
Rust-Based Linters and Type Checking
No Rust-based linter has integrated with TypeScript’s type checking APIs yet. That means no Rust-based linter is a full replacement for ESLint + typescript-eslint.
I’m not saying you shouldn’t use a Rust-based linter: if you don’t want any of the type-checked lint rules, then sure, switching over is great. But I strongly recommend you look through at least the recommended type-checked rules in typescript-eslint to understand what you’re missing first.
You could even run both tools in tandem: a native-speed linter first for quick feedback, then typescript-eslint for just the rules with type information. This idea is supported by multiple native-speed linter maintainers:
- Dual-linting has been mentioned as a reasonable strategy by Biome’s Emanuele.
- Oxc’s announcement post phrases oxlint as an enhancement for use when ESLint is too slow, not a full replacement.
That desire to complement rather than replace is partially born out of a major structural difference in how the two kinds of linters work. Native speed linters haven’t worked towards implementing type checking in their lint rules. Let’s dig into that curious feature gap.
Integrating Type-Checked Linting and Rust-Based Linters
In order to work with type checking, a Rust linter would have to either:
- Reimplement TypeScript’s APIs in a native speed language
- Speed up TypeScript’s APIs to native speed
Let’s go into the different options for integrating Rust-based linters with TypeScript’s type checking.
This performance hit option would likely slow the native-speed linters down to the point where they have little to no noticeable performance advantage compared to ESLint. 👎
That being said, if any native speed linter wants to do this, we in typescript-eslint would love to help.
@typescript-eslint/typescript-estree Node.js APIs are open source and as well documented as we’ve thought to write.
We’d be happy to work with anybody who wants to use them, including spinning out standalone packages if that’d be useful.
Option: Reimplementing TypeScript at Native Speed
Reimplementing TypeScript at native speed is a tantalizing prospect for TypeScript users in general, not just linters. I know of three significant attempts:
- Ezno: A new TypeScript-like language with added features (dependent typing! ❤️🔥)
stc: A drop-in replacement for TypeScript’s type checking, written in Rust
- TypeRunner: An older attempt in C++, no longer actively developed
All three projects are very early stage and not likely to become production ready for a very long time.
Keep in mind that re-implementing TypeScript in a new language is a herculean task. TypeScript’s type inference has to deal with bizarrely complex edge cases around generic types, covariance, contravariance, and other terms most of us shudder to hear.
💡 See Ryan Cavanaugh’s Let’s Make a Generic Inference Algorithm TypeScript Congress 2023 talk for an example of the difficult type system cases TypeScript has to deal with.
I sometimes wonder whether a project could reduce the scope of this option by implementing just the type inference parts of TypeScript. Linters would be fine with a port that skips implementing any source code transpilation, type checking assignability errors, or other parts of TypeScript not used by the programmatic type checking API. For example, Oxc’s Boshen prototyped a TypeScript type inference port that made it to a few thousand lines of Rust.
On the other hand, TypeScript is also a funded development team with contributions from its own programming language specialists and community contributors.
Keeping up with even just the type inference changes in new versions is a never-ending task for any re-implementation.
As impressive as Ezno and
stc are, their long-term feasibility as standalone projects is precarious.
💡 See Matt Pocock’s Rewriting TypeScript in Rust? You’d have to be… for more discussion with
stc’s Donny. At time of writing, stc’s Donny is not actively working on stc.
Option: Boosting TypeScript’s APIs to Native Speed
I think a more viable long-term option would be to find a way to get TypeScript’s type checker to run at native speed. There are a couple possibilities:
- Writing a tool that transpiles its source to a faster language such as Go or Rust
- Pre-compiling and optimizing TypeScript like a binary
Both of those options are difficult and will take some time to land.
Transpiling the checker to Go was the original aim of what became
stc before the the project switched to a Rust re-implementation.
Node.js user land snapshots are mentioned in TypeScript’s Ideas for faster cold compiler start-up issue in the context of startup times. For the context of typed linting, aggressively optimizing code ahead of time might be marginally useful too. The Hermes engine has some interesting build-time precompilation too.
AssemblyScript and Static TypeScript are two more interesting explorations in making TypeScript fast. Both operate with a subset or modified version of the TypeScript language oriented to low-level performance.
Regardless of the approach used to speed up TypeScript, the implementation of TypeScript itself impedes the approach because TypeScript isn’t architected for native code. Its code assumes a runtime with built-in garbage collection, mutable objects, and other performance paper cuts. I suspect the biggest gains might be from rearchitecting TypeScript to be more performance-friendly:
- The aforementioned isolated declaration mode
- Restricting global type augmentations to be more parallelization-friendly
- Changing the way its checker runs to avoid those paper cuts
Any major structural change to TypeScript would be very difficult to implement and cause breaking changes in TypeScript’s APIs. Besides isolated declaration mode likely shipping in 2024, nothing is likely to happen any time soon.
Another high-level strategy could be to integrate linting into the existing TypeScript language server infrastructure. The TypeScript Language Service Plugin allows for adding tools to be run as part of the TypeScript editing experience.
I’ve seen two attempts at this:
- Quramy/typescript-eslint-language-service: A general TypeScript language service plugin for ESLint
- johnsoncodehk/typescript-linter: A re-implementation of a linter built on the TypeScript language server
Both seem promising. I think running ESLint as a TypeScript language service plugin is more feasible in the short-term for the sake of compatibility with existing rules. Either way, figuring out how to make the TypeScript experience great without behind other languages -especially given ESLint’s intent to embrace other web languages- will be a key challenge.
We in typescript-eslint haven’t had time to investigate language server integrations deeply.
I’m hopeful our
EXPERIMENTAL_useProjectService option will make it easier to run more closely to the TypeScript language server.
But this is a long-term play that will take years to stabilize.
I’m not going to show you a performance comparison of Rust-based linters vs. ESLint vs. ESLint with typescript-eslint. The comparison would be misleading: until Rust-based linters achieve feature parity with typed linting rules, they benefit in comparisons from having to run significantly less work. And given how many different avenues we have yet to flesh out in running type linting rules with a native speed linter, we have near-zero idea what that performance would look like.
💡 When evaluating performance comparisons, always make sure the comparisons are on comparable behavior. Don’t trust any metric you don’t understand the contents of.
deno lint, Oxc, and RSLint are fantastically fast projects.
But that speed comes with a serious feature gap compared to ESLint + typescript-eslint’s type-checked lint rules.
You should understand those tradeoffs when making a decision on which to use.
Both Biome and oxlint have indicated some level of recommendation towards running a faster native speed linter before, rather than instead, of the type-informed typescript-eslint.
Rust-based linters may eventually be able to get the benefits of type-checked linting at native speed code. But it’s going to be a very long time until that’s feasible.
This post had a lot of help from quite a few developers working on the tools it mentions!
- Biome: Emanuele Stoppa and Victorien Elvinger emailed detailed thoughts on the performance landscape -including isolated declaration emit in TypeScript-, positioning of the different linters, and general thoughts on this post.
- Deno: David Sherret shared enthusiasm and an interesting discussion around integrating TypeScript and Rust packages.
- Ezno: Ben left an informative pull request review around phrasing, levels of detail, and some discussions around backing up performance claims.
- Oxc: Boshen left a similarly detailed pull request review discussing performance tradeoffs, TypeScript-integrated linting, and lint tool positioning.
- stc: Donny confirmed the article direction and mentioned no longer working on stc.
- TypeScript: Nathan Shively-Sanders mentioned Static TypeScript.
You can see the full comments in this blog post’s backing pull request. I sincerely appreciate everyone who pitched in! There wasn’t a single comment I disagreed with or didn’t find value from. 💖 Thank you all!
If any of this stuff is of interest to you, I’d encourage you to look at the projects’ GitHubs and try to get involved. We’re all open source projects and would love to have new contributors help out.
I help maintain typescript-eslint and make sure our issue backlog always has good first issues stocked for newcomers. Our website has a dedicated Contributing guide to help you through the steps. And, of course, we can always use more community financial contributors to help us work.
Let me know if you want any help! 😊
Liked this post? Thanks! Let the world know: