JavaScriptLinting & Formatting

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:

JS
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, prefer const, no implicit globals

  • Keeps 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:

Bash
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.

JS
// 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:

Bash
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)
Flat config vs legacy config
ESLint 9 made flat config the default; the old .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

eqeqeq

Use === instead of ==

Loose equality does surprising type coercion

no-unused-vars

No variables that are never read

Usually leftover or misspelled code

prefer-const

Use const when never reassigned

Signals intent, prevents accidental reassignment

no-var

Use let/const instead of var

var has confusing function-scoping and hoisting

no-undef

No undeclared variables

Catches typos and missing imports

no-cond-assign

No = inside if (...)

Almost always a typo for ===

no-console

No console.log in committed code

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:

JS
// 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.

Bash
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:

JSON
{
  "semi": false,
  "singleQuote": true,
  "trailingComma": "all",
  "printWidth": 80,
  "tabWidth": 2
}

Before and after — Prettier normalizes everything in one pass:

JS
// 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, == instead of ===

Inconsistent indentation, missing trailing comma

Can auto-fix?

Some rules

Everything it handles

Do not let them fight
ESLint has stylistic rules (quotes, semicolons, indentation) that can conflict with Prettier. If both tools try to own formatting, they will endlessly flag each other's output. The fix is eslint-config-prettier, which turns off every ESLint rule that Prettier makes redundant.

Bash
npm install --save-dev eslint-config-prettier

JS
// 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)
  1. Install the ESLint extension (dbaeumer.vscode-eslint) — squiggly underlines appear as you type.

  2. Install the Prettier extension (esbenp.prettier-vscode).

  3. Enable format-on-save so every file is formatted the moment you hit save.

JSON
// .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"
  }
}
Fix on save
With this setup, saving a file formats it with Prettier and applies every auto-fixable ESLint rule at once. Most days you will never run either tool by hand.
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):

Bash
npm install --save-dev husky lint-staged
npx husky init
echo "npx lint-staged" > .husky/pre-commit

JSON
// 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:

JSON
{
  "scripts": {
    "lint": "eslint .",
    "format:check": "prettier --check ."
  }
}

YAML
# .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:check
Three layers of defense
Editor (instant feedback while typing) → pre-commit hook (blocks bad commits) → CI (blocks bad merges). Each layer catches what the previous one missed.
Popular shared configs

You rarely build a ruleset from scratch — teams extend a battle-tested shared config:

Config

Style

Notes

@eslint/js recommended

Bug-catching only, no style opinions

The sensible default starting point

Airbnb (eslint-config-airbnb)

Strict and comprehensive

Hugely popular; official flat-config support has lagged

Standard (eslint-config-standard)

No semicolons, minimal config

Zero-decision setup; also lags on flat config

typescript-eslint

Type-aware rules for TypeScript

The standard for TS projects

eslint-plugin-unicorn

Modern, opinionated extras

Great rules like prefer node: protocol imports

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.js exporting an array of config objects

  • Add eslint-config-prettier last in the config array to prevent rule conflicts

  • Wire 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