Josh Goldberg
Tevye from Fiddler on the Roof TODO_ACTIVITY

If I Wrote a Linter

Sep 13, 202435 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.

🛑 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:

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 pitfalls5 is not a straightforward task.

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

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 abandoned6 or stalled7. 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.8 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-eslint9: 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 support10. 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 time11. 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 cases12 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 mindshare13, 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 14, not long-lived noice.

In practice, I think this is not useful:

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 Rules

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 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”:

I think we could solve most of that by:

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.

Userland Standardization

Consistent Glossary

Most developers I’ve talked to do not understand or use the right terms when talking about linting. Heck, most developers don’t want to talk about the linter in the first place — let alone understand the difference between, say, a config and a plugin.

Many important linting terms have inconsistent usage or even definitions in the wild today. For example, “stylistic” can alternately refer to:

…what!?

I work on these tools and I have a hard time keeping this all straight.

The ESLint Glossary is a very good first step towards solidifying terminology. I would want to go one step further and have a single term and definition for all the linting concepts that authors and/or end users care about. Doing so would provide authors with the guidance to name their configs, plugins, and rules consistently to each other and the linter core. Users then would have an easier time navigating the plugin ecosystem and understanding how they all fit together.

Granular Rule Categories

Over the last decade, the linter community learned the hard way that users need properly categorized shared configs. If you give everyone one config that mixes logical and stylistic rules the way, say, eslint-plugin-airbnb did, users will resent the portions they disagree with and turn away from linting altogether. Plugins that include both of those broad areas of rules now tend to provide dedicated shared configs for different areas of rules 19 20.

Once a consistent glossary exists, its definitions can be used to consistently categorize configs and rules. Heck, the linter could even provide recommended utilities for generating configs based on rule categories. I think that’d go a long way towards further standardizing and making clear the ecosystem of plugin rules. At the very least, it’d help encourage plugin authors to make it easier for users to include only the categories of rules they want from the plugin.

First Party Templates

Userland Help

Thorough Config Initializer

Thorough Examples

Thorough FAQs

Full explanation docs for all decisions

Thorough Troubleshooting Guide

Community

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.

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

  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. eslint/rfcs#102 feat: parsing session objects

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

  5. typescript-eslint > Troubleshooting & FAQs > Typed Linting

  6. dudykr/stc#1101 Project is officially abandoned

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

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

  9. typescript-eslint > Rules > Extension Rules

  10. eslint/eslint#18830 Rethinking TypeScript support in ESLint

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

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

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

  14. eslint/eslint#16696 docs: Add explanation of when to use ‘warn’ severity

  15. eslint/eslint#15476Change Request: report unnecessary config overrides

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

  17. StackOverflow: Parsing error: was not found by the project service, but I’ve ignored these files

  18. Discord help thread: Eslint not ignoring .js files and throwing Definition for rule … not found error

  19. eslint-plugin-jsdoc: Configuration > Granular Flat Configs

  20. typescript-eslint: Shared Configs > Recommended Configurations


Liked this post? Thanks! Let the world know: