Josh Goldberg
TODO.

Why I'd Write a Linter in TypeScript

Sep 19, 202425 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 has continued 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.

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 many 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.

Multiple Core Languages?

Today’s Rust linters may likely 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 familiar 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. Splitting out rules into a completely separate language harms the linter’s ability to onboard new contributors.

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 paradigms well-supported by Rust. 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 Drawback: Type Information

I’ve previously blogged about how Rust-Based JavaScript linters are fast, but don’t (yet!) have a full TypeScript types story. 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.

2️⃣ TypeScript Over JavaScript

ESLint core is written in JavaScript and the upcoming rewrite of ESLint will also be written in JavaScript. The rewrite will use 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, the only logical 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.). Let me explain why.

Type Information Is Essential

typescript-eslint’s Typed Linting is the most powerful JavaScript/TypeScript linting in common use today. Lint rules that use type information are significantly more capable than traditional, AST-only rules. Type information allows lint rules to perform operations such as:

Many popular lint rules have ended up either dependent on typed linting or having to deal with known bugs or feature gaps without typed linting 1 2. ESLint’s core rules don’t understand type information, leaing to some typescript-eslint “extension” rules addding in type information 3.

We’ve seen from ESLint what happens when type information isn’t a native part of the linter:

Building a concept of type information into a linter’s core is, in my opinion, a must-have for any new linter in 2024 on.

The next question is: how do you get type information? Well…

TypeScript Is Essential For Type Information

TypeScript is the only tool that can provide full TypeScript type information for JavaScript or TypeScript code. Every public effort to recreate it is either abandoned6 or stalled7. Flow is explicitly not targeting competing with TypeScript for public mindshare8. The closest publicly known effort right now is Ezno, which is very early stage.

One way to avoid a TypeScript dependency could be to support only limited type retrievals: effectively only looking at what’s visible in the AST. I’d wager you could get somewhat far with basic AST checks in a file for many functions, and even further with a basic TypeScript parser that builds up a scope manager for each file and effectively looks up where identifiers are declared.

Sadly, an AST-only type lookup system falls apart fairly quickly in the presence of any complex TypeScript types (e.g. conditional or mapped types). Most larger TypeScript projects end up using complex types somewhere in the stack. Any modern ORM (e.g. Prisma, Supabase) or schema validation library (e.g. Arktype, Zod) employs conditional types and other shenanigans. Not being able to understand those types blocks rules from understanding any code referencing those types. Inconsistent levels of type-awareness are very confusing for users.

A full type system such as TypeScript’s is the only way path to fully working lint rules that perform any go-to-definition or type-dependent logic.

The next question is: how do you get type information using TypeScript? Well…

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 bloat. I’m proposing the TypeScript core strategy to avoid the dual tree format.

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 to help with common cases9 but the conceptual overhead is painful. 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.

On the typescript-eslint team, we’ve also had to dedicate a bit of time for every TypeScript AST change to update 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.

Drawback: 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 avoiding 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 10.

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.

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 11. The results there were that even with a 10% reduction in conversion time, the net reduction in overall time was only ~0.2% 12. That was because lint rules themselves take up most lint timing asking TypeScript for type information. For the forseeable future, I think there will be two speeds of linting:

We may be able to significantly speed up TypeScript type information long term. typescript-eslint-language-service is a direction I’d already like to explore with typescript-eslint now that our project service is stable. TSSLint is a recent project that does a great job of integrating with tsserver. 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. The cost of parsing and converting into a dual tree structure is not a significant performance factor when linting with type information.

Alternatives Can Be Good, Actually

Why do we need another linter? Users have configuring their linter enough as it is. Why give them the added tax of having to choose between multiple options in yet another part of their web stack?

I’ll tell you why: because the more we try, the faster we learn. Trying out many approaches and learning from them is one of the core strengths of the web ecosystem! Web development moves quickly because the community is able to try many things and quickly learn from them.

A big part of why I’d want to write a linter in TypeScript is because nobody else is. I think the advantages of this approach need to be tried out to be fully understood. Whatever learnings we can glean from this would be useful to the for the ecosystem in general.

I know it’s painful to have to choose between seemingly equivalent tools for many parts of a web project’s stack. It saddens me to discuss exasterbating that pain. But I think the long-term benefits of learning how to write better linters are well worth the short-term pain of giving users more choices.

The “dark matter” developers of 2035 will be a little better off because annoying try-hards like me pushed for growth in 2025.

Putting It All Together

Here are the motivations I’ve stated so far:

I think that’s a very compelling vision for a linter. It combines the developer and ecosystem compatibility of ESLint with the simpler execution model of Biome or Oxlint. Plus it optimizes for power by making type information a first-class citizen of core rules.

I don’t have the time to write a new linter this year. Nor am I certain these motivations are powerful enough to be certain that a TypeScript-first linter is the only viable web linting strategy. But I think there’s a very promising architecture that should be tried out… eventually.

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. facebook/react#25065 Bug: Eslint hooks returned by factory functions not linted

  2. vitest-dev/eslint-plugin-vitest#251 valid-type: use type checking to determine test name type?

  3. typescript-eslint.io > Rules > Extension & Type Information.

  4. eslint/rfcs#102 feat: parsing session objects

  5. microsoft/vscode-eslint#1774 ESLint does not re-compute cross-file information on file changes

  6. dudykr/stc#1101 Project is officially abandoned

  7. marcj/TypeRunner Is there still a chance of kickstarting the project?

  8. Clarity on Flow’s Direction and Open Source Engagement

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

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

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

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


Liked this post? Thanks! Let the world know: