|$ curl https://forge-ai.dev/api/markdown?path=docs/testing/linting
$cat docs/linting-&-formatting.md
updated Recently·35 min read·published

Linting & Formatting

TestingLintingQualityBeginner to Intermediate
Introduction

Linting and formatting are static analysis tools that enforce code quality, consistency, and best practices without executing your code. ESLint catches potential bugs and style issues, while Prettier ensures consistent formatting across your codebase.

This guide covers ESLint configuration (including the new flat config), rules and plugins, Prettier integration, pre-commit hooks with husky and lint-staged, editor integration (VS Code), and custom rule creation.

ESLint Configuration

ESLint supports two configuration formats: the traditional .eslintrc (JSON/YAML/JS) and the modern flat config (eslint.config.js) introduced in ESLint v9.

Flat Config (ESLint v9+)

eslint.config.js
JavaScript
1// eslint.config.js — Modern flat config (ESLint v9+)
2import js from "@eslint/js";
3import tseslint from "typescript-eslint";
4import react from "eslint-plugin-react";
5import reactHooks from "eslint-plugin-react-hooks";
6import jsxA11y from "eslint-plugin-jsx-a11y";
7import globals from "globals";
8
9export default [
10 // Global ignores
11 {
12 ignores: [
13 "dist/**",
14 "build/**",
15 ".next/**",
16 "node_modules/**",
17 ],
18 },
19
20 // Base recommended rules
21 js.configs.recommended,
22
23 // TypeScript rules
24 ...tseslint.configs.recommended,
25
26 // React rules
27 {
28 files: ["**/*.{jsx,tsx}"],
29 plugins: {
30 react,
31 "react-hooks": reactHooks,
32 "jsx-a11y": jsxA11y,
33 },
34 languageOptions: {
35 globals: {
36 ...globals.browser,
37 ...globals.es2022,
38 },
39 parserOptions: {
40 ecmaFeatures: { jsx: true },
41 },
42 },
43 rules: {
44 ...react.configs.recommended.rules,
45 ...reactHooks.configs.recommended.rules,
46 ...jsxA11y.configs.recommended.rules,
47
48 "react/react-in-jsx-scope": "off",
49 "react/prop-types": "off",
50 "react/jsx-no-target-blank": "error",
51
52 "react-hooks/rules-of-hooks": "error",
53 "react-hooks/exhaustive-deps": "warn",
54
55 "jsx-a11y/anchor-is-valid": [
56 "error",
57 { components: ["Link"], specialLink: ["to"] },
58 ],
59 },
60 settings: {
61 react: { version: "detect" },
62 },
63 },
64
65 // Node files (no browser globals)
66 {
67 files: ["**/*.config.{js,ts}", "scripts/**"],
68 languageOptions: {
69 globals: {
70 ...globals.node,
71 },
72 },
73 rules: {
74 "no-console": "off",
75 },
76 },
77];

Legacy .eslintrc Format

.eslintrc.cjs
JavaScript
1// .eslintrc.cjs — Traditional format (ESLint v8 and below)
2module.exports = {
3 root: true,
4 env: {
5 browser: true,
6 es2022: true,
7 node: true,
8 },
9 extends: [
10 "eslint:recommended",
11 "plugin:@typescript-eslint/recommended",
12 "plugin:react/recommended",
13 "plugin:react-hooks/recommended",
14 "plugin:jsx-a11y/recommended",
15 "prettier", // Must be last to disable conflicting rules
16 ],
17 parser: "@typescript-eslint/parser",
18 parserOptions: {
19 ecmaVersion: "latest",
20 sourceType: "module",
21 ecmaFeatures: { jsx: true },
22 },
23 plugins: ["@typescript-eslint", "react", "react-hooks", "jsx-a11y"],
24 rules: {
25 "react/react-in-jsx-scope": "off",
26 "react/prop-types": "off",
27 "@typescript-eslint/no-unused-vars": [
28 "warn",
29 { argsIgnorePattern: "^_" },
30 ],
31 "no-console": ["warn", { allow: ["warn", "error"] }],
32 },
33 settings: {
34 react: { version: "detect" },
35 },
36 ignorePatterns: ["dist", "build", ".next", "node_modules"],
37};

info

The flat config (eslint.config.js) is the future of ESLint. It uses native arrays instead of inheritance, supports direct import of plugins as ES modules, and eliminates the confusing extends merging behavior. Migrate new projects to flat config.
Rules & Plugins

ESLint rules are categorized by severity: off (0), warn (1), and error (2). Plugins extend ESLint with context-specific rules.

PluginPurposeKey Rules
@typescript-eslintTypeScript-specific lintingno-unused-vars, no-explicit-any, strict-boolean-expressions
eslint-plugin-reactReact best practicesjsx-no-target-blank, no-array-index-key, button-has-type
eslint-plugin-react-hooksHooks correctnessrules-of-hooks, exhaustive-deps
eslint-plugin-jsx-a11yAccessibility checksalt-text, anchor-is-valid, click-events-have-key-events
eslint-plugin-importImport/export validationno-unused-modules, order, no-cycle
eslint-plugin-prettierRuns Prettier as ESLint ruleprettier/prettier
eslint-plugin-simple-import-sortImport sortingsort-imports
eslint-plugin-tailwindcssTailwind class orderingclassnames-order, no-custom-classname
eslint-plugin-testing-libraryRTL best practicesawait-async-queries, no-wait-for-snapshot
eslint-plugin-jest-domjest-dom matcher usageprefer-to-have-text-content, prefer-to-be-visible
rules.js
JavaScript
1// Common rules configuration
2
3const commonRules = {
4 // TypeScript
5 "@typescript-eslint/no-unused-vars": [
6 "error",
7 { argsIgnorePattern: "^_", destructuredArrayIgnorePattern: "^_" },
8 ],
9 "@typescript-eslint/no-explicit-any": "warn",
10 "@typescript-eslint/consistent-type-imports": "error",
11 "@typescript-eslint/no-import-type-side-effects": "error",
12
13 // React
14 "react/jsx-no-target-blank": ["error", { allowReferrer: true }],
15 "react/button-has-type": "error",
16 "react/no-array-index-key": "warn",
17 "react/self-closing-comp": "error",
18
19 // Hooks
20 "react-hooks/rules-of-hooks": "error",
21 "react-hooks/exhaustive-deps": "warn",
22
23 // Accessibility
24 "jsx-a11y/alt-text": "error",
25 "jsx-a11y/anchor-is-valid": "error",
26 "jsx-a11y/click-events-have-key-events": "warn",
27 "jsx-a11y/no-autofocus": "warn",
28
29 // General
30 "no-console": ["warn", { allow: ["warn", "error"] }],
31 "prefer-const": "error",
32 "no-var": "error",
33 "eqeqeq": ["error", "always"],
34 "curly": ["error", "all"],
35 "no-throw-literal": "error",
36 "prefer-template": "warn",
37};

best practice

Use "warn" for stylistic rules and "error" for correctness rules. In CI, treat warnings as failures with --max-warnings=0 to enforce a zero-warning policy. In development, warnings provide helpful guidance without blocking the dev server.
Prettier Configuration

Prettier is an opinionated code formatter that enforces consistent style (spacing, quotes, semicolons, line wrapping). It integrates with ESLint via eslint-config-prettier (disables conflicting ESLint rules) and optionally eslint-plugin-prettier (runs Prettier as a rule).

.prettierrc
JSON
1{
2 "semi": true,
3 "singleQuote": false,
4 "tabWidth": 2,
5 "trailingComma": "all",
6 "printWidth": 80,
7 "bracketSpacing": true,
8 "arrowParens": "always",
9 "endOfLine": "lf",
10 "quoteProps": "as-needed",
11 "jsxSingleQuote": false,
12 "bracketSameLine": false,
13 "embeddedLanguageFormatting": "auto",
14 "htmlWhitespaceSensitivity": "css",
15 "proseWrap": "preserve"
16}
prettier.config.js
JavaScript
1// prettier.config.js — Alternative format
2export default {
3 semi: true,
4 singleQuote: false,
5 tabWidth: 2,
6 trailingComma: "all",
7 printWidth: 80,
8 plugins: [
9 "prettier-plugin-tailwindcss", // Sorts Tailwind classes
10 "prettier-plugin-organize-imports", // Organizes imports
11 ],
12};
13
14// .prettierignore — Files to skip
15// build/
16// dist/
17// .next/
18// node_modules/
19// package-lock.json
20// coverage/
21// *.generated.*
terminal
Bash
1# Format all files
2npx prettier --write .
3
4# Check formatting (CI mode)
5npx prettier --check .
6
7# ESLint + Prettier together
8npx eslint . --fix
9npx prettier --write .
10
11# Or with eslint-plugin-prettier:
12npx eslint . --fix # Prettier runs automatically

warning

Never run Prettier and ESLint with overlapping rule sets without eslint-config-prettier. Without it, ESLint and Prettier will conflict on formatting rules like semi and quotes, leading to lint-fix loops. eslint-config-prettier disables all formatting rules in ESLint so Prettier is the sole authority.
Pre-commit Hooks (Husky + lint-staged)

Husky manages Git hooks, and lint-staged runs linters only on staged files. Together they ensure every commit meets quality standards without slowing down the entire project.

terminal
Bash
1# Install husky
2npm install -D husky
3npx husky init
4
5# Install lint-staged
6npm install -D lint-staged
7
8# .husky/pre-commit — Created by husky init
9npx lint-staged
10
11# Configure lint-staged in package.json
package.json (partial)
JSON
1// package.json — lint-staged configuration
2{
3 "lint-staged": {
4 "*.{ts,tsx,js,jsx}": [
5 "eslint --fix",
6 "prettier --write"
7 ],
8 "*.{json,md,yaml,yml}": [
9 "prettier --write"
10 ],
11 "*.css": [
12 "prettier --write"
13 ]
14 }
15}
16
17// For monorepos, you can scope by directory:
18// {
19// "lint-staged": {
20// "packages/web/**/*.{ts,tsx}": ["eslint --fix", "prettier --write"],
21// "packages/api/**/*.ts": ["eslint --fix", "prettier --write"]
22// }
23// }
terminal
Bash
1# Alternative: lint-staged config file
2# .lintstagedrc.js
3module.exports = {
4 "*.{ts,tsx,js,jsx}": [
5 "eslint --fix",
6 "prettier --write",
7 ],
8 "*.{json,md,yaml,yml,css}": [
9 "prettier --write",
10 ],
11};
12
13# Run only on staged files (dry-run to see what would run)
14npx lint-staged --dry
15
16# Bypass hooks (use sparingly!)
17git commit --no-verify
🔥

pro tip

Add --no-verify to the commit message for rare emergencies only (e.g., fixing a broken CI build). To enforce the hook in CI, run npx lint-staged as a CI step — if lint-staged passes, the hooks would have passed too.
Editor Integration (VS Code)

Configure VS Code to format on save, show lint errors inline, and auto-fix on save. This gives instant feedback without running CLI commands.

.vscode/settings.json
JSON
1// .vscode/settings.json
2{
3 "editor.defaultFormatter": "esbenp.prettier-vscode",
4 "editor.formatOnSave": true,
5 "editor.codeActionsOnSave": {
6 "source.fixAll.eslint": "explicit",
7 "source.organizeImports": "explicit"
8 },
9 "eslint.validate": [
10 "javascript",
11 "javascriptreact",
12 "typescript",
13 "typescriptreact"
14 ],
15 "eslint.useFlatConfig": true,
16 "typescript.preferences.importModuleSpecifier": "shortest",
17 "typescript.updateImportsOnFileMove.enabled": "always",
18 "css.lint.unknownAtRules": "ignore",
19 "files.associations": {
20 "*.css": "tailwindcss"
21 }
22}
23
24// Extensions to install (in .vscode/extensions.json):
25{
26 "recommendations": [
27 "dbaeumer.vscode-eslint",
28 "esbenp.prettier-vscode",
29 "bradlc.vscode-tailwindcss"
30 ]
31}

info

Set "eslint.useFlatConfig": true in VS Code settings to enable flat config support. The ESLint VS Code extension v3.0+ supports both formats, but flat config requires explicit opt-in in the settings until it becomes the default.
ESLint for TypeScript & React

TypeScript and React have specific linting needs. The typescript-eslint project provides TypeScript-aware rules, while React plugins cover JSX correctness and hook rules.

RuleSeverityDescription
@typescript-eslint/no-explicit-anywarnAvoid any — use unknown or specific types
@typescript-eslint/strict-boolean-expressionswarnRequire strict boolean checks (no truthy/falsy)
@typescript-eslint/consistent-type-importserrorUse import type for type-only imports
react/no-array-index-keywarnAvoid array index as key prop
react/button-has-typeerrorButtons must have explicit type attribute
react/jsx-no-leaked-rendererrorPrevent accidental rendering of falsy values (0, NaN)
react-hooks/exhaustive-depswarnVerify hook dependency arrays
tsx-linting.tsx
TypeScript
1// Before linting — problematic patterns
2function UserProfile({ user }: { user: any }) {
3 // ^^^ @typescript-eslint/no-explicit-any
4
5 const [items, setItems] = useState([]);
6
7 useEffect(() => {
8 fetchItems().then(setItems);
9 }, []);
10 // ^^^ react-hooks/exhaustive-deps warning
11
12 return (
13 <div>
14 {items.length && <ItemList items={items} />}
15 // ^^^ react/jsx-no-leaked-render (0 renders as "0")
16
17 {items.map((item, index) => (
18 <div key={index}>{item.name}</div>
19 // ^^^ react/no-array-index-key
20 ))}
21
22 <button onClick={handleSubmit}>Save</button>
23 // ^^^ react/button-has-type (defaults to "submit" in form)
24 </div>
25 );
26}
27
28// After fixing
29function UserProfile({ user }: { user: User }) {
30 const [items, setItems] = useState<Item[]>([]);
31
32 useEffect(() => {
33 fetchItems().then(setItems);
34 }, []);
35
36 return (
37 <div>
38 {items.length > 0 && <ItemList items={items} />}
39
40 {items.map((item) => (
41 <div key={item.id}>{item.name}</div>
42 ))}
43
44 <button type="button" onClick={handleSubmit}>
45 Save
46 </button>
47 </div>
48 );
49}

best practice

Enable strict type checking in your ESLint config: ...tseslint.configs.strict (flat config) or "plugin:@typescript-eslint/strict" (legacy). This enables stricter TypeScript rules that catch subtle bugs like Promise handling, type narrowing, and implicit any in callbacks.
Custom Rules

Create custom ESLint rules to enforce project-specific conventions. Rules are functions that traverse the AST and report violations.

custom-rule.js
TypeScript
1// eslint-rules/no-barrel-imports.js
2// Custom rule: forbid barrel imports (index.ts) in specific contexts
3
4export default {
5 meta: {
6 type: "suggestion",
7 docs: {
8 description: "Forbid barrel imports from feature directories",
9 recommended: false,
10 },
11 schema: [
12 {
13 type: "object",
14 properties: {
15 directories: {
16 type: "array",
17 items: { type: "string" },
18 },
19 },
20 additionalProperties: false,
21 },
22 ],
23 messages: {
24 noBarrel:
25 "Avoid barrel imports from '{{path}}'. Import directly from the module file.",
26 },
27 },
28 create(context) {
29 const directories = context.options[0]?.directories ?? [];
30
31 return {
32 ImportDeclaration(node) {
33 const source = node.source.value;
34
35 for (const dir of directories) {
36 if (source.startsWith(dir)) {
37 context.report({
38 node,
39 messageId: "noBarrel",
40 data: { path: source },
41 });
42 return;
43 }
44 }
45 },
46 };
47 },
48};
49
50// Usage in eslint.config.js:
51// {
52// plugins: { "custom": customPlugin },
53// rules: {
54// "custom/no-barrel-imports": ["error", {
55// directories: ["features/", "pages/"]
56// }],
57// },
58// }
🔥

pro tip

Use @typescript-eslint/utils to write custom rules in TypeScript with full type checking. The RuleCreator utility provides type-safe rule creation. Test custom rules with @typescript-eslint/rule-tester for confidence.
Package Scripts & CI

Standardize linting and formatting commands in package.json for consistent execution across environments.

package.json (scripts)
JSON
1// package.json scripts
2{
3 "scripts": {
4 "lint": "eslint . --max-warnings=0",
5 "lint:fix": "eslint . --fix",
6 "format": "prettier --write .",
7 "format:check": "prettier --check .",
8 "typecheck": "tsc --noEmit",
9 "lint-staged": "lint-staged",
10 "prepare": "husky"
11 }
12}
13
14// CI pipeline integration:
15// 1. npm run format:check
16// 2. npm run lint
17// 3. npm run typecheck
18// 4. npm run test
19// 5. npm run build
ci.yml
YAML
1# .github/workflows/lint.yml
2name: Lint & Format Check
3on:
4 push:
5 branches: [main]
6 pull_request:
7
8jobs:
9 quality:
10 runs-on: ubuntu-latest
11 steps:
12 - uses: actions/checkout@v4
13 - uses: actions/setup-node@v4
14 with:
15 node-version: 20
16 cache: "npm"
17 - run: npm ci
18 - run: npm run format:check
19 - run: npm run lint
20 - run: npm run typecheck

best practice

Run format:check before lint in CI. Prettier check is fast and catches formatting issues early. If formatting fails, the developer knows before hitting lint errors. Never run --write in CI — it modifies files without committing them.
$Blueprint — Engineering Documentation·Section ID: LNT-01·Revision: 1.0