Linting & Formatting
Every professional JavaScript project uses two kinds of automated code quality tools: a linter (ESLint) that catches bugs and enforces best practices, and a formatter (Prettier) that keeps code style consistent. Together they eliminate entire categories of bugs and end every "tabs vs spaces" argument forever.
This page walks you through setting up ESLint 9 with the modern flat config, adding Prettier, wiring them into your editor, and automating everything with pre-commit hooks and CI.
Why linting matters
JavaScript is forgiving at parse time and unforgiving at runtime. Code like this is perfectly valid syntax — and almost certainly a bug:
function getDiscount(user) {
if (user.role = 'admin') { // assignment instead of comparison!
return 0.5
}
const unused = computeSomething() // dead code
return 0.1
}
async function loadAll(ids) {
ids.forEach(async (id) => {
await save(id) // awaits are lost inside forEach — subtle async bug
})
}A linter statically analyzes your code and flags these problems before you run it:
Catches real bugs — assignment in conditions, unused variables, unreachable code, misused promises
Enforces best practices — no
var, preferconst, no implicit globalsKeeps a team consistent — everyone follows the same rules automatically
Teaches you the language — rule explanations are miniature JavaScript lessons
Installing ESLint 9
The quickest start is the official init wizard, which detects your project type and writes a config for you:
npm init @eslint/config@latest # Or install manually npm install --save-dev eslint @eslint/js
ESLint 9 uses flat config: a single eslint.config.js file at the project root that exports an array of configuration objects. This replaces the old .eslintrc.json / .eslintrc.js format.
// eslint.config.js
import js from '@eslint/js'
import globals from 'globals'
export default [
// Start from ESLint's recommended rules
js.configs.recommended,
{
files: ['**/*.js'],
languageOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
globals: {
...globals.browser, // window, document, fetch...
...globals.node, // process, __dirname...
},
},
rules: {
'no-unused-vars': 'warn',
'no-console': 'off',
eqeqeq: ['error', 'always'],
'prefer-const': 'error',
},
},
// Ignore build output (replaces .eslintignore)
{ ignores: ['dist/', 'coverage/', 'node_modules/'] },
]Run it against your code:
npx eslint . # lint everything npx eslint src/app.js # lint one file npx eslint . --fix # auto-fix what can be fixed
src/app.js 3:12 error Expected '===' and instead saw '==' eqeqeq 7:9 warn 'unused' is assigned a value but never used no-unused-vars 2 problems (1 error, 1 warning)
.eslintrc format is deprecated. If you find tutorials using extends and env keys in a JSON file, they describe the legacy format. New projects should useeslint.config.js.Common rules explained
Every rule can be set to "off", "warn", or "error". Here are rules you'll meet in almost every project:
Rule | What it enforces | Why |
|---|---|---|
| Use | Loose equality does surprising type coercion |
| No variables that are never read | Usually leftover or misspelled code |
| Use | Signals intent, prevents accidental reassignment |
| Use |
|
| No undeclared variables | Catches typos and missing imports |
| No | Almost always a typo for |
| No | Keeps debug noise out of production |
Sometimes a rule is wrong for one specific line. Disable it narrowly with a comment — and get in the habit of explaining why:
// eslint-disable-next-line no-console -- intentional startup banner
console.log('Server started on port 3000')Prettier: the formatter
Prettier takes a completely different approach from a linter: it reprints your entire file from scratch according to its own rules. You don't debate style — you let Prettier decide, and everyone's code looks identical.
npm install --save-dev --save-exact prettier # Format everything npx prettier --write . # Check without writing (useful in CI) npx prettier --check .
Prettier is intentionally opinionated with only a handful of options, set in .prettierrc:
{
"semi": false,
"singleQuote": true,
"trailingComma": "all",
"printWidth": 80,
"tabWidth": 2
}Before and after — Prettier normalizes everything in one pass:
// Before
const user={name:"Ada",langs:["js","ts"],active:true};function greet( u ){
return "Hi "+u.name}
// After prettier --write
const user = { name: 'Ada', langs: ['js', 'ts'], active: true }
function greet(u) {
return 'Hi ' + u.name
}ESLint vs Prettier: separation of concerns
ESLint | Prettier | |
|---|---|---|
Job | Code quality — bugs and bad patterns | Code style — spacing, quotes, line breaks |
How it works | Analyzes the AST rule by rule | Reprints the whole file |
Configurable? | Hundreds of rules, fully tunable | Deliberately few options |
Example catch | Unused variable, | Inconsistent indentation, missing trailing comma |
Can auto-fix? | Some rules | Everything it handles |
eslint-config-prettier, which turns off every ESLint rule that Prettier makes redundant.npm install --save-dev eslint-config-prettier
// eslint.config.js import js from '@eslint/js' import eslintConfigPrettier from 'eslint-config-prettier' export default [ js.configs.recommended, // ...your rules... eslintConfigPrettier, // MUST be last — disables conflicting style rules ]
The resulting division of labor: ESLint finds bugs, Prettier formats code. Run them as separate commands rather than piping Prettier through ESLint (the old eslint-plugin-prettier approach is slower and muddles error reporting).
Editor integration (VS Code)
Install the ESLint extension (
dbaeumer.vscode-eslint) — squiggly underlines appear as you type.Install the Prettier extension (
esbenp.prettier-vscode).Enable format-on-save so every file is formatted the moment you hit save.
// .vscode/settings.json — commit this so the whole team shares it
{
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
}
}Pre-commit hooks: husky + lint-staged
Editor integration is opt-in — a teammate without the extensions can still commit messy code. Git hooks make checks mandatory. husky manages the hooks; lint-staged runs tools only on the files you're actually committing (fast, even in huge repos):
npm install --save-dev husky lint-staged npx husky init echo "npx lint-staged" > .husky/pre-commit
// package.json
{
"lint-staged": {
"*.{js,jsx,ts,tsx}": ["eslint --fix", "prettier --write"],
"*.{json,css,md}": ["prettier --write"]
}
}Now every git commit automatically lints and formats the staged files. If ESLint finds an unfixable error, the commit is blocked until you resolve it:
$ git commit -m "add discount logic" ✔ Backed up original state ✖ eslint --fix: src/discount.js 4:14 error Expected '===' and instead saw '=' no-cond-assign husky - pre-commit script failed (code 1)
CI integration
Hooks can be skipped with git commit --no-verify, so the final safety net is continuous integration. Add scripts to package.json and run them on every push:
{
"scripts": {
"lint": "eslint .",
"format:check": "prettier --check ."
}
}# .github/workflows/ci.yml
name: CI
on: [push, pull_request]
jobs:
quality:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- run: npm run lint
- run: npm run format:checkPopular shared configs
You rarely build a ruleset from scratch — teams extend a battle-tested shared config:
Config | Style | Notes |
|---|---|---|
| Bug-catching only, no style opinions | The sensible default starting point |
Airbnb ( | Strict and comprehensive | Hugely popular; official flat-config support has lagged |
Standard ( | No semicolons, minimal config | Zero-decision setup; also lags on flat config |
| Type-aware rules for TypeScript | The standard for TS projects |
| Modern, opinionated extras | Great rules like prefer |
A pragmatic modern stack: @eslint/js recommended + typescript-eslint (if using TS) + a framework plugin (React, Vue) + eslint-config-prettier last, with Prettier handling all formatting.
Summary
ESLint catches bugs and bad patterns; Prettier owns formatting — keep the jobs separate
ESLint 9 uses flat config: one
eslint.config.jsexporting an array of config objectsAdd
eslint-config-prettierlast in the config array to prevent rule conflictsWire both tools into VS Code with format-on-save and fix-on-save
Enforce with husky + lint-staged locally and a lint/format check in CI