Josh Goldberg
The face of the man from Normal Rockwell's 'Freedom of Speech': standing up and looking up stoically, about to speak.

Hybrid Linters: The Best of Both Worlds

Mar 20, 202515 minute read

Native speed for parsing and type checking is wonderful. I also want the huge benefits of writing lint rules in an ecosystem's primary language. Here's how I think we can have both.

It’s an exciting time for web dev tooling. 2024 continued 2023’s trend of rewriting tooling in Rust, while 2025 saw TypeScript announce a rewrite in Go.

Web development linters today are effectively split into two archetypes:

From just headlines you’d think the next mainstream web linter should also be completely written in native code. The file intake and type information portions of it certainly should be for their incredible speed improvements.

But I believe that specifically the coordination and lint rule layers should still be written in TypeScript. TypeScript-first lint rules in particular help make a more approachable, user-friendly linting ecosystem. That’s a great goal to have as long as we can still benefit from the incredible performance improvements of native speed linters.

Let me explain why I think a third type of linter, a “hybrid” between native speed externals and a TypeScript shell, would bring the best of both worlds.

Hybrid Performance

Many of the performance gains from rewriting in native speed languages come from low-level optimizations to creating data structures in memory. TypeScript’s Go rewrite, for example, benefits greatly from Go’s memory layout and allocation models 1.

The vast majority of time spent in JavaScript core linting is on file intake and type information. Generating type information alone often takes 70% of total lint time 2. The remaining time in linting on coordination and lint rules is relatively small by comparison.

If a linter uses native speed for the slow areas of linting, then passes the results to JavaScript runtime for coordination and rules, will it still be wonderfully fast?

To test that question, I made a proof of concept: lint-rule-jit-on-parsing-native-speed. It parses files containing 100 lines of assorted syntax using swc, then lints with a rudimentary JavaScript rule. The results on my M1 Mac Studio are very encouraging:

This isn’t quite as fast as pure native speed linters, but it’s still incredibly fast. JavaScript engines are remarkably proficient at optimizing the kind of data structures and logic lint rules work in.

Also, keep in mind that a properly configured linter should only run on files impacted by code changes 3. Most of the time that is not a large percentage of your project. But even if you’re re-linting your whole project, ~3 seconds of overhead per 10k files in a proof of concept is darn good.

I think it’s safe to predict that JavaScript-speed lint rules backed by native speed parsing and type information will be more than fast enough for real-world linting.

JavaScript Plugins Lead to Hybrid Performance

Today’s native code linters are starting to allow third-party rules to be written easily in JavaScript/TypeScript 4 5 6. Doing so solves some native language linting approachability issues. Great!

But it does mean that the linters are sometimes effectively hybrid speed already. As soon as a linter configuration includes a JavaScript-speed plugin, the linter has to take the cost of spinning up its JavaScript engine and communicating with the JavaScript world.

Larger codebases tend to come from larger and/or longer-lived teams. Larger or longer-lived teams tend to have more investment in JavaScript/TypeScript tooling. There is a positive correlation between codebases being large enough to need a fast linter and their teams’ likelihood of writing custom lint rules in TypeScript.

Consider, too, the common case of lint rules needing to reference shared packages, such as design system tokens and utilities. Lint rules written in native code would need some interoperability layer to import and use those shared values — assuming they match Rust paradigms. Lint rules written in JavaScript/TypeScript can import the shared packages directly and stay within a single language’s way of thinking.

I think we can expect that most teams writing their own custom lint rules will lean towards JavaScript/TypeScript. If they already have developers writing those languages and native speed linters are still very fast with those rules then there’s often no strong motivation to instead write rules in native speed code.

Speaking of which…

Developer Compatibility

Writing custom lint rules is important both for team-specific concerns as well as for helping developers enter the wonderful world of dev tooling. Lint rules are self-contained exercises in using “AST”s (Abstract Syntax Trees): the core building block of many web development tools.

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.

✋ Please don’t reply guy me about how your team has plenty of Go/Rust/Zig/etc. devs. Plenty of teams do. The problem 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. Any additional cost makes it that much harder to get time for writing custom lint rules.

Why Not Multiple Rule Languages?

Allowing developers to write lint rules in the language they’re already familiar with does reduce barriers to entry. But I don’t think it fully removes barriers to entry.

The rules of a linter and its plugins are themselves an important knowledge base and avenue for contribution. Developers benefit from being able to read and understand the core rules of their linter. A linter’s own rules generally establish best practices and serve as a technical reference.

Any native core linter that also allows JavaScript-speed rule plugins will be stuck between a rock and a hard place:

Both options incur maintenance cost of separately documenting JavaScript rules and/or having to maintain two documented paths for rules. I think it’s better to have a single primary paradigm for lint rules. For the sake of approachability, I think that paradigm in web linting would be JavaScript/TypeScript.

Why Not Native Speed Coordination?

Once many lint rules are JavaScript-speed, I don’t see much benefit to writing the coordinating logic -arguments parsing, results printing, etc.- in native code. The most significant performance bottlenecks around file intake and type information are already eliminated by using external native speed dependencies. There’s not much to gain from moving the coordinating logic to native code.

The linter’s coordinating logic is also an important avenue for contribution. I’ve seen many developers start contributing to a linter’s rules and then move on to contributing to the linter itself. That’s how I got my start in open source!

The JavaScript/TypeScript ecosystem has a rich set of libraries and tools for coordinating logic. npm is a massive source of community support. There’s little benefit to switching to a smaller ecosystem with different paradigms if there’s no significant performance gain to be had. I believe keeping as much of a linter as possible in JavaScript/TypeScript, while still using native speed dependencies to optimize performance bottlenecks, is the right balance of approachability and performance.

Approachability Improves the Core

One common pushback I’ve received to emphasizing contribution approachability is:

“People don’t care about approachability, they just want a fast linter with good features.”

Sure, yes, agreed - but those features come from being approachable!

The easier a project is to contribute to, the more likely it is that developers will start contributing to it. A significant portion of the core rules of a linter are written by developers who started out writing custom lint rules. Most members of linter and plugin maintenance teams I work with started out as ad-hoc external contributors.

The more approachable a linter is, the better it becomes through community contributions. You really, really want contributor-friendly linters and lint rules. Even if you’re not contributing yourself.

Putting it Together

Here are the conclusions I’ve made so far:

  1. Hybrid linting performance is more than fast enough for real-world usage scenarios.
  2. JavaScript plugins will sometimes slow native speed linters down to hybrid speed.
  3. JavaScript/TypeScript-first rules are more approachable for the majority of web developers.
  4. Once lint rules are JavaScript-speed, coordinating logic might as well be in TypeScript too.
  5. Approachability is important for the long-term growth of a linter and its plugins.

In other words, I think an ideal web linter uses a JavaScript-speed language for user-facing concerns and a native speed language for externally sourced dependencies:

ConcernSpeedWhy
CoordinationJavaScriptMore approachable; ecosystem compatibility
Lint RulesJavaScriptMore approachable; ecosystem compatibility
File IntakeNativeSignificantly better performance
Type InformationNativeSignificantly better performance

How To Convince Me Otherwise

I’m open to learning I’m wrong about this. If a pure native speed linter can show me that it can provide the same developer and ecosystem compatibility as a TypeScript-first linter, I’d be happy to change my mind.

Most importantly, I’d want to see that a large plurality of web developers are happy to learn an additional language to write custom lint rules. Several reviewers on this blog post pointed out that if a developer is already willing to learn how to write custom lint rules, then they’re likely high-intent and willing to learn a useful language as well. Maybe I’m not giving my fellow dev tooling developers enough credit?

Alternately, if a native speed language does take over as a new de facto language of the web, then it would make sense to write linters and their rules in that language as well. Bindings between JavaScript and native languages might eventually be so fast that writing a full front-end app in a non-JavaScript/TypeScript language is a common reasonable choice. At that point the web ecosystem might shift to some other language(s).

I am least confident in my points on writing the core coordination logic in JavaScript/TypeScript. The core of a linter is much less commonly contributed to than the surrounding rules. The performance penalty of moving disk scanning, file I/O, and/or other tasks to JavaScript might be too high. I expect to continue experimenting with this to see how much of the linter’s coordinating logic can be written in JavaScript/TypeScript without a significant performance penalty.

Anyway, I don’t think any of the big shifts that would encourage native code lint rules are happening anytime soon. I think learning a new language is too harsh a prerequisite for dev tooling, that JavaScript/TypeScript is not going to fall by the wayside in the next few years, and that dual-language ecosystems fundamentally yield significantly fewer contributors. But hey, prove me wrong!

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

Separately, Nolan Lawson’s Why I’m skeptical of rewriting JavaScript tools in “faster” languages is a great post on the subject of native speed rewrites. It digs into the differences in runtime performance, contributions, and debuggability.

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!

Thanks to the following reviewers for excellent advice and feedback on this post as well as general web linting: auvred, Brad Zacher, Dimitri Mitropoulos (Michigan TypeScript), Don Isaac, and Joshua Chen. I appreciate your insights and time!

Footnotes

  1. microsoft/typescript-go#411 Why Go?

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

  3. Per typescript-eslint.io > Troubleshooting & FAQs > ESLint > Can I use ESLint’s --cache with typescript-eslint?, ESLint’s --cache does not work with typed linting. ESLint is not natively architected to work as a hybrid linter as proposed in this post. But I am confident that as more linters are created and rely on typed linting, they’re going to take on this very impactful performance optimization. Knip is a great proof-of-concept for fast import analysis that could be used by linters.

  4. Announcement: Plugins are coming to Biome 2.0

  5. Deno 2.2: OpenTelemetry, Lint Plugins, node:sqlite > JavaScript plugin API

  6. oxc-project/backlog#40 oxlint support ESLint plugins? comment


Liked this post? Thanks! Let the world know: