Today’s web linters are great.
ESLint is robust and has a huge ecosystem of plugins and configs.
Newer native speed linters such as Biome, deno lint
, and Oxlint are fast and easy to set up with their own burgeoning ecosystems.
I’ve been working on TypeScript linting for almost a decade. I started contributing to TSLint community projects in 2016 and am now a number of the typescript-eslint and ESLint teams. Based on those years of various linters, I’ve collected a large set of design ideas that I want to try.
I see a linter that is fast by default, powerful upon request, and straightforward to configure. This post is how I would craft the ecosystem around it.
💡 This post is the third in a series:
- Part 1: Architecture
- Part 2: Developer Experience
- 👉 Part 3: Ecosystem
- Part 4: Summary (coming soon)
Community Discord
Every mainstream linter has some kind of community Discord. They each have channels for:
- Automatic posting of new releases
- General discussion
- Help forums
- Project development
I have no new ideas in this space. I think the existing Discords are great. No complaints here.
If I wrote a linter, it would have a community Discord akin to the existing ones.
Shared Glossary
Many important linting terms have inconsistent usage or even definitions in the wild today. For example, “stylistic” can alternately refer to:
- Stylistic (Rule): The category of lint rules that enforce formatting, naming conventions, or consistent usage of equivalent syntaxes
- ESLint Stylistic: The plugin that ESLint’s formatting rules were migrated to, along with some non-formatting stylistic rules
- typescript-eslint’s stylistic shared configs, which enforce consistent usage of equivalent syntaxes, as well as general TypeScript best practices that don’t impact program logic
Other ambiguous terms include:
- “Config”: a shared config, or a configuration file?
- “Format”: formatting rules, or a formatter like Prettier, or code to prints ESLint reports?
…huh!?
I work on linters and I have a hard time keeping this all straight. Now imagine how confusing this all is for someone new to linting, and/or who doesn’t care much about their linter.
If I wrote a linter, I would continue the ESLint Glossary work by defining single recommended terms for all the shared linting concepts. I hope this would help reduce naming conflicts such as what we’re seeing with “config”, “format”, and “stylistic”.
Common Core Rules
One of the hardest parts of figuring out a project’s ecosystem is knowing what to put in core or delegate to users. I’ve seen two prevailing strategies in the wild:
- Lean core (ESLint): only including rules that pertain to JavaScript itself, which reduces its maintenance burden and allows plugins to iterate and learn
- Heavy core (Biome, deno lint, Oxlint): including common plugins, which increases the power of the linter out of the box and reduces configuration complexity
I like the lean core approach for allowing the ecosystem to iterate and learn in areas that aren’t solidified yet or are too project-dependent for core. I don’t believe a linter’s core should have rules that don’t apply to a supermajority of its users. Putting a rule in core is a heavyweight action for that rule. Development on the rule slows down as the core team has to be more careful about breaking changes than the typical plugin.
On the other hand, once a plugin is known to be stable and applicable to a supermajority of users, putting it in core can be very useful. Core linter rules tend to be more stable than plugin rules and more discoverable by virtue of being in the core documentation website. Onboarding a plugin to core means users don’t have to go out of their way to set it up.
If I wrote a linter, its core rules would pull in all those from plugins that are:
- Common to roughly all projects that would use the linter
- Stable enough that they are not still rapidly iterating and making breaking changes
That includes taking rules from the following plugins:
@eslint-community/eslint-plugin-eslint-comments
eslint-plugin-import
eslint-plugin-jsdoc
eslint-plugin-package-json
eslint-plugin-promise
eslint-plugin-n
eslint-plugin-regexp
eslint-plugin-unicorn
Note also from Part 1 > Core Common Languages that JSON, Markdown, TypeScript, and YML plugins would also be in core.
That does not include the following plugins:
@eslint-community/eslint-plugin-n
: not all projects use Node.jseslint-plugin-jsx-a11y
and other frontend plugins: not all projects include frontend codeeslint-plugin-perfectionist
: not enough of the community is bought in (yet?)eslint-plugin-vitest
and other test plugins: many projects test with different frameworks
Those plugins are important and should be discoverable. The next two sections in this post describe how the linter would help promote them to users.
Community Organization
The ESLint Community organization is wonderful. I think it serves a great need for housing high-applicability, high-value community projects that are not able to be part of ESLint core. It’s a kind of “next step” for finding plugins outside of ESLint core — not quite “first party”, and not an external “third party”.
If I wrote a linter, it would lean into having an equivalent community organization. That organization would have guidelines for inclusion, including:
- Actively supporting new core linter minor versions soon after release
- Adherence to the shared linting glossary
- Documenting all configs, rules, and rule options
- Maximum time to (re-)triage issues and pull requests
- Minimum numbers of consuming projects and weekly downloads
- Not being specific to any one userland framework
- Providing metadata alongside the
package.json
such as:- Names of any dependencies the plugin is directly for (e.g.
"lodash"
,"react"
) - Text of when to use the plugin and each of its configs
- Names of any dependencies the plugin is directly for (e.g.
To start, it would include equivalents of:
@eslint/css
@html-eslint/eslint-plugin
eslint-fix-utils
eslint-plugin-jsx-a11y
eslint-plugin-eslint-plugin
Those community plugins would be included in ecosystem tests for the core linter to ensure new core linter releases don’t unexpectedly break them 1.
Plugin Registry
Much of a linter’s ecosystem will always be third-party plugins. Finding the right third-party linter plugins for a project today is a pain. ESLint does not yet have a centralized listing 2 or one canonical approach users should take. The best process I’ve come to recommend for any given project is:
- Search dustinspecker/awesome-eslint for plugins that seem relevant to the project
- For each dependency the project relies upon, search online for “eslint plugin” and that dependency name
That’s a slow, unreliable process. Determining which plugins are popular or still actively maintained is time-consuming 3. It would be helpful if there was more automation and centralization around what plugins are available.
If I wrote a linter, I would create a centralized plugin registry of popular userland plugins. It would have similar guidelines for inclusion as the community organization, but with more lenient numbers, and allowing framework-specific plugins. The registry would automatically update plugin metadata such as:
- How recently the plugin was updated
- How many open issues exist that haven’t been interacted with by a maintainer
- The latest version of the linter that the plugin formally supports
Plugins that get too out-of-date on any of those metrics would be marked as such in the UI. That would allow users to filter and search for plugins that are, say, actively maintained and support the latest version of the linter.
The registry would be exposed to users in two ways:
- API: allowing tooling to be built using known plugin metadata, such as…
- Website: allowing users to search on that metadata
Essentially, this would be a tailored npm for linter plugins 4.
Config Initializer
Every mainstream linter comes with some kind of configuration file initialization CLI: @eslint/config
, biome init
, oxlint --init
, etc.
Good!
Initialization CLIs help users get started quickly and with confidence their configuration is correct.
My only gripe with those CLIs is that they only cover a few base starting points.
You’re left on your own to figure out how to add plugins not explicitly hardcoded into the CLI.
@eslint/config
, for example, offers to support the React and Vue frameworks out-of-the-box, but that’s it.
If I wrote a linter, its config initializer would use plugin data from the centralized plugin registry to make the setup experience dynamic. If run in a project with existing dependencies, it would offer to add the plugins for those plugins into the created configuration. It would also offer the user an input to provide dependency names they want to search on plugins for.
It’d probably have to use a templating system like Bingo’s Stratum so plugins can define how they add to a config file.
Initialized Examples
Initialization CLIs are great for previewing changes locally, but many users also want to see examples in documentation form. Creating up-to-date documentation examples is a pain to do manually — especially for community-authored plugins specific to frameworks. But, once we factor in the centralized plugin registry and config initializer templating, we can automate that process!
If I wrote a linter, it would automatically keep a known set of example repositories up-to-date with the latest versions of the linter and a plugin each. CI actions would run that update the templates whenever a new version of a dependency is released.
I implemented a similar feature for my Bingo and create-typescript-app projects recently.
bingo-js > Example Repositories lists several repositories auto-generated from templates.
The created-typescript-app*
ones each run a GitHub Action that checks in any changes from re-running CTA on Renovate pull requests.
One-Way Compatibility
If you want to get users to adopt a new linter, you need to make it easy for them to migrate from the current dominant market leader: ESLint. If users can’t switch from an existing ESLint setup confidently and quickly, they won’t.
I think most linters today target two-way compatibility. Meaning: they support all the use cases ESLint does by porting close equivalents to all of ESLint’s features.
Two-way compatibility doesn’t necessarily mean full compatibility. Details might not be the same or fully implemented, such as native speed linters not (yet?) 5 supporting JS configs rather than JSON. But those linters generally preserve ESLint’s features and allow users to migrate their configs with minimal conceptual changes 6.
My problem with two-way compatibility is twofold:
- It binds the new linter to older designs, even if they aren’t what the new linter would prefer (looking at you, errors vs. warnings…)
- By not allowing feature breakage, a new linter is restricted from choosing beneficial and splashy new features that can benefit users and drive excitement
If I wrote a linter, I would target one-way compatibility. Meaning: there would be support for all the use cases ESLint supports, but they might not map up to ESLint’s features. For drastically changed concepts, those migrations would one-way port to the linter’s equivalents — which might not preserve the exact original semantics.
Taking errors vs. warnings as an example, the equivalent would be the new linter’s “gradual onboardings” system 7 8. I’m envisioning a system that allows users to mark swathes of files as still onboarding to new rules. Compared to how other linters do it:
- Feature: errors vs. warnings would not be preserved
- Use case: marking rules as not ready to cause errors yet would still be supported
Building a first-class gradual onboardings system into the linter would give more granular control over the rule onboarding process. The new system would, I think, actually be better at supporting the use case by replacing, rather than porting, the specific feature.
Compatibility Layers
Existing userland ESLint plugins need to be consumable by the new linter. Getting developers to write -let alone maintain- a plugin for one linter is hard enough. Asking them to additionally work on one more implementation for each new linter is a non-starter.
If I wrote a linter, it would have a compatibility layer for ESLint plugins. It would allow you to install an ESLint plugin and directly use its configs and rules in your lint configuration.
Here’s a rough sketch of how it might look for a hypothetical @joshuakgoldberg/eslint-plugin
:
// if-i-wrote-a-linter.config.ts
import { defineConfig, ts } from "@joshuakgoldberg/linter";
import { wrapPlugin } from "@joshuakgoldberg/linter-eslint";
import { joshuakgoldberg } from "@joshuakgoldberg/eslint-plugin";
export default defineConfig({
use: [
{
glob: "**/*.ts",
rules: [
ts.configs.logical,
ts.configs.stylistic,
wrapPlugin(joshuakgoldberg.configs.recommended),
],
},
],
});
ESLint’s compatibility utilities are a good reference of prior art for compatibility layers.
Configuration Migration
Existing user configuration files need to be automatically portable to the new linter’s format. Getting developers to maintain their configuration files for one linter -let alone understand mapping between multiple linters- is hard enough. Asking them to manually rewrite a configuration file in a new format is a non-starter.
ESLint’s configuration migrator and tslint-to-eslint-config are a good references of prior art for migration tooling. I’m particularly fond of how tslint-to-eslint-config aggressively suggests developers rethink how their configuration files are structured:
If I wrote a linter, it would provide a configuration migrator utility for ESLint configuration files. It would take an existing ESLint configuration file and output as close an approximation in the new linter configuration file as possible.
It would also provide flags for whether to adopt practices recommended by the new linter:
- Adding any plugins from the plugin registry relevant to existing project dependencies
- Enabling the linter’s gradual onboardings system for rules previously set to warn
- If formatting rules were used, remove them and instead coordinate a formatter 9
- Using the recommended configs from the linter and any enabled plugins
Those flags would allow the migration tool to be used as more than just a single-shot “closest possible equivalent” tool. It would also help users migrate to best practices and more powerful linter configurations.
Up Next
Ecosystem work is hard. It requires a lot energy and time. I think the ideas in this blog post are a nice blend of the areas, and I’d love to see them tried out in the wild.
Next week Later this month will see the final entry in this blog post series.
It’ll summarize the entire series, answer FAQs, and provide a few final thoughts.
I hope you enjoyed this post and the series so far!
💡 This post is the third in a series:
- Part 1: Architecture
- Part 2: Developer Experience
- 👉 Part 3: Ecosystem
- Part 4: Summary (coming soon)
Footnotes
-
dustinspecker/awesome-eslint#245 fix: remove deprecated and 404 links: I noticed over a dozen archived or dead links in the list while working on this blog post. ↩
-
Lintbase was a project that aimed to do what I’m proposing. ↩
-
If I Wrote a Linter, Part 2: Developer Experience > Only Errors ↩
-
If I Wrote a Linter, Part 1: Architecture > Formatting Coordination ↩