Josh Goldberg
Tevye from Fiddler on the Roof TODO_ACTIVITY

If I Wrote a Linter

Sep 13, 202415 minute read

Why I'd write a TypeScript linter in TypeScript, build in TypeScript syntax and type awareness always, and other musings on the state of web linting in 2024.

Anybody who works with a project long enough inevitably fantasizes about rebuilding it themselves. Part of that is natural human “Not Built Here” syndrome. Part is because any one person will have different drives and goals than the person or group of people in charge of the tool- even if they are one of them. And part is the inevitable struggle of long lived tools simultaneously trying to preserve legacy support and keep up with industry trends in real time.

I’ve been working on TypeScript linting for a while. I started contributing to TSLint community projects in 2016 and am now a number of the typescript-eslint and ESLint teams. I enjoy both those projects. This post isn’t to rebuttal of either project or their direction, just my idle fantasizing about what could be.

Core Architecture

This is how I would choose to build a linter in 2024.

TypeScript Core

It is my sincere belief that a linter for an ecosystem must be written in the standard flavor of that ecosystem’s primary language. For the web ecosystem, that means TypeScript.

I love the speed gains of native-speed tooling such as Biome and Oxc. Those are fantastic projects run by excellent teams, and they serve a real use case of ultrafast tooling. But there are two particularly strong reasons why I would strongly prefer a JavaScript flavor for core over an “alternative” language such as Rust.

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 ASTs. The linter is an important entry point for many developers to enter the wonderful world of tooling.

Using an alternative language for linter restricts 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 Rust, a low-level bare metal language.

One compromise that Rust linters will likely come to is allowing third-party rules to be written easily in TypeScript. That solves some of the issue. But that also bifurcates the lint ecosystem: any JavaScript/TypeScript developer who isn’t confident in Rust will only be able to contribute to a likely small slice of the linter’s ecosystem.

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.

Type Aware, Always

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 tradtional, AST-only rules. 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.12

But, typed linting is not an easy feature for many users. The divide between untyped core rules and only some typed rules is painful for the whole ecosystem:

Even if you do understand typed linting, you have to go through an additional setup on top of your config’s TypeScript configuration. Setting it up without hitting typed linting’s common configuration pitfalls4 is not a straightforward task.

On the other hand, if rules can always assume type awareness, the linting story becomes much simpler:

TypeScript For Type Awareness

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 abandoned5 or stalled6. The only publicly known effort right now is Ezno, which is very early stage and has a long way to go.

TypeScript is a huge project under active development from a funded team of incredibly dedicated, experienced Microsoft employees — as well as an active community of power users and contributors. The TypeScript team receives the equivalent of millions of dollars a year in funding from employee compensation alone. A new version of TypeScript that adds type checking bugfixes and features releases every three months.

Can you imagine the Herculean difficulty of any team trying to keep up with TypeScript?

I hope for a day when there is a tool that can reasonably compete with TypeScript. Competition is good for an ecosystem. But it’s going to be years until a tool like that can develop.

No Type Checking Shortcuts

It’d be great to avoid the performance cost of a full TypeScript API call. One workaround 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 presense 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 would be very confusing for users.

TypeScript’s Assignability APIs

One shortcut in reimplementing TypeScript could be to only implement part of it. Typed linters haven’t traditionally needed type errors, just type retrievals. Reducing scope for a TypeScript reimplementation could make it achieveable outside of the TypeScript team.

However, typescript-eslint will soon start using TypeScript’s type assignability APIs too.7 That means any TypeScript API replacement would have to not just retrieve the types of AST nodes, but also be able to perform assignability checking (i.e. compare them).

TypeScript’s type retrievals and type assignability are a majority of the tricky logic within the core type checker. At this point, the scope reduction from excluding type error reporting isn’t enough to make me much less pessimistic about reimplementation efforts landing soon.

Built-In TypeScript Parsing

ESLint is one of the few common modern JavaScript utilities that doesn’t support TypeScript syntax out-of-the-box. To add support, your configuration must use typescript-eslint. Even if you bypass having to create your configuration yourself by using a creation tool such as @eslint/create-config, you’ll still come across that complexity whenever you need to meaningfully edit that config file.

More troublesome long-term is the inability of core ESLint rules to understand TypeScript types or concepts. That’s led to the concept of “extension rules” in typescript-eslint8: rules that replace built-in rules. Extension rules are confusing for users and inconvenient to work with for both maintainers and users.

I’m excited that ESLint is rethinking its TypeScript support9. Hopefully, once the ESLint rewrite comes out, we’ll be able to declutter userland configs and deduplicate the extension rules.

Probably TypeScript’s AST

ESLint’s AST representation is ESTree. @typescript-eslint/parser works by parsing code using TypeScript’s parser into TypeScript’s AST, then recursively creating a “TSESTree” (ESTree + TypeScript nodes) structure roughly adhering to ESTree from that. Every so often, a tooling afficianado will notice this parse-and-convert duplication and suggest removing one of the two trees to improve performance.

First off, the cost of parsing two ASTs out of source code has never been the relevant bottleneck in any linted project I’ve seen. Parse time is practically always dwarfed by type-checked linting time10. Runtime performance is not a real reason to avoid the parse-and-convert.

Second, both of those ASTs are useful:

The main downside of this dual-tree format is the complication for linter teams and lint rule authors working with TypeScript APIs. On the typescript-eslint team, we’ve had to dedicate a bit of time for every TypeScript AST change to update node conversion logic. For lint rule authors, having to convert TSESTree nodes to their TS counterparts before passing to TypeScript APIs is an annoyance. We’ve written utilities to help with common cases11 but the conceptual overhead alone is bad enough.

Now that typed linting is stable in typescript-eslint and Flow is explicitly not targeting competing with TypeScript for public mindshare12, I’m leaning towards preferring a TypeScript AST shape for core. 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.

Ideology

Consistent Glossary

Granular Rule Categories

Logical and stylistic rules with recommended and strict

Thorough FAQs

Full explanation docs for all decisions

First Party Community Repositories

First party built in for what is the current slate of popular plugins

Features for Users

Type Aware By Default

Strongly Typed Rule Options

…export functions?

First Party Templates

Features for Developers

Virtual File System

Cross File Fixes

Implementation

Session Objects

Full project context available up front including preprocessors and session object

Pluggable Architecture and APIs

Pluggable api for embedding in places like typescript, TS, config and biome project

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. microsoft/vscode-eslint#1774 ESLint does not re-compute cross-file information on file changes ↩

  4. typescript-eslint > Troubleshooting & FAQs > Typed Linting ↩

  5. dudykr/stc#1101 Project is officially abandoned ↩

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

  7. typescript-eslint/typescript-eslint#7936 🔓 Intent to use: checker.isTypeAssignableTo ↩

  8. typescript-eslint > Rules > Extension Rules ↩

  9. eslint/eslint#18830 Rethinking TypeScript support in ESLint ↩

  10. typescript-eslint/typescript-eslint#7680 feat: add a new ESLint parser built on top of SWC ↩

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

  12. Clarity on Flow’s Direction and Open Source Engagement ↩


Liked this post? Thanks! Let the world know: