cf.completefrontend
Open lab My progress
THE JAVASCRIPT FIELD GUIDE

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.

By the end, you can
  • 01
    Read and write lint rulesExplain how ESLint runs a rule's visitor, uses scope analysis, and applies fixes, and write a rule of your own.
  • 02
    Explain how Prettier decidesDescribe Prettier's doc format, and why a group stays on one line or breaks.
  • 03
    Know 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.

Run ESLint on a few linesPop out in the code editor (opens in a new tab)JavaScript
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.

A card your code goes into a function card parse, which gives a card AST. From the AST, one arrow goes to a function card lint rules, giving problems. Another goes to a function card printer, giving formatted code.codeparseASTlint rulesprinterproblems, and fixesformatted code
A linter and a formatter both parse your code into a tree. Then the linter's rules look for problems, and the formatter prints the tree again with its own layout.

Read each row of the table as: "this tool is this kind of tool, and works like this."

Linters and formatters you will meet
ToolWhat it isHow it works
ESLintLinterJavaScript. Parses with Espree by default, or with typescript-eslint's parser for TypeScript.
PrettierFormatterJavaScript. Parses, builds a doc, and prints it.
BiomeLinter and formatterRust. One tool for both jobs.
OxlintLinterRust, built on the Oxc parser.
Real-life analogyA proofreader and a typesetter

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
The main idea

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.

A rule that visits on the way in and outPop out in the code editor (opens in a new tab)JavaScript
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."

The parts of an ESLint rule
PartWhat it is for
meta.messagesThe 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.sourceCodeThe 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.

Look at a scope from inside a rulePop out in the code editor (opens in a new tab)JavaScript
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.

A box labeled global scope holds a tag cups. Three cards point at the tag: let cups = 2, cups += 1, and show(cups). Outside the box, a card show is labeled through: not declared.cupsglobal scopelet cups = 2cups += 1show(cups)3 referencesshownot declared
Scope analysis links every use of a name to where it was declared. 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.

A custom rule with a fixPop out in the code editor (opens in a new tab)JavaScript
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.

Test rules like transforms

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.

Build a doc and print it twicePop out in the code editor (opens in a new tab)JavaScript
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.

Step through printing one group at two widths
Step 0 of 9Ready
Your turn: follow the blue line

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.

Running in
  1. script
Next: line 4
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);}
CallStoreChangeResultBlue line: runs next. Ran: already executed.
Recent returnsNothing yet. Start with the blue line.
A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.

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.

A card group needs 29 columns. At width 40 it fits: one line, show(cupsOfTea, slicesOfCake). At width 20 it breaks: show(, then cupsOfTea, and slicesOfCake on their own indented lines, then ).group: 29 widewidth 40width 20show(cupsOfTea, slicesOfCake)fits: one lineshow(cupsOfTea,slicesOfCake)breaks
A group is printed on one line if it fits in the space left. If not, it breaks: each 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:

Format code with PrettierPop out in the code editor (opens in a new tab)JavaScript
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.

A promise nobody waits forTypeScript
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."

A card save({ cups: 2 }) points two ways. Without types: just a call, labeled looks fine. With TypeScript's types: returns Promise void, labeled floating promise.save({ cups: 2 })without typeswith typesa callPromise<void>looks finefloating promise
On its own, 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:

Turning on typed lintingJavaScript
// 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.

Playground: watch Prettier break lines
Prettier's outputformatting…

Formatting…

The doc Prettier builtgroup, indent, line

Formatting…

Try it yourself
printWidth 50: formatting….

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.

This is the real Prettier, the same one the code editor uses to format. The doc comes from Prettier's debug helpers, which show the doc it builds before it prints your code.

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.
Who catches it?
  • 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
  • await on a value that isn't a promise
  • A price that adds the tax twice
Try it yourself
0 of 8 correct

Put each problem with what catches it: a lint rule, the formatter, a type-aware rule, or only a test.

Choose a category for every card. You can change an answer at any time; Reset clears them all.

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 fixable offer 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."

What is true about linters and formatters
TopicWhat people thinkWhat is true
A formatterIt finds bugs.It only changes layout. A linter or a test finds bugs.
no-unused-varsIt searches the text for the name.It counts references from scope analysis, so another variable with the same name doesn't count.
Type-aware rulesThey 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.

Exercise 1 · Warm-upTwo rules, one variable

What does line 5 print?

Starter codePop out in the code editor (opens in a new tab)JavaScript
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(" "));

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · Warm-upCount the references

    What is the first line printed?

    Starter codePop out in the code editor (opens in a new tab)JavaScript
    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"));

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeWrite a no-alert rule

      Fill in the visitor on line 8. What does your program print?

      Starter codePop out in the code editor (opens in a new tab)JavaScript
      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(" "));

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticeAdd a fix

        The rule reports == but can't fix it. Change line 9 so it offers a fix. What is the first line your program prints?

        Starter codePop out in the code editor (opens in a new tab)JavaScript
        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);

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengePredict a broken array

          How many lines does the second print, at width 6, take?

          Starter codePop out in the code editor (opens in a new tab)JavaScript
          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);

          Answer, then press Check. Spacing and letter case don’t matter.

            Exercise 6 · ChallengeFix a floating promise

            This prints done before saved. Fix checkout so it waits for save. What does your program print, in order?

            Starter codePop out in the code editor (opens in a new tab)JavaScript
            async function save() {
              await null;
              console.log("saved");
            }
            
            function checkout() {
              save();
              console.log("done");
            }
            
            checkout();

            Answer, then press Check. Spacing and letter case don’t matter.

              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.

              The linters and formatters quiz · 6 questionsScore: first tries count
              1. Question 1 of 6What does an ESLint rule's create(context) return?

                Choose an answer to see the explanation.

              2. Question 2 of 6How does a group decide here?

                Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
                const 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.

              3. Question 3 of 6Which names would no-undef report, in this model?

                Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
                const 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.

              4. Question 4 of 6Why does no-unused-vars need scope analysis, not a text search?

                Choose an answer to see the explanation.

              5. Question 5 of 6Why are type-aware rules slower?

                Choose an answer to see the explanation.

              6. 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 :exit runs on the way out.
              • Scope analysis links every reference to its variable; no-unused-vars and no-undef depend 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.

              CompleteFrontend Clear concepts. Working examples.