Linting and formatting

Intermediate50 minNode.js 22.13+

Catch likely bugs and settle style arguments automatically with ESLint's flat config, Prettier and Biome, plus the zero-install syntax check of node --check.

What you will learn
  • Explain the difference between a linter, a formatter and a syntax check
  • Write a small ESLint flat config and read and fix its reports
  • Format code with Prettier or Biome and check formatting in CI
  • Wire lint and format commands into package.json scripts
  • Recognise the bugs that rules such as eqeqeq, no-var and no-undef protect against
On this page

Two kinds of mistakes survive a quick read of your own code. Likely bugs: a variable declared and never used, a name with a typo, == where === was meant. And inconsistency: single quotes in one file and double in the next, wandering indentation, a line that runs off the screen. A program can find both, so a team should not spend its attention on them.

  • A linter reads your code without running it and reports patterns that are probably wrong. It knows rules (“no unused variables”), and you choose which are on and how serious each is. The standard linter for JavaScript is ESLint.
  • A formatter rewrites the layout of your code into one fixed style and never changes what the program does. Style is decided once, by the tool. The common formatters are Prettier and Biome.

A third tool is already on your machine: Node can check that a file is valid JavaScript without running it. And a type checker, the subject of the next lesson, finds a different class of mistakes.

The rules exist because of real bugs you have met: loose comparison with == (equality), a var that leaks out of its block (let and const), and a typo in a name, which is only a ReferenceError when that line finally runs. A linter reports all three before the program starts.

The built-in check: node –check

node --check file.mjs parses a file and reports a syntax error without running anything. It finds only mistakes that make the code unparseable, such as a missing bracket, but it needs no installation. The exit status is 0 for good syntax and 1 for an error. The two files are good.mjs, a correct line, and bad.mjs, which has a stray ; inside the parentheses.

ll_check/bad.mjs
const total = (1 + ;

The real error message includes a stack line that changes between Node versions, so the listing keeps its first five lines (your terminal shows the full path of the file):

Terminal window
$ node --check good.mjs; echo $?
0
$ node --check bad.mjs 2>&1 | head -5
bad.mjs:1
const total = (1 + ;
^
SyntaxError: Unexpected token ';'
$ node --check bad.mjs 2>/dev/null; echo $?
1

ESLint

The rest of this lesson uses tools from npm, which need a network. Their outputs come from real runs on 17 October 2026 on Node 26.11.1, with ESLint 10.13.0, Prettier 3.9.10 and Biome 2.5.15. Versions move fast, so check each tool’s own documentation.

ESLint 10 declares that it needs Node 20.19, 22.13 or later 22.x, or 24 and newer, so on Node 22 it does not install below 22.13 (that is why this lesson’s minVersion is 22.13). Install it as a development dependency, with its recommended rules and a list of the global names that Node provides:

Terminal window
npm install --save-dev eslint @eslint/js globals prettier

This is the project’s package.json afterwards. The three scripts were written by hand, and npm added devDependencies.

ll_proj/package.json
{
"name": "ll-proj",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"lint": "eslint .",
"format": "prettier --write .",
"check": "prettier --check . && eslint ."
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"eslint": "^10.13.0",
"globals": "^17.13.0",
"prettier": "^3.9.10"
}
}

ESLint is configured by a flat config file at the root, named eslint.config.mjs. The flat config has been the default since ESLint 9, and ESLint 10 no longer loads the old .eslintrc files. The file exports an array of configuration objects, each saying which files it applies to and what to do with them.

ll_proj/eslint.config.mjs
import { defineConfig } from "eslint/config";
import js from "@eslint/js";
import globals from "globals";
export default defineConfig([
{ ignores: ["dist/"] },
{
files: ["**/*.{js,mjs}"],
extends: [js.configs.recommended],
languageOptions: { globals: globals.node },
rules: {
eqeqeq: "error",
"no-var": "error",
"prefer-const": "warn",
},
},
]);
  • An object with only ignores is a global ignore list: ESLint will not look at those paths at all.
  • files chooses the files the rest of the object applies to.
  • extends pulls in a ready-made set of rules. js.configs.recommended holds the rules for things that are almost certainly mistakes, such as no-unused-vars and no-undef.
  • languageOptions.globals says which global names exist. Without globals.node, process would be reported as undefined.
  • rules sets each rule to "off", "warn" or "error". eqeqeq, no-var and prefer-const are not in the recommended set, so this config turns them on.

Here is a file with several problems. It is a fixture; the typo nmae was meant to be name.

ll_proj/src/messy.mjs
import { readFile } from "node:fs/promises";
var count = 0;
let name = "Ada";
function total(a, b) {
const unused = 1;
if (a == b) {
return a + b;
}
return count;
}
console.log(total(1, "1"), name, nmae);

The real report starts with the absolute path of the file, shown here as /path/to/ll-proj/src/messy.mjs. Each line has the position, the severity, a message and the rule name, which you can look up on ESLint’s website.

Terminal window
$ npx eslint src/messy.mjs
/path/to/ll-proj/src/messy.mjs
1:10 error 'readFile' is defined but never used no-unused-vars
2:1 error Unexpected var, use let or const instead no-var
3:5 warning 'name' is never reassigned. Use 'const' instead prefer-const
5:9 error 'unused' is assigned a value but never used no-unused-vars
6:9 error Expected '===' and instead saw '==' eqeqeq
11:34 error 'nmae' is not defined no-undef
✖ 6 problems (5 errors, 1 warning)
1 error and 1 warning potentially fixable with the `--fix` option.

Errors make ESLint exit with status 1, so a CI job fails; a run with only warnings exits with 0. Some problems are safe to repair mechanically. npx eslint --fix rewrote var count and let name to const and left the four that need a human decision: an unused import, an unused variable, a loose comparison and a misspelled name.

Sometimes a rule is wrong for one line. A comment turns it off for the next line, ideally with the reason:

// eslint-disable-next-line eqeqeq -- comparing with null on purpose
if (value == null) {
// ...
}

Use that sparingly: a project full of disable comments has a configuration that does not fit.

Prettier

Prettier throws away the original layout of a file and prints the same program in its own style. --check lists unformatted files (exit status 1 if any), and --write rewrites them. Here is a badly formatted file:

ll_proj/src/ugly.mjs
export const items=['a',"b", 'c'];
export function show( list ){
for(const item of list){console.log( item )}
return list.length
}
export const config = {name:'demo',retries:3,verbose:false,
tags:["one","two","three","four","five","six"]}
Terminal window
$ npx prettier --check src
Checking formatting...
[warn] src/ugly.mjs
[warn] Code style issues found in the above file. Run Prettier with --write to fix.
$ npx prettier --write src/ugly.mjs
src/ugly.mjs 32ms
$ npx prettier --check src
Checking formatting...
All matched files use Prettier code style!

The file now reads:

export const items = ["a", "b", "c"];
export function show(list) {
for (const item of list) {
console.log(item);
}
return list.length;
}
export const config = {
name: "demo",
retries: 3,
verbose: false,
tags: ["one", "two", "three", "four", "five", "six"],
};

The defaults are double quotes, semicolons, a two-space indent, trailing commas in multi-line lists, and lines wrapped at 80 columns. The config object was too long for one line, so it was broken up; the tags array fits on a line of its own. The few options go in a .prettierrc.json file, for example { "singleQuote": true }. Prettier deliberately has few options: the point is to stop discussions.

Prettier does not lint, and ESLint is not for layout. Use both, each for its own job.

Biome

Biome lints and formats in one fast tool, configured by biome.json (npx biome init creates one). Install it with npm install --save-dev --save-exact @biomejs/biome; the commands are npx biome check . (lint and check formatting), npx biome check --write . (apply safe fixes) and npx biome format --write .. On the file above, Biome 2.5.15 produced the same code as Prettier except for the indentation: Biome indents with tabs by default. Both can be configured to match. Its lint rules have their own names and differ from ESLint’s, so choose ESLint with Prettier, or Biome alone; two linters only double the noise.

Wiring it into the project

Commands that a team must all remember belong in scripts, as in the package.json above: one word for each job, for people and for CI. npm run check or node --run check formats nothing; it runs Prettier’s check and then ESLint (&& runs the second only if the first succeeded), and fails if either does. During development you run format and lint. Editors close the loop: the ESLint and Prettier extensions for VS Code show lint problems as underlines and can format on save (editor.formatOnSave).

Introduce these tools at the start of a project if you can. Adding a formatter to an old project produces one enormous commit that touches every line; make it a commit of its own, so nobody has to read it.

Common pitfalls

  • Turning on every rule. A noisy linter gets ignored. Start from the recommended set and add rules you can explain.
  • Arguing with the formatter. Pick options once, in the config.
  • Using ESLint and Prettier for the same job. Prettier formats; ESLint finds bugs.
  • Linting generated code and node_modules. Put build output in a global ignores entry.
  • Disabling a rule file-wide to silence one case. Disable it for the line, with a reason.
  • Mixing a formatter change with real changes in one commit.
  • Forgetting that warnings do not fail the build. If a warning matters, make it an error.

Exercises

ExerciseMake ESLint quiet

In a folder with the package.json and eslint.config.mjs from this lesson (after npm install), save messy.mjs from this lesson in src/. Run npx eslint --fix src/messy.mjs, then repair the rest by hand until npx eslint src/messy.mjs prints nothing and exits with 0. The typo nmae was meant to be name. Then run the file with Node.

After --fix, a plain run reports the four problems that need you (ESLint 10.13.0, 17 October 2026; the path line is shortened):

Terminal window
$ npx eslint src/messy.mjs
/path/to/ll-proj/src/messy.mjs
1:10 error 'readFile' is defined but never used no-unused-vars
5:9 error 'unused' is assigned a value but never used no-unused-vars
6:9 error Expected '===' and instead saw '==' eqeqeq
11:34 error 'nmae' is not defined no-undef
✖ 4 problems (4 errors, 0 warnings)

When you have fixed them:

Terminal window
$ npx eslint src/messy.mjs; echo $?
0
$ node src/messy.mjs
0 Ada Ada
Show solution

Delete the unused import and the unused variable, use ===, and write name. --fix had already turned var count and let name into const. The result prints 0 Ada Ada because 1 === "1" is false, so total returns count.

ll_fixed/src/messy.mjs
const count = 0;
const name = "Ada";
function total(a, b) {
if (a === b) {
return a + b;
}
return count;
}
console.log(total(1, "1"), name, name);
Output
0 Ada Ada
ExerciseFormat a crooked file

Save this as crooked.mjs, use Prettier to check it, rewrite it, and check again. Predict first: will the check pass, and what will --write change?

export const point={x:1,y:2}
export function dist(a,b){return Math.hypot(a.x-b.x,a.y-b.y)}

Expected output (Prettier 3.9.10, 17 October 2026):

Terminal window
$ npx prettier --check crooked.mjs
Checking formatting...
[warn] crooked.mjs
[warn] Code style issues found in the above file. Run Prettier with --write to fix.
$ npx prettier --write crooked.mjs
crooked.mjs 36ms
$ npx prettier --check crooked.mjs
Checking formatting...
All matched files use Prettier code style!
Show solution

The first check fails (and exits with 1), --write prints the file name with the time it took (yours will differ), and the second check passes. The file now reads:

ll_ex_fmt/formatted.mjs
export const point = { x: 1, y: 2 };
export function dist(a, b) {
return Math.hypot(a.x - b.x, a.y - b.y);
}
ExerciseWarnings, errors and the exit code

Save let total = 1; and console.log(total); on two lines as warn.mjs. With this lesson’s config, run npx eslint warn.mjs; echo $?. Then change "prefer-const": "warn" to "error" in eslint.config.mjs and run it again. Note what changes, in the report and in the exit code, and why a CI job cares.

Expected output (ESLint 10.13.0, 17 October 2026; the path line is shortened):

Terminal window
$ npx eslint warn.mjs; echo $?
/path/to/ll-proj/warn.mjs
1:5 warning 'total' is never reassigned. Use 'const' instead prefer-const
✖ 1 problem (0 errors, 1 warning)
0 errors and 1 warning potentially fixable with the `--fix` option.
0
$ npx eslint warn.mjs; echo $?
/path/to/ll-proj/warn.mjs
1:5 error 'total' is never reassigned. Use 'const' instead prefer-const
✖ 1 problem (1 error, 0 warnings)
1 error and 0 warnings potentially fixable with the `--fix` option.
1
Show solution

The same problem is reported both times, but a warning leaves the exit status at 0 and an error makes it 1. A CI job fails only on a non-zero status, so a warning cannot stop a bad change from being merged. If a rule matters, make it an error.

Summary

  • A linter (ESLint) finds likely bugs; a formatter (Prettier or Biome) fixes layout and nothing else. node --check finds syntax errors with no installation.
  • ESLint 10 needs Node 20.19, 22.13 or later 22.x, or 24 and newer. It reads the flat config eslint.config.mjs (not .eslintrc): an array of objects with files, ignores, extends, languageOptions and rules ("off", "warn", "error"). Errors exit with 1, warnings alone with 0, and --fix repairs what is safe to repair.
  • Prettier is almost option-free (--check, --write). Biome does both jobs in one tool and indents with tabs by default.
  • Put lint, format and check in package.json scripts, run check in CI, and let the editor do the same on save.
  • Tool versions in this lesson: ESLint 10.13.0, Prettier 3.9.10, Biome 2.5.15 (17 October 2026).

Next: type checking for plain JavaScript, with JSDoc comments and with TypeScript syntax that Node can strip.