Introduction
At some point in every JavaScript project that doesn’t have formatting and linting set up, someone writes a code review comment about indentation. Or semicolons. Or whether the opening brace goes on the same line or the next one.
And somewhere in that same codebase, there is a == where there should be a ===. A variable declared and never used. A console.log that made it past a commit. A useEffect with a missing dependency. None of these are caught because nothing is looking for them.
These are different problems. The formatting argument is a waste of everyone’s time and attention — it genuinely does not matter whether you use tabs or spaces, as long as everyone uses the same thing. The linting problems are actual bugs or the conditions for actual bugs, and they matter quite a lot.
Prettier solves the first category. ESLint solves the second. Together, they eliminate an entire class of friction from a codebase, free up code reviews for things that actually matter, and catch real problems before they reach production.
I’ve worked on enterprise codebases that had neither, and I’ve worked on ones with both configured well. The difference in day-to-day development experience is significant enough that I now consider them non-negotiable on any project I have influence over — personal, professional, or otherwise.
This is why, and how to set them up properly.
The Problem Prettier Solves
Let me paint a picture you might recognise.
You’re reading through a file someone else wrote. The indentation is two spaces. You open the file next to it — four spaces. The imports in one file have trailing commas. The imports in the other don’t. One file uses single quotes everywhere. The next one uses double quotes, except in three places where it uses single quotes for no discernible reason.
None of this affects whether the code runs. All of it affects how quickly you can read it, because your brain has to adjust its parsing every time the style shifts. And it affects code reviews in a specific way that I find particularly frustrating: style inconsistencies generate comments that have nothing to do with the quality of the logic.
I’ve been in code reviews — as reviewer and as reviewee — where a meaningful portion of the feedback was about formatting. That feedback has to be given, because inconsistent formatting in a shared codebase compounds over time. But every comment about a semicolon or a line length is a comment that isn’t about architecture, about edge cases, about the correctness of the logic. It’s an opportunity cost.
Prettier is an opinionated code formatter. You don’t configure it into your preferred style — you largely accept its style, and in exchange you never think about formatting again. It makes every decision for you: quote style, semicolons, trailing commas, line length, bracket spacing, all of it. Run Prettier on a file and it comes out the same way every time, regardless of how it went in.
The key word is opinionated. Some developers push back on this — “but I prefer single quotes” or “I don’t want trailing commas.” Those preferences are real, and Prettier does allow a small number of configurable options. But the value of Prettier is not in expressing your formatting preferences. It’s in making formatting a non-decision. The moment formatting is automated, it stops being a thing anyone argues about, and code reviews get cleaner immediately.
The Problem ESLint Solves
ESLint is different in kind from Prettier. Prettier is about style. ESLint is about correctness — or at least about the conditions that tend toward incorrectness.
The problems ESLint catches range from the trivial to the genuinely dangerous.
Trivial: a variable you declared but never used. A console.log you left in during debugging. An import you added and then stopped needing.
Genuinely dangerous: using == instead of ===, which in JavaScript leads to type coercion that behaves in ways almost nobody intends. Accessing a property on something that might be undefined. A switch statement with a case that falls through to the next one without a break — sometimes intentional, often a bug, always worth flagging.
In TypeScript projects specifically, ESLint with the TypeScript plugin catches things the TypeScript compiler doesn’t: certain patterns around any, certain async/await mistakes, certain React hook dependency arrays that are incomplete in ways that cause stale closure bugs.
The difference between ESLint and a code review catching these things is timing and cost. ESLint catches them before the code leaves your editor — before a commit, before a review, before a deployment. A code review catching the same problem is slower, more expensive, and slightly awkward for both parties. A production bug caused by == where === was intended is slower and more expensive still.
Static analysis at the editor level is one of the highest-return investments in a codebase. It scales perfectly — it checks every line, every time, without getting tired or distracted.
Setting Up Prettier
Let’s get into the actual setup. I’ll cover a vanilla JavaScript or TypeScript project first, then Angular and React specifically.
Installation
npm install --save-dev prettier
That’s the only dependency. Prettier has no peer dependencies and no plugins required for basic JavaScript and TypeScript formatting.
Configuration
Create a .prettierrc file in the root of your project. This is where you set the small number of options Prettier exposes:
{
"semi": true,
"singleQuote": true,
"trailingComma": "all",
"printWidth": 80,
"tabWidth": 2,
"useTabs": false,
"bracketSpacing": true,
"arrowParens": "always"
}
A brief note on each option, because these are the ones worth understanding:
semi— whether to add semicolons. I usetrue. The no-semicolons camp has arguments, but in a codebase with mixed experience levels, semicolons are clearer.singleQuote— I prefer single quotes in JavaScript. Set tofalseif you prefer double.trailingComma: "all"— adds trailing commas to the last item in arrays, objects, and function parameters. Makes diffs cleaner — adding a new item only touches one line instead of two.printWidth: 80— This is Prettier’s default. I find it more comfortable.- The rest are fairly self-explanatory defaults.
Ignoring files
Create a .prettierignore in the root — same format as .gitignore — for files Prettier should leave alone:
node_modules
dist
build
coverage
.angular
*.min.js
Running Prettier
Add scripts to your package.json:
{
"scripts": {
"format": "prettier --write .",
"format:check": "prettier --check ."
}
}
format rewrites files in place. format:check exits with a non-zero status if any file is unformatted — useful in CI to fail a build if unformatted code is committed.
Editor integration
Install the Prettier extension for VS Code (esbenp.prettier-vscode). Then add this to your .vscode/settings.json:
{
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true
}
This is the setting that makes Prettier disappear into the background. You save a file, it formats. You never run the command manually. You never think about formatting again.
Commit the .vscode/settings.json file so every developer on the team gets the same setup automatically.
Setting Up ESLint
ESLint has more moving parts than Prettier because it does more. The configuration depends on your stack — I’ll cover the base setup and then specific additions for TypeScript, Angular, and React.
Installation — Base
npm install --save-dev eslint
From ESLint v9, the configuration format changed to a flat config system using eslint.config.js instead of the older .eslintrc files. I’ll use the modern flat config here.
# If you're on ESLint v9+
npm install --save-dev eslint @eslint/js
Base configuration — eslint.config.js
import js from '@eslint/js';
export default [
js.configs.recommended,
{
rules: {
// Disallow == in favour of ===
eqeqeq: ['error', 'always'],
// No unused variables
'no-unused-vars': [
'warn',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
},
],
// No console.log left in committed code
'no-console': ['warn', { allow: ['warn', 'error'] }],
// No var — use const or let
'no-var': 'error',
// Prefer const where variable is never reassigned
'prefer-const': 'error',
// No implicit fallthrough in switch cases
'no-fallthrough': 'error',
},
},
];
The _ pattern in no-unused-vars is a convention I follow consistently: prefix a variable with an underscore to explicitly signal “I know this is unused, it’s intentional.” Common in callbacks where you need the second argument but not the first:
// Without the pattern, ESLint would warn here
array.map((_, index) => index);
// With argsIgnorePattern: "^_", this is clean
Adding TypeScript support
npm install --save-dev typescript-eslint
import js from '@eslint/js';
import tseslint from 'typescript-eslint';
export default tseslint.config(
js.configs.recommended,
...tseslint.configs.recommended,
{
rules: {
// Disallow explicit `any` — forces proper typing
'@typescript-eslint/no-explicit-any': 'warn',
// Enforce return types on functions
'@typescript-eslint/explicit-function-return-type': 'off',
// No non-null assertions (!.) without good reason
'@typescript-eslint/no-non-null-assertion': 'warn',
// Prefer nullish coalescing (??) over ||
'@typescript-eslint/prefer-nullish-coalescing': 'error',
// Prefer optional chaining (?.) over && chains
'@typescript-eslint/prefer-optional-chain': 'error',
// Enforce consistent type imports
'@typescript-eslint/consistent-type-imports': [
'error',
{
prefer: 'type-imports',
},
],
// Override base rule with TypeScript-aware version
'no-unused-vars': 'off',
'@typescript-eslint/no-unused-vars': [
'warn',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
},
],
},
}
);
The consistent-type-imports rule is one I’ve found particularly valuable. It enforces using import type { Foo } instead of import { Foo } when you’re only importing a type, not a value. This gives the TypeScript compiler better information and can reduce bundle size because type-only imports are erased at build time.
// ESLint will flag this if Foo is a type-only import
import { Foo } from './types';
// This is what the rule enforces
import type { Foo } from './types';
Adding React support
npm install --save-dev eslint-plugin-react eslint-plugin-react-hooks eslint-plugin-jsx-a11y
import js from '@eslint/js';
import tseslint from 'typescript-eslint';
import react from 'eslint-plugin-react';
import reactHooks from 'eslint-plugin-react-hooks';
import jsxA11y from 'eslint-plugin-jsx-a11y';
export default tseslint.config(
js.configs.recommended,
...tseslint.configs.recommended,
{
plugins: {
react,
'react-hooks': reactHooks,
'jsx-a11y': jsxA11y,
},
settings: {
react: {
version: 'detect',
},
},
rules: {
// React hooks rules — these catch real bugs
'react-hooks/rules-of-hooks': 'error',
'react-hooks/exhaustive-deps': 'warn',
// Accessibility basics
'jsx-a11y/alt-text': 'error',
'jsx-a11y/anchor-is-valid': 'error',
// No direct state mutation
'react/no-direct-mutation-state': 'error',
// Enforce self-closing tags where no children
'react/self-closing-comp': 'error',
// Not needed in React 17+ with new JSX transform
'react/react-in-jsx-scope': 'off',
},
}
);
The react-hooks/exhaustive-deps rule deserves a specific mention because it catches a class of bug that is surprisingly common and surprisingly hard to spot in code review. When a useEffect has a dependency array that’s missing a value the effect actually depends on, you get stale closure bugs — the effect runs with the value from the first render rather than the current value, and debugging it is genuinely unpleasant.
The linter catches this before it becomes a runtime problem.
Adding Angular support
npm install --save-dev @angular-eslint/eslint-plugin @angular-eslint/eslint-plugin-template @angular-eslint/template-parser
import tseslint from 'typescript-eslint';
import angular from '@angular-eslint/eslint-plugin';
import angularTemplate from '@angular-eslint/eslint-plugin-template';
import angularTemplateParser from '@angular-eslint/template-parser';
export default tseslint.config(
...tseslint.configs.recommended,
{
// TypeScript files
files: ['**/*.ts'],
plugins: {
'@angular-eslint': angular,
},
rules: {
// Component selector must be kebab-case with a prefix
'@angular-eslint/component-selector': [
'error',
{
type: 'element',
prefix: 'app',
style: 'kebab-case',
},
],
// Directive selector must be camelCase with a prefix
'@angular-eslint/directive-selector': [
'error',
{
type: 'attribute',
prefix: 'app',
style: 'camelCase',
},
],
// No empty lifecycle hooks
'@angular-eslint/no-empty-lifecycle-method': 'error',
// Prefer OnPush change detection
'@angular-eslint/prefer-on-push-component-change-detection': 'warn',
// No output prefixed with 'on'
'@angular-eslint/no-output-on-prefix': 'error',
// Relative imports for same module
'@angular-eslint/relative-url-prefix': 'error',
},
},
{
// HTML template files
files: ['**/*.html'],
plugins: {
'@angular-eslint/template': angularTemplate,
},
languageOptions: {
parser: angularTemplateParser,
},
rules: {
// No call expressions in templates (performance)
'@angular-eslint/template/no-call-expression': 'warn',
// Accessibility
'@angular-eslint/template/alt-text': 'error',
// No any in template
'@angular-eslint/template/no-any': 'warn',
},
}
);
The prefer-on-push-component-change-detection rule is one I set to warn rather than error — it’s an important Angular performance pattern, but enforcing it as an error in a codebase mid-migration would block development while the team works through the existing components. A warning surfaces the issue without blocking work.
Running ESLint
Add scripts to package.json:
{
"scripts": {
"lint": "eslint .",
"lint:fix": "eslint . --fix"
}
}
lint reports problems. lint:fix automatically fixes the ones that can be auto-fixed — many formatting-adjacent rules, unused imports in some configurations, and several code pattern issues have auto-fixers.
Making Prettier and ESLint Work Together
If you’re running both, you need one more step: telling ESLint not to apply formatting rules that Prettier is already handling. If both try to manage formatting, you get conflicts.
npm install --save-dev eslint-config-prettier
Add eslintConfigPrettier to your ESLint config — it must come last to override any formatting rules from other configs:
import js from '@eslint/js';
import tseslint from 'typescript-eslint';
import eslintConfigPrettier from 'eslint-config-prettier';
export default tseslint.config(
js.configs.recommended,
...tseslint.configs.recommended,
// ... your other configs
eslintConfigPrettier // Always last
);
Now Prettier owns formatting entirely and ESLint owns code quality entirely. They don’t step on each other.
Pre-commit Hooks with lint-staged
Configuration is only useful if it runs. A developer can ignore a lint warning in their editor. They can forget to run the format script before committing. Pre-commit hooks remove that reliance on memory.
npm install --save-dev husky lint-staged
Initialise Husky:
npx husky init
This creates a .husky folder. Edit .husky/pre-commit:
npx lint-staged
Configure lint-staged in package.json — this tells it what to run on which files when they’re staged for commit:
{
"lint-staged": {
"*.{js,jsx,ts,tsx}": ["eslint --fix", "prettier --write"],
"*.{json,md,html,scss,css}": ["prettier --write"]
}
}
Now every time someone commits, lint-staged runs Prettier and ESLint only on the files that have changed — not the whole codebase, so it stays fast — and auto-fixes what it can. If ESLint finds a problem it can’t auto-fix, the commit fails and the developer sees the error.
This is the setup that actually enforces the standards rather than just recommending them.
The CI Gate
The pre-commit hook handles the local workflow. The CI gate handles the “what if someone bypasses the hook” case — which happens, usually accidentally, with --no-verify commits or direct pushes.
Add these to your CI pipeline (GitHub Actions, GitLab CI, whatever you use):
# .github/workflows/quality.yml
name: Code Quality
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
- name: Check formatting
run: npm run format:check
- name: Lint
run: npm run lint
format:check exits non-zero if any file isn’t formatted. lint exits non-zero if there are errors. Either failure blocks the merge. The standards are enforced regardless of what happened locally.
The Conversation About Rules
Here is the thing about ESLint rules that I want to be honest about: the right configuration for your project is not whatever I’ve written above. It’s whatever your team agrees on and actually follows.
The worst ESLint configuration I’ve seen in practice was one that had hundreds of rules, all set to error, inherited from an extremely strict shared config, applied to a codebase that hadn’t been written to those standards. The result was thousands of lint errors, all of which were being suppressed with // eslint-disable-next-line comments scattered throughout the codebase. The rules were technically present. They were doing nothing.
Rules set to error that nobody can reasonably fix get disabled. Rules set to warn that nobody ever addresses become background noise. The right level of strictness is the level that the team can maintain without resorting to mass disabling.
My practical approach:
- Start with
recommendedconfigs as a baseline. - Add rules that catch real bugs as
error—eqeqeq,no-fallthrough,react-hooks/rules-of-hooks. - Add stylistic preferences as
warninitially — it surfaces the issue without blocking work while the team adjusts. - Promote
warntoerroronce the codebase is clean for that rule. - Discuss rule changes as a team. An ESLint config is a shared agreement, not a unilateral decision.
The last point matters more than people usually give it credit for. If the rules are imposed rather than agreed upon, you get passive resistance — the eslint-disable comment instead of the fixed code. If the team owns the rules together, they enforce themselves.
What Changes After You Set This Up
Here is what actually changes in day-to-day development once Prettier and ESLint are both running properly.
Code reviews get cleaner. This is the first and most immediate change. When formatting is automated and obvious anti-patterns are caught by the linter, review comments focus on the things that actually require human judgment: architecture, logic, edge cases, naming, test coverage. The review becomes a conversation about quality rather than a checklist of style corrections.
Onboarding gets easier. A new developer on a codebase with these tools set up doesn’t need to be told the code style — they get it from the tooling. The linter tells them immediately when they’ve written something the team considers a problem. They don’t have to absorb an unwritten style guide through osmosis.
The codebase drifts less. Entropy is real in shared codebases. Without enforcement, styles diverge gradually as different developers bring different habits. With automated enforcement, the divergence is caught at the commit level before it compounds.
Some bugs don’t happen. This one is harder to measure because you’re counting things that didn’t occur. But no-unused-vars catching a variable that was meant to be used, react-hooks/exhaustive-deps catching a missing dependency, eqeqeq flagging a loose equality — these are real problems that the linter prevents from reaching production. The value is real even if it’s invisible.
Conclusion
Prettier and ESLint are not glamorous tools. They don’t solve hard problems. They solve tedious ones — the formatting inconsistencies, the style debates, the easily-preventable bugs that waste time disproportionate to their complexity.
But the accumulation of tedious problems is what makes a codebase unpleasant to work in. It’s what makes code reviews combative rather than collaborative. It’s what makes onboarding slower than it needs to be. It’s what lets small bugs compound into larger ones because nobody was looking for them at the right time.
Setting these tools up takes a few hours. The configuration I’ve laid out above covers the common cases for vanilla JavaScript, TypeScript, React, and Angular. The pre-commit hooks and CI gate make the standards enforceable rather than aspirational.
The return on that few hours compounds for as long as the project runs.
Set them up at the start of the project. Set them up in the middle if you didn’t. The later you leave it, the more debt you’re letting accumulate — and the bigger the diff when you finally do run Prettier across a codebase that’s never been formatted consistently.
There is no good argument for not having them. I’ve looked for one. I haven’t found it.