frontend / project structure / 06_linting_and_formatting.md

Linting and Formatting — ESLint Flat Config, Prettier, husky, lint-staged

6 min read source

Linting and Formatting — ESLint Flat Config, Prettier, husky, lint-staged

TL;DR

The senior baseline for a frontend project: ESLint (with flat config — eslint.config.js) for static analysis + code rules, Prettier for formatting, husky + lint-staged to gate commits, CI to catch what hooks miss. Modern stacks may swap ESLint+Prettier for Biome (Rust-native, one tool, faster). The bigger lift: write the rule set (don’t accept defaults uncritically), gate on CI, fix violations rather than disabling rules.

Interview Q&A

Q: ESLint flat config vs legacy .eslintrc?

A: ESLint 9+ defaults to flat config (eslint.config.js) — declarative, single file, no plugin-magic resolution. Legacy .eslintrc.json + extends: is deprecated.

// eslint.config.js (flat — modern)
import js from "@eslint/js";
import tsEslint from "typescript-eslint";
import reactPlugin from "eslint-plugin-react";
import reactHooks from "eslint-plugin-react-hooks";

export default [
  js.configs.recommended,
  ...tsEslint.configs.recommended,
  {
    files: ["**/*.{ts,tsx}"],
    plugins: {
      react: reactPlugin,
      "react-hooks": reactHooks,
    },
    rules: {
      "react/react-in-jsx-scope": "off",
      "react-hooks/rules-of-hooks": "error",
      "react-hooks/exhaustive-deps": "warn",
      "@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_" }],
    },
    settings: { react: { version: "detect" } },
  },
  {
    files: ["**/*.test.ts", "**/*.test.tsx"],
    rules: { "@typescript-eslint/no-explicit-any": "off" },
  },
];

Flat config is just a JS array of config objects, each scoped by files:. Simpler to reason about, faster to load.

Q: Essential ESLint plugins.

A:

Plugin Why
@eslint/js base JS rules
typescript-eslint TS rules + parser
eslint-plugin-react React rules
eslint-plugin-react-hooks hook rules + exhaustive-deps
eslint-plugin-jsx-a11y accessibility static analysis
eslint-plugin-import import sorting, no-duplicates, no-circular
eslint-plugin-unicorn (opinionated) modern JS best practices
eslint-plugin-boundaries architecture boundaries (see 05)
eslint-plugin-tailwindcss Tailwind class sorting + validation
eslint-plugin-vue (Vue) Vue rules

@typescript-eslint/recommended-type-checked enables rules that need type info (slower but catches more). Worth it on most TS projects.

Q: Prettier — what’s the principle?

A: Formatting is solved, don’t argue about it. Prettier picks one valid style; commit to it.

// .prettierrc.json
{
  "semi": true,
  "singleQuote": false,
  "trailingComma": "all",
  "printWidth": 100,
  "tabWidth": 2,
  "arrowParens": "always"
}

Run via:

  • Editor — format on save (VS Code’s Prettier extension).
  • Pre-commit hook — auto-format staged files (lint-staged).
  • CIprettier --check . fails build if anyone bypassed.

Q: ESLint + Prettier — do they conflict?

A: Historically yes — ESLint had formatting rules (indent, quotes, semi) that conflicted with Prettier. Modern setup: disable formatting rules in ESLint, let Prettier own formatting.

import prettierConfig from "eslint-config-prettier";

export default [
  // ... your other configs
  prettierConfig,                    // must be LAST — disables formatting rules from earlier configs
];

Some teams ship eslint-plugin-prettier to run Prettier as an ESLint rule. Don’t — it makes lint noisy and slow. Run Prettier separately.

Q: husky + lint-staged — what they do.

A: husky installs Git hooks (.husky/pre-commit, etc.). lint-staged runs commands only on staged files (fast feedback).

pnpm add -D husky lint-staged
pnpm exec husky init
// package.json
{
  "lint-staged": {
    "*.{ts,tsx,js,jsx}": [
      "eslint --fix",
      "prettier --write"
    ],
    "*.{json,md,yaml}": "prettier --write"
  }
}
# .husky/pre-commit
pnpm exec lint-staged

Effect: on git commit, only the staged files get linted + formatted. Fast (a few seconds), so devs don’t bypass with --no-verify.

For long-running checks (type-check on whole repo, tests), use pre-push not pre-commitpre-commit should be < 10 seconds.

Q: pre-commit vs pre-push vs CI — what runs where?

A: Tiered for speed:

Hook What runs Time budget
pre-commit format staged files, lint staged files < 10s
pre-push type-check, fast tests < 60s
CI full lint, type-check, all tests, build, deploy minutes

Each tier catches what slipped through the prior. CI is the final gate — never trust hooks alone (devs disable, forget to install).

Q: Biome — what is it, when use?

A: Biome is a Rust-native single-binary that does linting + formatting + import sorting in one tool. ~50× faster than ESLint+Prettier combined.

pnpm add -D --save-exact @biomejs/biome
biome init
// biome.json
{
  "formatter": { "indentStyle": "space", "lineWidth": 100 },
  "linter": { "rules": { "recommended": true } },
  "javascript": { "formatter": { "quoteStyle": "double" } }
}

Single command: biome check --write . formats + lints + sorts imports. CI: biome ci ..

Pros: speed, single config, single binary. Cons: smaller plugin ecosystem than ESLint, fewer specialized rules (boundaries, Tailwind validation, etc.).

For new projects with simple rules: Biome. For large existing projects with rich ESLint configs: stay on ESLint + Prettier, the ecosystem matters more than the speed.

Q: TypeScript checking in lint vs tsc?

A:

  • tsc --noEmit — full type checking. Slow but authoritative.
  • typescript-eslint rules — some type-aware rules (no-misused-promises, await-thenable), but not a full type check.

Don’t rely on ESLint for type errors. Run tsc --noEmit in CI (and ideally pre-push). VS Code’s tsserver covers it during editing.

"scripts": {
  "type-check": "tsc --noEmit",
  "lint": "eslint .",
  "format": "prettier --write .",
  "ci": "pnpm type-check && pnpm lint && pnpm test && pnpm build"
}

Q: What about EditorConfig?

A: .editorconfig sets basic file conventions (indent, line endings, trailing newline) that work across editors without Prettier. Useful for non-Prettier-handled files (.md, .yaml, .toml) where Prettier might disagree.

# .editorconfig
root = true

[*]
indent_style = space
indent_size = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true

[*.md]
trim_trailing_whitespace = false

Most editors auto-apply. Doesn’t replace Prettier; complements it.

Q: A senior’s full config layout.

A:

my-app/
├── eslint.config.js              # flat config
├── .prettierrc.json
├── .prettierignore
├── .editorconfig
├── biome.json (alternative)
├── tsconfig.json
├── .husky/
│   ├── pre-commit
│   └── pre-push
└── package.json                  # lint-staged config

CI runs pnpm ci (or similar) doing format check + lint + type-check + tests + build.

Q: What rules do you push back on?

A:

  • Overly opinionated unicorn rules (unicorn/no-array-reduce) — case-by-case.
  • no-default-export — Next.js + Vite expect default exports for routes/pages; can’t blanket disable.
  • Strict null checks via TS, not lint — handle in TS, not duplicated in lint.
  • Style rules duplicating Prettier — disable via eslint-config-prettier.

The principle: each rule earns its place by preventing a real bug class, not by matching a preference.

Gotchas / edge cases

  • ESLint flat config + plugins that don’t yet support flat — wrap in FlatCompat (legacy → flat).
  • Type-aware rules slow — slow CI lint runs. Configure to enable only on changed files in CI.
  • Prettier line-width disputes100 is common; 80 is purist; 120 is “I have a wide monitor.” Pick once.
  • Tailwind class order via eslint-plugin-tailwindcss — sorts classes consistently (important for PR diffs). Prettier has a Tailwind plugin too; pick one.
  • husky in CI — Git hooks don’t run in CI; don’t rely on them as a final gate.
  • --no-verify lets devs bypass hooks. Accept it for emergencies; don’t make hooks slow enough to invite use.
  • Files outside src/ (config, scripts) — explicitly include or your lint misses them.

What a senior is expected to say

  • “ESLint flat config (modern) + Prettier (formatting) + husky + lint-staged (fast commit gate) + CI (final gate). Or Biome as a single-tool replacement for new projects with simple needs.”
  • “Type checking with tsc --noEmit is separate from lint — typescript-eslint covers a few type-aware rules; full check needs tsc.”
  • “lint-staged on pre-commit keeps the hook < 10s. Type-check + tests on pre-push. Full suite in CI.”
  • “Disable formatting rules in ESLint (eslint-config-prettier) and let Prettier own formatting. Don’t run Prettier through ESLint — slow and noisy.”
  • “Every rule should justify its existence with a bug class it prevents. Avoid adding rules just because a preset includes them.”

Cross-references

Further reading