How linters & formatters work
Learn how ESLint and Prettier work: rules as visitors, scope analysis, writing a custom rule with a fix, Prettier's doc format, and type-aware linting.
- 01Read and write lint rulesExplain how ESLint runs a rule's visitor, uses scope analysis, and applies fixes, and write a rule of your own.
- 02Explain how Prettier decidesDescribe Prettier's doc format, and why a group stays on one line or breaks.
- 03Know when types helpTell which problems need a type-aware rule, and what it costs.
What linters and formatters do
A linter reads your code and reports problems, such as a variable you never use. A formatter prints your code again with a consistent layout, and never changes what it does. Both start by parsing the code into a tree.
import { Linter } from "eslint/universal";
const linter = new Linter();
const code = "let total = 0;\nlet unused = 1;\nif (total == 0) report(total);";
const messages = linter.verify(code, {
rules: { "no-unused-vars": "error", "no-undef": "error", eqeqeq: "error" },
});
for (const message of messages) {
console.log(message.line + ":" + message.column, message.ruleId);
}Line 3 makes ESLint's Linter, the part that lints one piece of code. Lines 5 to 7 run three of ESLint's built-in rules on the code from line 4. Line 9 prints where each problem is, and which rule found it.
It prints 2:5 no-unused-vars for unused, 3:11 eqeqeq for the ==, and 3:17 no-undef for report, which was never declared. Each one is line:column.
Read each row of the table as: "this tool is this kind of tool, and works like this."
| Tool | What it is | How it works |
|---|---|---|
| ESLint | Linter | JavaScript. Parses with Espree by default, or with typescript-eslint's parser for TypeScript. |
| Prettier | Formatter | JavaScript. Parses, builds a doc, and prints it. |
| Biome | Linter and formatter | Rust. One tool for both jobs. |
| Oxlint | Linter | Rust, built on the Oxc parser. |
A book goes through two people. The proofreader reads for mistakes, using the publisher's style guide, and marks them. The typesetter decides where lines break and how the page looks, but never changes a word. Linters and formatters split the work the same way.
- In real life: A proofreader marks mistakes
- In JavaScript: A linter reports problems
- In real life: A house style guide
- In JavaScript: The rules you turn on
- In real life: A typesetter lays out the page
- In JavaScript: A formatter prints the layout
- In real life: The typesetter never changes a word
- In JavaScript: A formatter never changes what code does
A linter walks the tree with rules that report problems. A formatter builds a layout from the tree and prints it. Neither runs your code.
A rule is a visitor
An ESLint rule is an object with a create function. It returns a visitor, as in the codemods lesson. ESLint parses the code once, walks the tree once, and calls every rule's functions as it goes. A key can also end in :exit, to run when the walk leaves a node.
import { Linter } from "eslint/universal";
const found = [];
const spy = {
create() {
return {
CallExpression(node) {
if (node.callee.name === "alert") found.push("alert call on line " + node.loc.start.line);
},
"FunctionDeclaration:exit"(node) {
found.push("left " + node.id.name);
},
};
},
};
const code = 'function warn() {\n alert("careful");\n}\nwarn();';
new Linter().verify(code, { plugins: { demo: { rules: { spy } } }, rules: { "demo/spy": "error" } });
console.log(found.join("\n"));Line 7 runs for every call, and line 8 keeps the calls to alert. Line 10 adds :exit, so it runs as the walk leaves each function, after everything inside it has been visited.
Line 18 lints the code from line 17, and line 19 prints alert call on line 2, then left warn: the call inside the function comes first.
Keys can also be selectors, which pick nodes the way CSS selectors pick elements. CallExpression[callee.name='alert'] would do line 8's check for you. Selectors need ESLint's selector parser, which the esm.sh build used by this page can't load, so the example checks the name itself.
Read each row of the table as: "this part of a rule is used for this."
| Part | What it is for |
|---|---|
meta.messages | The messages the rule can report, each with an ID. |
meta.fixable | "code" when the rule can fix what it reports. |
create(context) | Returns the visitor: a function for each node type or selector. |
context.report({ … }) | Reports a problem at a node, with a message and an optional fix. |
context.sourceCode | The code's text and tokens, and getScope(node). |
fixer.replaceText(nodeOrToken, text) | A fix that replaces that node's or token's text. |
Scope analysis: variables and references
Some rules need more than the tree. To know whether a variable is used, ESLint must know which declaration each name belongs to. Before the rules run, it does scope analysis: it finds every scope, every variable in it, and every reference, a place where a name is read or written.
import { Linter } from "eslint/universal";
const report = [];
const showScope = {
create(context) {
return {
"Program:exit"(node) {
const scope = context.sourceCode.getScope(node);
for (const variable of scope.variables) {
if (variable.defs.length > 0) report.push(variable.name + ": " + variable.references.length + " references");
}
for (const reference of scope.through) report.push("not declared: " + reference.identifier.name);
},
};
},
};
new Linter().verify("let cups = 2;\ncups += 1;\nshow(cups);", {
languageOptions: { sourceType: "script" },
plugins: { demo: { rules: { "show-scope": showScope } } },
rules: { "demo/show-scope": "error" },
});
console.log(report.join("\n"));Line 7 runs when the walk leaves the program. Line 8 asks for its scope. Line 10 lists the variables declared in the code, with how many references each has. Line 12 lists scope.through: references that no declaration in this scope matched.
Line 18 lints three lines, as a classic script. It prints cups: 3 references, one for each line, and not declared: show.
cups has 3 references. show was never declared, so it falls through the scope, which is what no-undef reports.That is exactly how two built-in rules work. no-unused-vars reports variables that are never read. no-undef reports references that fall through every scope. The scope analysis lesson shows how engines build the same scopes.
Writing a custom lint rule
Teams write their own rules for their own conventions. Here is one that reports calls to an old function, and can fix them.
import { Linter } from "eslint/universal";
const noFetchUser = {
meta: {
type: "suggestion",
fixable: "code",
messages: { renamed: "{{name}} was renamed to getUser." },
},
create(context) {
return {
CallExpression(node) {
if (node.callee.type !== "Identifier" || node.callee.name !== "fetchUser") return;
context.report({
node,
messageId: "renamed",
data: { name: node.callee.name },
fix: (fixer) => fixer.replaceText(node.callee, "getUser"),
});
},
};
},
};
const linter = new Linter();
const config = { plugins: { shop: { rules: { "no-fetch-user": noFetchUser } } }, rules: { "shop/no-fetch-user": "error" } };
const code = "fetchUser(7);\napi.fetchUser(8);";
for (const message of linter.verify(code, config)) console.log(message.line + ":" + message.column, message.message);
console.log(linter.verifyAndFix(code, config).output);Lines 4 to 8 are the rule's meta. fixable: "code" says it can fix what it finds, and messages holds its message, with a {{name}} placeholder.
Line 12 skips anything that isn't a plain call to fetchUser. Line 13 reports the call. Line 16 fills in the placeholder, and line 17 offers a fix that replaces the callee's text.
Line 25 gives the rule a plugin name, shop, and turns it on. Line 27 prints 1:1 fetchUser was renamed to getUser.. Line 28 applies the fixes and prints getUser(7); and api.fetchUser(8);. The second call has a different shape, so the rule doesn't touch it.
ESLint's RuleTester takes valid code that must not be reported, and invalid code with the errors and fixed output you expect, just like the fixtures in the codemods lesson.
How Prettier prints: the doc format
Prettier doesn't keep any of your layout. It parses the code, then turns the tree into a doc: a description of the code with marks for where it may break. Only then does a printer decide the lines, using printWidth.
import { doc } from "prettier/standalone";
const { group, indent, line, softline, join } = doc.builders;
const call = group([
"show(",
indent([softline, join([",", line], ["cupsOfTea", "slicesOfCake"])]),
softline,
")",
]);
for (const printWidth of [40, 20]) {
console.log(doc.printer.printDocToString(call, { printWidth, tabWidth: 2 }).formatted);
}Lines 4 to 9 build a group. Inside it, softline means "nothing, or a new line", and line means "a space, or a new line". indent on line 6 indents any new lines inside it.
Line 12 prints it at two widths. At 40 the group fits, so it prints show(cupsOfTea, slicesOfCake). At 20 it doesn't, so every line in the group breaks, and it takes four lines.
A replay of the example code, not a view inside a JavaScript engine. A small copy of Prettier's printer runs for real; the tests check it prints what Prettier prints.
script
import { doc } from "prettier/standalone"; const { group, indent, line, softline, join } = doc.builders; "show(", indent([softline, join([",", line], ["cupsOfTea", "slicesOfCake"])]), softline, ")",]); for (const printWidth of [40, 20]) { console.log(doc.printer.printDocToString(call, { printWidth, tabWidth: 2 }).formatted);}Press Step to watch the printer measure the group, then break it line by line. This replays the example code; it isn't a view inside the JavaScript engine.
softline and line inside it becomes a new line.Prettier builds docs like this for every node. Here it formats a messy line of real code:
import { format } from "prettier/standalone";
import * as babel from "prettier/plugins/babel";
import * as estree from "prettier/plugins/estree";
const code = "const order={cups:2,tea:'green'};if(order.cups>1){serve(order)}";
console.log(await format(code, { parser: "babel", plugins: [babel, estree] }));Line 6 formats the code from line 5. It prints four lines: spaces around = and inside the braces, double quotes, a semicolon after serve(order), and the if body on its own line. Prettier ends the code with a line break.
Type-aware linting
Some problems can't be seen in one file's tree. In the code below, nothing says that save returns a promise, except its type.
async function save(order: { cups: number }): Promise<void> {
if (order.cups > 10) throw new Error("too many cups");
}
export function checkout() {
save({ cups: 2 });
return "done";
}Line 1 returns a Promise, and line 2 can throw. Line 6 calls save without await, so if it throws, nobody hears about it: it is a floating promise.
typescript-eslint's no-floating-promises rule reports line 6, column 3: "Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the `void` operator."
save({ cups: 2 }) is just a call. Only TypeScript's type checker knows it returns a promise that nobody awaits, so only a type-aware rule can report it.To get the types, the rule asks TypeScript. You turn that on in your ESLint config:
// eslint.config.js
import js from "@eslint/js";
import { defineConfig } from "eslint/config";
import tseslint from "typescript-eslint";
export default defineConfig(
js.configs.recommended,
tseslint.configs.recommendedTypeChecked,
{
languageOptions: {
parserOptions: { projectService: true, tsconfigRootDir: import.meta.dirname },
},
},
);Line 8 adds typescript-eslint's rules that need types. Line 11's projectService: true asks TypeScript for each file's types, using your tsconfig.json.
Without it, ESLint refuses to run these rules, with an error that you have used a rule which requires type information. The price is speed: TypeScript must understand the whole project, so typed linting is slower than normal linting.
Linters and formatters together
Pick a printWidth and edit the code. The playground formats it with the real Prettier, and shows the doc Prettier built for it.
formatting…Formatting…
group, indent, lineFormatting…
Each group stays on one line if it fits in the width that is left, and breaks if not. A narrower width breaks more groups, starting with the outermost.
At 80, the serve call fits on one line. At 50, its object breaks. At 30, the order object breaks too. In the doc, look for group, indent and line: they are the same building blocks as above.
How teams usually set them up:
- Let the formatter own layout. eslint-config-prettier turns off ESLint rules that are unnecessary or might conflict with Prettier.
- Run Prettier when you save, and ESLint in your editor, so problems show while you type.
- Run both in continuous integration, and fail the build on lint errors.
- Turn on type-aware rules where promises and types matter most, and accept the slower lint.
- A variable that is never used
- A name that was never declared
==where the team wants===- A line longer than 80 characters
- Single quotes where the team uses double
- A promise that nobody awaits
awaiton a value that isn't a promise- A price that adds the tax twice
Put each problem with what catches it: a lint rule, the formatter, a type-aware rule, or only a test.
Common mix-ups
Most mix-ups come from mixing up the two jobs: finding problems and laying out code.
- "A formatter finds bugs." No. It only changes layout. A linter or a test finds bugs.
- "Linters search the text." No. They walk the tree, and many rules use scope analysis.
- "Prettier keeps your line breaks." Mostly no. It builds a new layout from the tree; it keeps only a few things, such as blank lines between statements.
- "Every rule can be fixed automatically." No. Only rules with
fixableoffer fixes, and many problems need a person to decide. - "Type-aware rules work like any other rule." No. They need the TypeScript project and are slower.
The table below puts three of these side by side. Read each row as: "people often think this, but this is true."
| Topic | What people think | What is true |
|---|---|---|
| A formatter | It finds bugs. | It only changes layout. A linter or a test finds bugs. |
| no-unused-vars | It searches the text for the name. | It counts references from scope analysis, so another variable with the same name doesn't count. |
| Type-aware rules | They work like any other rule. | They need TypeScript's type checker for the whole project, so they are slower and need extra config. |
Practice exercises
Most exercises run ESLint or Prettier for real. Guess first, then run it.
What does line 5 print?
import { Linter } from "eslint/universal";
const code = "const a = 1;\nlet b = 2;\nconsole.log(a);";
const messages = new Linter().verify(code, { rules: { "prefer-const": "error", "no-unused-vars": "error" } });
console.log(messages.map((message) => message.line + ":" + message.ruleId).join(" "));import { Linter } from "eslint/universal";
const code = "const a = 1;\nlet b = 2;\nconsole.log(a);";
const messages = new Linter().verify(code, { rules: { "prefer-const": "error", "no-unused-vars": "error" } });
console.log(messages.map((message) => message.line + ":" + message.ruleId).join(" "));prefer-const reports b because it never changes, and no-unused-vars reports it because it's never read. Both are on line 2.
What is the first line printed?
import { Linter } from "eslint/universal";
const report = [];
const showScope = {
create(context) {
return {
"Program:exit"(node) {
const scope = context.sourceCode.getScope(node);
for (const variable of scope.variables) {
if (variable.defs.length > 0) report.push(variable.name + ": " + variable.references.length + " references");
}
for (const reference of scope.through) report.push("not declared: " + reference.identifier.name);
},
};
},
};
new Linter().verify("let n = 1;\nn = n + 1;\nlog(n);", {
languageOptions: { sourceType: "script" },
plugins: { demo: { rules: { "show-scope": showScope } } },
rules: { "demo/show-scope": "error" },
});
console.log(report.join("\n"));import { Linter } from "eslint/universal";
const report = [];
const showScope = {
create(context) {
return {
"Program:exit"(node) {
const scope = context.sourceCode.getScope(node);
for (const variable of scope.variables) {
if (variable.defs.length > 0) report.push(variable.name + ": " + variable.references.length + " references");
}
for (const reference of scope.through) report.push("not declared: " + reference.identifier.name);
},
};
},
};
new Linter().verify("let n = 1;\nn = n + 1;\nlog(n);", {
languageOptions: { sourceType: "script" },
plugins: { demo: { rules: { "show-scope": showScope } } },
rules: { "demo/show-scope": "error" },
});
console.log(report.join("\n"));n is written on line 1, written and read on line 2, and read on line 3: 4 references. log is never declared.
Fill in the visitor on line 8. What does your program print?
import { Linter } from "eslint/universal";
const noAlert = {
meta: { messages: { toast: "Use showToast instead of alert." } },
create(context) {
return {
CallExpression(node) {
// Report calls to alert(...) with the "toast" message.
},
};
},
};
const code = 'alert("saved");\nshowToast("saved");\nwindow.alert("hi");';
const messages = new Linter().verify(code, { plugins: { ui: { rules: { "no-alert": noAlert } } }, rules: { "ui/no-alert": "error" } });
console.log(messages.length, messages.map((message) => message.line).join(" "));import { Linter } from "eslint/universal";
const noAlert = {
meta: { messages: { toast: "Use showToast instead of alert." } },
create(context) {
return {
CallExpression(node) {
if (node.callee.type === "Identifier" && node.callee.name === "alert") context.report({ node, messageId: "toast" });
},
};
},
};
const code = 'alert("saved");\nshowToast("saved");\nwindow.alert("hi");';
const messages = new Linter().verify(code, { plugins: { ui: { rules: { "no-alert": noAlert } } }, rules: { "ui/no-alert": "error" } });
console.log(messages.length, messages.map((message) => message.line).join(" "));Only alert("saved") on line 1 is a plain call to alert. window.alert has a MemberExpression callee, so it prints 1 1.
The rule reports == but can't fix it. Change line 9 so it offers a fix. What is the first line your program prints?
import { Linter } from "eslint/universal";
const strict = {
meta: { fixable: "code", messages: { strict: "Use === instead of ==." } },
create(context) {
return {
BinaryExpression(node) {
if (node.operator !== "==") return;
context.report({ node, messageId: "strict" });
},
};
},
};
const code = "if (a == b) go();\nif (c === d) stop();";
const config = { plugins: { team: { rules: { strict } } }, rules: { "team/strict": "error" } };
console.log(new Linter().verifyAndFix(code, config).output);import { Linter } from "eslint/universal";
const strict = {
meta: { fixable: "code", messages: { strict: "Use === instead of ==." } },
create(context) {
return {
BinaryExpression(node) {
if (node.operator !== "==") return;
const token = context.sourceCode.getFirstTokenBetween(node.left, node.right, (t) => t.value === "==");
context.report({ node, messageId: "strict", fix: (fixer) => fixer.replaceText(token, "===") });
},
};
},
};
const code = "if (a == b) go();\nif (c === d) stop();";
const config = { plugins: { team: { rules: { strict } } }, rules: { "team/strict": "error" } };
console.log(new Linter().verifyAndFix(code, config).output);verifyAndFix applies the fix, so a == b becomes a === b. The second line already uses ===, so it isn't reported.
How many lines does the second print, at width 6, take?
import { doc } from "prettier/standalone";
const { group, indent, line, softline, join } = doc.builders;
const list = group(["[", indent([softline, join([",", line], ["1", "2", "3"])]), softline, "]"]);
console.log(doc.printer.printDocToString(list, { printWidth: 20, tabWidth: 2 }).formatted);
console.log(doc.printer.printDocToString(list, { printWidth: 6, tabWidth: 2 }).formatted);import { doc } from "prettier/standalone";
const { group, indent, line, softline, join } = doc.builders;
const list = group(["[", indent([softline, join([",", line], ["1", "2", "3"])]), softline, "]"]);
console.log(doc.printer.printDocToString(list, { printWidth: 20, tabWidth: 2 }).formatted);
console.log(doc.printer.printDocToString(list, { printWidth: 6, tabWidth: 2 }).formatted);At width 6 the group breaks: [, then 1, 2 and 3 on their own indented lines, then ]. That is 5 lines.
This prints done before saved. Fix checkout so it waits for save. What does your program print, in order?
async function save() {
await null;
console.log("saved");
}
function checkout() {
save();
console.log("done");
}
checkout();async function save() {
await null;
console.log("saved");
}
async function checkout() {
await save();
console.log("done");
}
checkout();Awaiting save makes checkout wait for it, so saved prints before done. This is the problem no-floating-promises reports.
Check your understanding
For each question, ask whether it is about finding problems or about layout, and what information the tool needs. Each answer explains why it's right or wrong.
Question 1 of 6What does an ESLint rule's
create(context)return?Choose an answer to see the explanation.
Question 2 of 6How does a group decide here?
Read the code, then predictconst flat = "show(cupsOfTea, slicesOfCake)"; for (const printWidth of [40, 20]) { console.log(printWidth, flat.length <= printWidth ? "flat" : "break"); }Choose an answer to see the explanation.
Question 3 of 6Which names would
no-undefreport, in this model?Read the code, then predictconst declared = new Set(["cups", "total"]); const used = ["cups", "show", "total", "cups"]; console.log(used.filter((name) => !declared.has(name)).join(" "));Choose an answer to see the explanation.
Question 4 of 6Why does
no-unused-varsneed scope analysis, not a text search?Choose an answer to see the explanation.
Question 5 of 6Why are type-aware rules slower?
Choose an answer to see the explanation.
Question 6 of 6What does eslint-config-prettier do?
Choose an answer to see the explanation.
Key takeaways
Linters and formatters both read your code as a tree, then do two different jobs with it.
- A lint rule is a visitor; ESLint walks the tree once and calls every rule.
- Selectors pick nodes by shape, and
:exitruns on the way out. - Scope analysis links every reference to its variable;
no-unused-varsandno-undefdepend on it. - A rule reports with
context.report, and can offer a fix. - Prettier turns the tree into a doc; each group is printed on one line if it fits, or broken if not.
- Type-aware rules ask TypeScript for types, which finds more problems but is slower.
Remember the one-liner.
Linters walk the tree to find problems; formatters turn it into a doc and print what fits.
Coming next: Source maps in depth. They map minified and compiled code back to the lines you wrote.