Josh Goldberg
TODO.

Why I'd Write a Linter in TypeScript

Nov 5, 202420 minute read

Explaining the potential of a TypeScript-first linter strategy that blends the compatibility benefits of ESLint with the simpler execution model of Biome or Oxlint.

It’s an exciting time to be working in web linting. ESLint’s flat config is stable in production and most popular community plugins support it now. 2024 is continuing 2023’s big trend of web tooling was rewriting existing tooling in Rust. The Node.js linting world has effectively been split into two archetypes:

Both are exciting and experiencing fascinating growth cycles. But I think neither is exactly what I want from a linter. I want a TypeScript core that can build in typed linting.

I’d like to explain what that means, how it’s different from the other archetypes, and the benefits it brings.

1️⃣ TypeScript Over Native Speed Languages

I believe the linter for an ecosystem should be written in a flavor of that ecosystem’s primary language. Although other languages may be faster, I think there are strong benefits around developer and ecosystem compatibility to stick with JavaScript or TypeScript for web.

I’m not saying native speed linters such as Biome or Oxlint shouldn’t exist, or that you shouldn’t use them. Those are fantastic projects run by excellent teams, and they serve a real use case of ultra-fast tooling. Biome and Oxlint are truly wonderful and I think there’s a lot of use for linting ginormous amounts of files with them.

I’m saying I think there should also be a JavaScript/TypeScript-first linter - and here’s why.

Motivator: Developer Compatibility

One of the best parts of modern linters is the ability for teams to write custom rules in their linter. Lint rules are self-contained exercises in using “AST”s (Abstract Syntax Trees): the core building block of many web development tools. The linter is an important entry point for many developers to enter the wonderful world of tooling.

Using an alternative language for a linter gates development to developers who are familiar with both languages. Most developers writing TypeScript, a high-level memory-managed VM language, aren’t also familiar -let alone confident- with lower level languages such as Go or Rust.

Speaking generally, most professional teams developing web applications don’t include many low-level-familiar developers (if any). There may be a few Go developers familiar with the backend if the product stack includes Go. But finding multiple developers on a TypeScript-focused team who are proficient enough with Go or Rust to write lint rules in them is generally a pleasant surprise rather than a common norm.

✋ Please don’t reply guy me about how your team has plenty of Go/Rust/Zig/etc. devs. Plenty of teams do. The point is that most web teams don’t.

Most developers -especially “dark matter” developers- work in roughly one paradigm. In my experiences on web platform teams, it was hard enough to get developers interested in any custom lint rules, let alone ones written in a completely different paradigm than their day-to-day work. I’d like to avoid any additional “barriers to entry” if at all possible.

Motivator: Avoiding Multiple Core Languages

Today’s Rust linters may eventually allowing third-party rules to be written easily in JavaScript/TypeScript. That would solves some of the language approachability issues. Teams could write lint rules in the web language they’re comfortable in.

But splitting a linter’s language bifurcates the lint ecosystem. Lint rule implementations become split across multiple languages. Any developer who is only confident in one of those languages will not be able to contribute to a significant portion of the linter’s ecosystem.

Consider also a TypeScript-focused development team that happens to have a Rust-proficient developer writing custom lint rules. How likely is the rest of the team to feel confident contributing to those lint rules, given they’d have to ramp up both on ASTs and on Rust? What happens to those Rust lint rules if the 1-2 Rust-familiar developers leave the team?

Furthermore, even if a team’s lint rules are written in TypeScript, there’s still an approachability issue with the core linter’s own lint rules. A linter’s own rules generally establish best practices and serve as a technical reference. Not so if userland rules are written in a different language.

I want users of my linter to be able to look at the source code of the linter’s rules. They should be helped towards the same “oh, this isn’t so unapproachable!” realization I had many years ago that got me into working on linting. Splitting out rules into a completely separate language harms the linter’s ability to onboard new contributors.

Motivator: Ecosystem Compatibility

Most libraries for any ecosystem are written exclusively for that ecosystem’s one primary runtime. Third-party lint rules, especially those specific to a framework, often end up using those utilities.

Writing JavaScript/TypeScript lint rules in JavaScript/TypeScript guarantees the lint rules have access to the same set of utilities userland code uses. Having to cross the bridge between JavaScript/TypeScript and Rust for a JavaScript/TypeScript would be an added tax to development and maintenance.

Consider again the common case of a web development team. Suppose their TypeScript design system tokens and utilities are referenced in custom lint rules — a common need. Lint rules written in Rust would need some interoperability layer to import and use those tokens, assuming those tokens match Rust paradigms. Lint rules written in TypeScript can import those tokens directly and stay within a single language’s way of thinking.

Bridges between native code and JavaScript/TypeScript do exist. Node.js WASM interop layers in particular are steadily getting better over time. But the conceptual overhead of jumping between language paradigms makes me lean strongly away from multi-language lint rules.

Not a Motivator: Type Information

💡 Not familiar with typed linting? See Typed Linting: The Most Powerful TypeScript Linting Ever.

I’ve previously blogged on type information and Rust linters: Rust-Based JavaScript Linters: Fast, But No Typed Linting Right Now. No Rust-based linter today supports linting with type information.

That’s a transient concern. Those linters will eventually support typed linting. They’re already working on it. Lack of typed linting support is not a long-term motivator against writing linters in Rust.

For me, it’s the drawbacks on developer and ecosystem compatibility that push wanting a linter written in JavaScript or TypeScript.

Drawback: Performance

Native speed linters are absurdly fast at untyped linting. They can achieve incredible performance because they’re written in languages optimized for performant low-level tooling. A linter that executes wholly in Node.js “JIT” (Just In Time) compiling speed cannot achieve even close to the same speeds.

However! Typed linting will always be roughly as slow as type checking.

We may be able to significantly speed up TypeScript type information long term. Bun, Node.js compile caching, and other engine- and platform-level improvements give me a lot of hope for a much faster JavaScript world. Maybe we could even have a TypeScript daemon that preemptively generates type information files before lint rules run.

Regardless, for now, TypeScript runs at JIT speed, and therefore type information will be as well. I think we should take a step back and evaluate how impactful these gains are long-term in a typed linting world. I think there will be two speeds of linting for the forseeable future:

Native speed linters are certainly excellent, but I don’t think their performance is as big of a game changer as it seems.

ESLint Performance is a Straw Man Fallacy

Many of the teams excited about their performance improvements from switching off ESLint were “holding it wrong” to begin with. ESLint is notoriously difficult to configure at scale [^TODO] [^TODO] [^TODO]. I’ve seen plenty of projects from very capable team experiencing 5x [^TODO] or 10x [^TODO] slowdowns from entries covered in the typescript-eslint Performance Troubleshooting & FAQs.

Furthermore, ESLint is not optimized for typed linting performance. Its caching does not support cross-file information [^TODO] and there are clear engine-level optimizations that have not yet been made [^TODO]. Explorations in TSSLint show a very quick significant performance speedup compared to ESLint. Heck, Node.js compile caching was only recently added to ESLint [^TODO] and TypeScript [^TODO].

You can’t make a fair judgement against linters written in a JavaScript flavor by pointing at ESLint. ESLint is not the fastest linter running JavaScript on Node.js anymore. Judging all JavaScript-speed linters based on ESLint is a straw man fallacy.

2️⃣ TypeScript Over JavaScript

ESLint core is written in JavaScript and the upcoming rewrite of ESLint is also written in JavaScript. The rewrite uses JSDoc comments to allow TypeScript to type check it. This is an intentional decision by the ESLint TSC (Technical Steering Committee) for several reasons:

ESLint’s decision makes sense given those goals and priorities for ESLint. I’m not trying to suggest any changes to ESLint in this post. My vision for a linter is different from ESLint’s, so I’ve naturally come to a different conclusion.

My priorities have a higher value on optimizing for the majority and a lower value on ideological pureness. I would rather make a linter that requires no out-of-the-box plugins for common important use cases -including typed linting- than one that is more dependency-controlled.

With that priority weighting, my conclusion is to make the linter wholly TypeScript-first for its JavaScript and TypeScript files by default. It’d of course have support for non-script languages (JSON, Markdown, etc.) and allow plugging in non-TypeScript flavors of JavaScript (Ezno, Flow, etc.). But I want to optimize the architecture to be as straightforward as possible for the most important use case, typed linting.

Let me explain why.

TypeScript Nodes Are Essential For TypeScript Types

TypeScript’s type checking APIs must be provided AST nodes generated by TypeScript. Specifically, TypeScript’s Type Checker APIs are made available by the TypeScript “Program” objects that roughly represent an application being type checked using a TSConfig. If a linter wants type checking with TypeScript today, it needs to create a TypeScript Program and use nodes created by that Program’s representation of source files.

That means type-informed linters have two strategies to choose from:

As I see it, if you’re going to be creating a TypeScript tree internally anyway, having an additional tree is conceptually heavyweight. I’m proposing the TypeScript core strategy to avoid the burden of a dual tree format.

Motivator: Dual Trees Are Annoying

The main downside of the dual-tree format is the complication for linter teams and lint rule authors working with TypeScript APIs. We’ve written utilities in typescript-eslint to help with common cases 1 but the conceptual overhead is painful. Most nontrivial typed lint rules end up breaking out of those utilities and needing to call to TypeScript APIs that require TypeScript AST nodes.

Ramping teams up on AST concepts, then on type checker APIs, is painful enough. Now we have to explain why there are two ASTs, and when to use each one? It’s a nightmare.

We’ve also had to dedicate a bit of time for every TypeScript AST change to update the typescript-eslint parser’s node conversion logic. We sometimes have bugs when types mismatch between trees or our conversion logic doesn’t represent relationships correctly. The maintenance tax for dual-tree conversion isn’t horribly painful, but it’s non-zero.

We should be making the acts of writing lint rules and adding type awareness to lint rules as streamlined as possible. Especially given my desire for built-in type awareness, I think the tradeoff of having to depend on TypeScript is worth it.

Not a Motivator: Dual Tree Performance

I’ve seen a lot of developers express performance concerns about how typescript-eslint’s dual tree strategy has to create two ASTs. Although thinking about performance is good, I’ve never seen tree parsing or conversion be a relevant performance bottleneck once typed linting is involved. The space and time used for a project’s typed linting are always exponentially larger than those of a dual tree parse-and-convert.

For example, Brad Zacher tried out optimizing typescript-eslint’s parser to use a more efficient AST converter 2. The results there were that even with a 10% reduction in conversion time, the net reduction in overall time was only ~0.2% 3. That was because lint rules themselves take up most lint timing asking TypeScript for type information.

The cost of parsing and converting into a dual tree structure is not a significant performance factor when linting with type information.

Drawback: ESLint Ecosystem Compatibility

One downside of using the TypeScript AST shape for lint rules is losing interoperability with existing ESLint rules. ESLint’s rules use a different AST structure, ESTree, that is fundamentally different from TypeScript’s AST structure.

Any rule written for ESLint and ESTree would have to be rewritten in TypeScript’s AST. That’s a lot of work, especially as rules update. TSLint was killed in part to avoid this very same burden!

I think this drawback is important, but not as pressing as it used to be. ESLint’s ecosystem has matured over the last few years. The act of writing rules is much better documented now than it was when TSLint was being killed. The recent ESLint “flat config” launch is both taking a lot of time away from rule shakeups and helping show how many community plugins aren’t actively changing very much 4.

For experimental evidence, see the Biome Linter rules from other sources and Oxlint Rules pages showing hundreds of community rules each linter has implemented. I think that progress is great evidence that the drawbacks of rewriting lint rules in a new AST structure can be outweighed by other advantages.

More Thoughts

I’ve put a lot of thought into how linting plays into the web ecosystem. You can read more of my blog posts to see other aspects of it:

I’m drafting a much deeper dive into what I would want in a new linter. You can preview it on Blog post: ‘If I wrote a linter’.

Nothing I’ve said here or any in any of those blog posts is set in stone. If you have thoughts here, I’d love to talk with you. Let me know!

Footnotes

  1. typescript-eslint/typescript-eslint#6404 feat(typescript-estree): add type checker wrapper APIs to ParserServicesWithTypeInformation

  2. typescript-eslint/typescript-eslint#6149 Repo: investigate switching core TS->ESTree conversion logic from a “switch/case” to a “jump table” for performance improvements

  3. typescript-eslint/typescript-eslint#6371 feat(typescript-estree): use a jump table instead of switch/case for conversion logic

  4. eslint/eslint#18093 📈 Tracking: Flat Config support


Liked this post? Thanks! Let the world know: