Josh Goldberg
Elsa from Frozen walking up a staircase as she's creating it with ice, singing 'Let It Go'

If I Wrote a Linter, Part 2: Developer Experience

May 9, 202530 minute read

This is how I would tailor the developer experience for a modern linter from scratch: focusing on end-to-end type safety, self-apparent configs, and native workspaces.

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 ways developers would interact with it.

💡 This post is the second in a series:


Developer Experience is Crucial

The developer experience of a linter an incredibly important part of its usability. Especially when your tool tells developers they’re “wrong” (which is how many interpret linters, sadly). Linters have have earned that bad reputation for a few reasons:

My goals with rethinking a linter’s developer experience are:

The more we make a linter accessible and friendly, the more likely developers are to embrace it.

Only Errors

All mainstream web linters today allow configuring rules as errors or warnings. Errors are generally visualized with red squigglies and fail builds; warnings are visualized with yellow squigglies and don’t fail builds.

Warnings are intended to be transient indicators during migrations or when rules aren’t certain about issues 1. In practice, I think that delineation is not worthwhile:

I think warnings are a bad fit for the migration use case. Tools like eslint-nibble and ESLint’s new Bulk Suppressions can provide a more comprehensive experience.

If I wrote a linter, I would have it so rules can only be turned off or on. All lint rule reports would be errors and visualized with yellow squigglies in errors. Gradual onboardings of new rules or rule options would be a separately managed feature.

Comprehensive Rule Reports

Rule reports are the entry point for most developers who experience a linter. The little red or yellow squiggly in an editor that shows rule text on hover is how developers learn about the report.

Linters today generally populate hover text with:

If that message includes too little text, people won’t understand the report. Too much text, and they won’t read any of it. Many rules’ reports have too much conceptual complexity or too many important nuances to fit in a brief hover message.

If I wrote a linter, rule report bodies would include something like:

Rich structured rule reports would enable rich displays in reporters that surface those errors to users.

Comprehensive Editor Reports (In Theory)

In theory, editor extensions could then visualize these nicely 2. In practice, VS Code is unfortunately still waiting on formatted diagnostic text support 3 4.

I’ve seen proof-of-concepts in extensions such as pretty-ts-errors — but they require unseemly workarounds.

Ugh.

Comprehensive CLI Reports

Terminals these days can do a lot more more than just print text in preset colors. They can display hex codes, format links as plain text, and even re-render quickly for interactive displays.

The Biome 5, TSSLint 6, and Vitest 7 CLIs do a great job of making rule reports approachable and clear. Bonus shoutouts to Orta’s tsc explorations 8 and userland Rust error reporting crates 9 10 11. I particularly like how some CLIs display only the first report per file and/or per rule, with an opt-in option to show all.

If I wrote a linter, it would include some kind of interactive rich reporter display by default. It would at least:

Here’s a very rough starting mockup:

The linter’s reporting would have to fall back to more primitive colors, or no colors at all, depending on what the running terminal supports. And maybe the Details and Focused view settings would persist per-package or per-system? There’s a lot of room for exploration here.

Comprehensive Rule Documentation

The next step for many developers who receive a lint report is the rule’s documentation page. The typescript-eslint site includes a pretty rich display for rules, similar to many other lint sites:

Each of those sections are must-haves for any lint rule — especially one in an open source linter or popular plugin.

If I wrote a linter, I would require all those sections be filled out in every core rule’s documentation. I would additionally attempt to make sure any popular plugin website templates include the sections and fill them out too.

Standardized Rule Metadata

Users like naming and stylistic standardization. Without consistent patterns, it becomes much more difficult to remember how things work consistently. Rule names, option names, option value defaults, and other strings users need to think of when using a linter are easier to remember when they’re consistent.

Unfortunately, no mainstream linter today is consistent with its choices — even internally. Community plugins often make very different and inconsistent choices as well. This lack of standardization is painful for users.

If I wrote a linter, it would standardize how rules are named and structured. The next few sections will explain the relevant questions I would standardize answers to.

Standardized Rule Categories

How do we even categorize rules? I think developers have generally settled on three categories of lint rules:

Those categories are useful for surfacing to developers why a rule would -or wouldn’t- be useful. Many developers also treat reports differently based on which category they fall into.

If I wrote a linter, I would have all core rules categorized into one of those three categories. Plugin rules would be encouraged -but not required- to use them as well.

This would allow developers to react to the metadata in their own tooling. For example, developers might want to use an equivalent of eslint.rules.customizations to downgrade stylistic rules to blue info squigglies instead of yellow warnings:

"linter.rules.customizations": [
	{ "category": "stylistic", "severity": "info" }
]

Standardized Rule Messages

The text of rule messages is pretty inconsistent across rules, even within the same linter or plugin. Older rules tend to have assertive and curt messages, such as:

I’ve found that users don’t generally react well to curt messages. They can feel like the linter saying something is absolutely wrong, even though most lint rules are sometimes wrong. There’s a reason why many linters put “When Not To Use” sections in their rule documentation pages.

Newer or more recently updated rule messages tend to be more descriptive and speak to the actual problems the rules detect, such as:

If I wrote a linter, all core rule messages would have consistent phrasing. They would never be dogmatic or overly prescriptive about what the user “should” do. They’d instead just speak to the detected potential problem in code.

Standardized Rule Names

Rule names have to make several choices:

Inconsistent answers to each of those choices lead to user confusion:

If I wrote a linter, core rule names would make one choice for each of those naming options:

For example, I would rework the following rule names roughly like:

Current Rule NameReworked Rule Name
array-callback-returnarrayCallbackReturns
ban-ts-commenttypescriptCommentDirectives
constructor-superconstructorSuper
default-case-lastdefaultCaseLast
for-directionforLoopDirections
no-await-in-looploopAwaits
no-cond-assignconditionalAssignments
no-const-assignconstAssignments
no-constant-binary-expressionconstantBinaryExpressions
no-floating-promisesfloatingPromises
no-magic-numbersmagicNumbers
prefer-constconstVariables
require-atomic-updatesatomicUpdates
restrict-plus-operandsplusOperands

I would also include rule name as a required field in its metadata. That way, downstream tooling such as documentation generators and unit testers can rely on it.

Standardized Rule Options

Rule options have to make an even broader, deeper set of choices:

What a list!

If I wrote a linter, core rule options would make the same choice consistently:

Here’s how the options mentioned in the choices list would look in my linter:

Current NameReworked NameCurrent DefaultReworked Default
(array-type)style'array'(same)
(no-restricted-component-names)names[](same)
allow(same)[{ ... }][]
allowForKnownSafeCalls(same)[](same)
allowImplicitignoreImplicitfalse(same)
booleanbooleanstruefalse
checkProperties(same)false(same)
checkThenables(same)false(same)
enforceForClassMembersignoreClassMemberstruefalse
ignoreComments(same)false(same)
ignoreIIFE(same)false(same)
ignoreVoid(same)truefalse
includeExportscheckExportsfalse(same)
requireForBlockBodycheckBlockBodiesfalse(same)
skipBlankLinesignoreBlankLinesfalse(same)

Oh, and rule options would be sorted alphabetically in their docs pages. I want to be able to O(log(N)) scan a docs page for a rule option. Not O(1) search through some arbitrary order.

Standardized Specifiers

Some rules allow specifying types or values in their options. For example, @typescript-eslint/no-restricted-types allows specifying types in its types option:

"@typescript-eslint/no-restricted-types": [
	"error",
	{
		"types": {
			"SomeType": "Don't use SomeType because it is unsafe",
		},
	},
]

The problem with using plain strings to target names is that they are ambiguous. In the example, any type named SomeType would be restricted, even if it is a different type than the one the user intended.

Over in typescript-eslint, we developed a TypeOrValueSpecifier format for specifying types or values in options. It allows users to specify not just the name but also the source -global, package, etc.- of a type or value. Here’s how that would look for specifying SomeType from a specific file:

"@typescript-eslint/no-restricted-types": [
	"error",
	{
		"types": [{
			"message": "Don't use SomeType because it is unsafe",
			"type": {
				"from": "file",
				"name": "SomeType",
				"path": "./src/legacy-types.ts",
			},
		}],
	},
]

If I wrote a linter, core rules would only ever use the TypeOrValueSpecifier format to specify types or values. Plugins would be strongly encouraged to use the format instead of ambiguous strings.

Typed Rules

My biggest gripe with linter configuration systems today is that rule options are not type-checked. They’re only validated at runtime. Mainstream linters today have you specify rules 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.

If I wrote a linter, it would allow Standard Schema descriptions for rule options, which would allow TypeScript-friendly schema validations library like Zod:

import { createRule, createPlugin } from "@joshuakgoldberg/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:

// if-i-wrote-a-linter.config.ts (partial)
({
	rules: [
		myPlugin.rules({
			"some-rule": {
				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 be verified at runtime by the schema validation library.

Typed Plugin Settings

An even less type-safe part of many current linters’ config systems 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 used a shared setting across plugins.

If I wrote a linter, I would have plugins define their own settings and settings types. They would need to use a Standard Schema validation library too. It could look something like:

import { createPlugin } from "@joshuakgoldberg/linter";
import { z } from "zod";

export const myPlugin = createPlugin({
	name: "My Plugin",
	rules: [
		// ...
	],
	settings: {
		mySetting: z.string(),
	},
});

…and in usage could look something like:

// if-i-wrote-a-linter.config.ts (partial)
import { myPlugin } from "@joshuakgoldberg/my-plugin";

myPlugin.settings({
	mySetting: "...",
});

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.

Typed Configuration Files

Linters today fall into two classifications of linter configs:

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 JSON files isn’t great if you’re not using a custom editor extension to get JSON intellisense. Plus, because they can’t use native ESM imports, the linters have to implement their own ad hoc module system.

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.

I think ESLint’s flat config has learned the hard way:

If I wrote a linter, it would have a config system that wholly leans into well-typed functions and objects. Each line of the config file would set exactly one thing and make it clear what is being set — even if you don’t understand the nuances of the config system.

Here’s a rough sketch of a starting TypeScript setup, compared to the closest ESLint equivalent:

// if-i-wrote-a-linter.config.ts
import { defineConfig, ts } from "@joshuakgoldberg/linter";

export default defineConfig({
	use: [
		{
			glob: "**/*.ts",
			rules: [ts.configs.logical, ts.configs.stylistic],
		},
	],
});

That sketch is different from the current ESLint flat config in a few ways:

Here’s a rough sketch that configures rules, ignores some files, and adds a plugin with settings:

// if-i-wrote-a-linter.config.ts
import { defineConfig, ts } from "@joshuakgoldberg/linter";
import { example } from "@joshuakgoldberg/plugin-example";

export default defineConfig({
	ignore: ["lib/"],
	use: [
		{
			exclude: ["**/*.generated.ts"],
			glob: "**/*.ts",
			rules: [
				ts.configs.logical,
				ts.configs.stylistic,
				ts.rules({
					ruleA: false,
					ruleB: true,
					ruleC: {
						someRuleOption: true,
					},
				}),
				example.configs.recommended,
				example.rules({
					ruleD: true,
				}),
				example.settings({
					mySetting: "...",
				}),
			],
		},
	],
});

That sketch shows a few more differences from the current ESLint flat config system:

I previously mentioned that I would want to support common languages in core. Here’s how the config system would look for a repository that enables logical and stylistic rules on all those languages:

// if-i-wrote-a-linter.config.ts
import { defineConfig, md, json, ts, yml } from "@joshuakgoldberg/linter";

export default defineConfig({
	use: [
		{
			glob: "**/*.ts",
			rules: [ts.configs.logical, ts.configs.stylistic],
		},
		{
			glob: "**/*.json",
			rules: [json.configs.recommended],
		},
		{
			glob: "**/*.md",
			rules: [md.configs.recommended],
		},
		{
			glob: "**/*.yml",
			rules: [yml.configs.recommended],
		},
	],
});

I don’t love that my linter sketch has so many more lines than the ESLint equivalent. They have fewer overall characters, and I think they’re worth it for clarity, but it’s a tradeoff.

Another downside of this proposed approach is that it requires the user to know and manage their file extensions themselves. Knowing that TypeScript files can also be *.cts, *.mts, *.tsx, etc. is a bit of a pain. Maybe plugins such as the core linter plugins could provide globs constants?

import { defineConfig, md, json, ts, yml } from "@joshuakgoldberg/linter";

export default defineConfig({
	use: [
		{
			glob: ts.globs.all,
			rules: [ts.configs.logical, ts.configs.stylistic],
		},
	],
});

…and what about rules that should be different across different files? Maybe some TypeScript rules skip or apply different logic to .d.ts files 17?

I suspect having the rules react to the extension of the file being linted would be enough. A rule can skip linting a file based on its file extension, or really any other information it has on the file. But we’ll see.

Native Workspace Support

Larger web repositories these days are often structured as “monorepos”, often using the “workspace” feature of their package manager and other tools. Workspaces are particularly useful when different packages in the same repository use some very different frameworks.

If I wrote a linter, I would want native support for workspaces. I think a root-level lint config file should be able to define common rules and settings for all workspaces. It should also be able to define where the linter should look for workspace-specific config files. Then, each workspace could define its own config file that extends the root config and overrides any settings it wants.

Here’s a rough sketch of what the root monorepo config could like with a config system inspired by Vitest workspaces:

// if-i-wrote-a-linter.config.ts
import { defineConfig, ts } from "@joshuakgoldberg/linter";

export default defineConfig({
	use: [
		{
			glob: "**/*.ts",
			rules: [ts.configs.logical, ts.configs.stylistic],
		},
	],
	workspaces: ["packages/*"],
});

Each packages/* directory could have its own config that explicitly inherits from the root config:

// packages/example/if-i-wrote-a-linter.config.ts
import { defineConfig } from "@joshuakgoldberg/linter";
import { example } from "@joshuakgoldberg/plugin-example";

export default defineConfig({
	from: "../..",
	use: [
		{
			exclude: ["**/*.generated.ts"],
			glob: "**/*.ts",
			rules: [
				example.configs.recommended,
				example.rules({
					ruleD: true,
				}),
				example.settings({
					mySetting: "...",
				}),
			],
		},
	],
});

Inline Snapshot Unit Tests

I like the design of ESLint’s RuleTester (and typescript-eslint’s RuleTester extension). RuleTester’s API allows for clear, succinct descriptions of many isolated test cases at once. I prefer it over the TSLint *.ts.lint test files that put all test cases into one big file namespace.

Here’s an example of a RuleTester test for a TypeScript rule:

import { RuleTester } from "eslint";
import rule from "./preferConst.js";

const ruleTester = new RuleTester();

ruleTester.run("prefer-const", rule, {
	invalid: [
		{
			code: `let value = 123;`,
			output: `const value = 123;`,
			errors: [
				{
					column: 5,
					line: 1,
					data: { name: "value" },
					messageId: "preferConst",
				},
			],
		},
	],
	valid: [
		`const value = 123;`,
		`
			let value = 123;
			value = 456;
		`,
	],
});

I don’t love that we have to specify the rule name manually. That’s easily solvable by Standardized Rule Names’s note of making that a required rule property.

I also don’t love that the RuleTester class is packaged with the linter itself. Doing so is convenient for plugin developers, but it means the code bloats users’ package installations.

My biggest gripes with RuleTester are around the errors array:

What I really want here is the best of three worlds:

If I wrote a linter, it would provide:

Here’s how that could look in practice:

import { RuleTester } from "@joshuakgoldberg/rule-tester";
import rule from "./preferConst.js";

const ruleTester = new RuleTester();

ruleTester.run(rule, {
	invalid: [
		{
			code: `let value = 123;`,
			output: `const value = 123;`,
			report: `
				let value = 123;
				    ^^^^^
				    [preferConst] Prefer using 'const' for variables that are never reassigned.
			`,
		},
	],
	valid: [
		`const value = 123;`,
		`
			let value = 123;
			value = 456;
		`,
	],
});

(I’d use ~, not ^, for squigglies, but right now they break syntax highlighting in my MDX extension (known bug)…)

report would only show the reported line, or the first and last lines if a multi-line report.

I’ll talk more in the next post in this series about the first-party plugin and other ecosystem tooling.

Up Next

This post ballooned in size from what I thought it would be. There are a lot of fun ideas in here and I’m not sure how many of them are practically doable. But I think it’d be great to achieve the stated goals for an approachable, clear, and friendly linting user experience.

Next week I’ll post a next entry in this series that focuses on ecosystem work. If these first two posts were of interest to you I think you’ll be interested in that one too!

💡 This post is the second in a series:


Acknowledgements

Thanks in particular to Kirk Waiblinger, fellow typescript-eslint team member, for very helpful advice on the config file format. Kirk ideated preset glob values and helped with balancing clarity of language configurations with its verbosity.

Additional thanks to Arend van Beelen, Dimitri Mitropoulos (Michigan TypeScript), and @tonywu6.org for suggesting references of CLI prior art. Dimitri also helped ideate the rich CLI output format and what to show in the different views.

Footnotes

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

  2. microsoft/vscode vscode.d.ts: DecorationOptions > hoverMessage

  3. microsoft/vscode-eslint#1761 Support for markdown in the rule’s message report

  4. yoavbls/pretty-ts-errors > docs/hide-original-errors.md

  5. Biome no-aria-unsupported-elements Examples > Invalid

  6. johnsoncodehk/tsslint#50 Include screenshot/gif of the CLI somewhere in this repo?

  7. Vitest Command Line Interface

  8. Microsoft/TypeScript#49668 Compiler CLI output format update v2

  9. zesterer/ariadne

  10. brendanzlab/codespan

  11. zkat/miette

  12. Suggestion: standardize against prefixing rule names with “no-”

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

  14. eslint/eslint#18385 Change Request: Make it easier to inherit flat configs from the repo root #18385

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

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

  17. typescript-eslint/typescript-eslint#7941 Base rule extension: no-var configuration for declarations

  18. Vitest Inline Snapshots


Liked this post? Thanks! Let the world know: