🛑 THIS IS JUST A DRAFT.
I need to run it by other developers in the linter ecosystem. It might be horribly wrong and it might be horribly misrepresenting reality. Please don’t take it seriously!
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 the standard linter for an ecosystem should 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 right now. The divide between untyped core rules and only some typed rules is painful for the ecosystem:
- Core rules are less powerful than they could be
- Extension rules have to choose between being fast and easy to set up vs. slow and type-aware
- ESLint core isn’t structured for cross-file linting, so there are known typed linting performance woes[^rfc-feat-parsing-session-objects] and blatant editor extension bugs3
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:
- Core rules don’t need to be delegated to or duplicated by plugins to add in typed linting support
- Extension rules don’t have to choose an awkward dependency on a non-core parser for type information
- The core linter architecture can be optimized for type-checked linting performance
For this always-type-aware-world, I envision projects effectively always having typescript-eslint’s new Project Service enabled. And because the core can optimize for it, it wouldn’t have performance issues from including “out-of-project” files. All files could be linted with type information! What a wonderful world that would be.
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 closest publicly known effort right now is Ezno, which is a very early stage language 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.
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.
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 creating 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 inconvenient 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.
If I wrote a linter, it would support TypeScript natively. No additional packages or “extension” rules. The core parser would be TypeScript’s, and core rules would understand TypeScript syntax and types.
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:
- ESTree: means lint rules have no dependency on the corporate-backed TypeScript — they are compatible with ESLint core
- One of the main downsides of TSLint being based on TypeScript’s AST was having to rewrite every ESLint/ESTree-based lint rule for TSLint
- TypeScript’s: must be used for AST nodes passed to TypeScript APIs, most notably for typed linting
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.
Embeddable by Design
Right now, most web projects that employ both linting and type checking run them separately in CI. Projects typically either run them in parallel across two workflows or in series within the same workflow. That’s inefficient. You either use an extra workflow or take roughly twice as long to run.
The root problem is that projects typically don’t connect the type information generated by TypeScript to typed linting in ESLint.
Designing an embeddable linter is not a straightforward problem.
A TypeScript plugin isn’t sufficient for all projects.
What if a project lints non-TypeScript files, such as JSON or YML, that the type checker won’t run on?
What if those files include embedded snippets that may run with type information, such as fenced ```ts
code blocks in Markdown?
I haven’t had time to deeply investigate how to deduplicate type checking work would work well. typescript-eslint-language-service is a direction I’d already like to explore in working more closely with typescript-eslint. TSSLint is a recent project that does a great job of integrating with tsserver.
User Experience
Only Errors
All web linters I’ve found allow configuring rules as errors or warnings. In theory, this is straightforward: errors are visualized with red squigglies and fail builds; warnings are visualized with yellow squigglies and don’t fail builds. Warnings are supposed to be transient indicators during migrations or when rules aren’t certain about issues 13, not long-lived noice.
In practice, I think this is not useful:
- Using the same red color and terminology for lint errors and type-checking errors is confusing both ideologically and practically. I personally tell my VS Code to visualize lint errors with yellow squigglies, to not conflict with red TypeScript squigglies.
- Warnings tend to live forever in codebases, which trains developers to ignore lint reports.
- If a problem can’t be determined with certainty, it either should be suppressed using an inline config comment with an explanation, or not turned into a lint rule at all!
In other words, I think warnings are a bad fit for the migration use case.
Tools like eslint-nibble can provide a more comprehensive experience.
Editor features such as VS Code’s eslint.rules.customizations
can now change how rules are visualized.
If I were to write a linter, I would have it so rules can only be turned off or on. Gradual onboardings of new rules or rule options would be a separately managed feature. Changing of visuals for specific rules or categories thereof would be separately managed features in editor extensions.
Strongly Typed Rule Options
One of my biggest gripes with all existing linter configuration systems today is that rule options are not type-safe. To recap, you specify them as properties an object, where their string key is their plugin name and rule name, and their value is their severity and any options:
{
"my-plugin/some-rule": ["error", {
setting: "..."
}]
}
Those string keys have no associated types in config files.
Linters themselves can validate rule options, such as ESLint’s options schemas, but those don’t translate to TypeScript types.
You don’t get editor intellisense while authoring; instead, you have to use @eslint/config-inspector
or run your config to know whether you’ve mistyped the name of a rule or an option.
I’d love to make a standard plugin creator function that plugin authors are encouraged -even required- to use. It could take in a set of rules and return some kind of well-typed function.
Vaguely, maybe it’d use a TypeScript-friendly schema validation library such as Zod and look something like:
import { createRule, createPlugin } from "@joshuakgoldberg/if-i-wrote-a-linter";
import { z } from "zod";
const someRule = createRule({
options: {
option: z.string(),
},
});
export const myPlugin = createPlugin({
name: "My Plugin",
rules: [someRule],
});
…and in usage could look something like:
// linter.config.ts
import { myPlugin } from "@joshuakgoldberg/my-plugin";
export default [
rules: myPlugin.rules({
someRule: {
option: "..."
},
}),
];
Under that kind of system, users would receive intellisense as they type plugin rules, and all those settings could be type checked. Doing so would even coincidentally solve the issue of plugin namespacing and rule config duplication. Config values would still be verified at runtime by the schema validation library.
Strongly Typed Plugin Settings
An even less type-safe part of ESLint’s current config system is the shared settings
object.
You can put whatever you want in there, and any plugin may read from it.
In theory, cross-plugin shared settings can be used for related plugins, while plugin-specific settings are by convention namespaced under their name. In practice, I don’t think I’ve ever seen a shared setting used across plugins.
I think a settings system more true to how plugins use it would have plugins define their own settings and settings types.
Vaguely, maybe it’d use a TypeScript-friendly schema validation library such as Zod and look something like:
import { createPlugin } from "@joshuakgoldberg/if-i-wrote-a-linter";
import { z } from "zod";
export const myPlugin = createPlugin({
name: "My Plugin",
rules: [
/* ... */
],
settings: {
setting: z.string(),
},
});
…and in usage could look something like:
// linter.config.ts
import { myPlugin } from "@joshuakgoldberg/my-plugin";
export default [
plugins: [
myPlugin({
setting: "..."
})
],
];
As with rules, allowing plugins to define their own settings types would help with the config authoring experience. It would also newly allow shared settings to be validated by both type-checking and the core linter. Doing so means plugins can be more confident in defining settings and changing them over time as needed.
Strongly Typed Configuration Files
Let’s take a step back from all these strong typings. I think there are roughly two classifications of linter configs in common use today:
- Direct JSON (
biome.json
, Deno, Oxlint) - Nuanced JS (ESLint:
.eslintrc.js
(deprecated),eslint.config.js
)
Direct JSON is a nice and straightforward “walled garden” that shines in small projects. But I don’t think it scales well. The user experience of typing config files isn’t great if you’re not using a custom editor extension to get JSON intellisense. More importantly, specifying plugin modules isn’t conceptually straightforward. Once the native language linters support plugins, we’lll have to specify them by some string specifier matching the plugin’s module entry point. That duplication of core JavaScript semantics feels off to me.
Nuanced JS configurations from ESLint, on the other hand, are “just JavaScript” and so utilize native module importing for plugins, global variables, and shared configurations.
That’s great for understandability and simplifying the plugin loading model.
ESLint’s flat config is a huge step forward from the confusing overrides
model of ESLint’s legacy configs.
But, I think we’re learning the hard way what nuances trip people up this far outside the “walled garden”:
- Because all config entries are just JavaScript objects, there’s no way to lint only the user’s own config entries 14
- Directory relativity becomes confusing when nested configs
import
from a higher-up config 15 ignores
is not very intuitive right now 16 17- Plugins’ shared configs being able to set file matches and other settings besides rules adds complexity to configs that use multiple plugins
I think we could solve most of that by:
- Creating delineated functions or objects to create
ignores
blocks, shared configs, and plugins. - Having plugins automatically register themselves when their rules are included
- Only allow plugin configs to specify rules and shared rule settings
Doing so would clarify the intentions behind each portion of a config both to users and to tooling. I think a good goal would be to make it so you don’t have to understand anything about a config system to be able to read and understand a config file.
Here’s a rough sketch of what the root monorepo config could like with a config system more akin to a tsup.config.ts
or vitest.config.ts
, but with explicit functions for common operations:
// if-i-wrote-a-linter.config.ts
import { linter } from "@joshuakgoldberg/if-i-wrote-a-linter";
import { someExample } from "@joshuakgoldberg/plugin-some-example";
export default linter.config({
files: [
{
extends: [
linter.recommended.logical(),
linter.recommended.stylistic(),
someExample.recommended({
setting: true,
}),
],
glob: "**/*.{js,ts}",
rules: [
linter.rules({
someCoreRuleA: false,
someCoreRuleB: true,
someCoreRuleC: {
someSetting: true,
},
}),
someExample.rules({
somePluginRule: true,
}),
],
},
],
ignore: ["packages/*/dist/", "generated/"],
workspaces: ["packages/*"],
});
Each packages/*
workspace directory could define its own config that explicitly indicates its root directory:
// packages/example/if-i-wrote-a-linter.config.ts
import { linter } from "@joshuakgoldberg/if-i-wrote-a-linter";
export default linter.config({
files: [
{
glob: "src/**/*.ts",
rules: [
linter.rules({
someCoreRuleD: false,
}),
],
},
],
ignore: "lib/",
root: "../..",
});
Those config file sketches are a little more verbose than ESLint flat config.
But they’re more explicitly clear on what they do, and can be made much more type-safe using the APIs like linter.config()
and linter.rules()
.
The core linter could then even let users know of any redundant properties passed to a linter.rules()
, somePlugin.rules()
, or somePlugin.settings
.
That config system is just a sketch and I have no way of knowing how its tradeoffs would work in production today. But I’d really love to see how its tradeoffs are experienced by users.
Ideology
Consistent Glossary
Granular Rule Categories
Logical and stylistic rules with recommended and strict
Thorough Examples
Thorough FAQs
Full explanation docs for all decisions
Thorough Troubleshooting
First Party Community Repositories
First party built in for what is the current slate of popular plugins
Features for Developers
Cross File Fixes
A linter is in some ways the best codemod platform for many kinds of migrations.
First Party Templates
Virtual File System
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
-
facebook/react#25065 Bug: Eslint hooks returned by factory functions not linted ↩
-
vitest-dev/eslint-plugin-vitest#251 valid-type: use type checking to determine test name type? ↩
-
microsoft/vscode-eslint#1774 ESLint does not re-compute cross-file information on file changes ↩
-
typescript-eslint > Troubleshooting & FAQs > Typed Linting ↩
-
marcj/TypeRunner Is there still a chance of kickstarting the project? ↩
-
typescript-eslint/typescript-eslint#7936 🔓 Intent to use: checker.isTypeAssignableTo ↩
-
eslint/eslint#18830 Rethinking TypeScript support in ESLint ↩
-
typescript-eslint/typescript-eslint#7680 feat: add a new ESLint parser built on top of SWC ↩
-
typescript-eslint/typescript-eslint#6404 feat(typescript-estree): add type checker wrapper APIs to ParserServicesWithTypeInformation ↩
-
eslint/eslint#16696 docs: Add explanation of when to use ‘warn’ severity ↩
-
eslint/eslint#15476Change Request: report unnecessary config overrides ↩
-
eslint/eslint#18385 Change Request: Make it easier to inherit flat configs from the repo root. eslint/rfcs#120 feat!: Look Up Config Files From Linted File was accepted to change lookup locations, but there’s still a conceptual ambiguity of how one config file’s relative paths should work in another config file. ↩
-
StackOverflow: Parsing error: was not found by the project service, but I’ve ignored these files ↩
-
Discord help thread: Eslint not ignoring .js files and throwing Definition for rule … not found error ↩