ESLint Complete Guide | Flat Config· Rules
이 글의 핵심
ESLint 9 ships a new flat config system (eslint.config.js) that replaces .eslintrc. This guide covers migration, TypeScript-ESLint setup, React rules, custom rules, Prettier integration, and enforcement in CI.
ESLint is the de facto standard for catching bugs and enforcing style in JavaScript and TypeScript codebases. Version 9 made flat config the default, which is simpler to reason about than the old cascade of .eslintrc files, but the ecosystem is still catching up. This guide explains how ESLint actually works under the hood, how to pick a config strategy, and what I have seen go wrong in real monorepos and CI pipelines.
How ESLint is structured: AST, rules, and plugins
Parsing. ESLint does not “understand” your app as a whole. It runs a parser (by default espree for JS) on each file and builds an AST (abstract syntax tree): a tree of nodes (Program, FunctionDeclaration, Literal, CallExpression, and so on). Rules walk that tree; they are not a type checker unless you add TypeScript-aware tooling.
Rules. A rule is a function with a create(context) method that returns visitor functions keyed by node types, for example Identifier(node) { ... }. When the AST is traversed, each visitor runs for matching nodes. The rule can call context.report({ node, message }) to flag an issue, optionally with a fix function for --fix. That is why many stylistic rules are cheap, while some TypeScript rules are expensive: they may need the type checker, not just the AST.
Plugins. A plugin is a package that exports a rules object (and optionally configs). In flat config you import the plugin and register it in plugins: { name: plugin } — no more magic string resolution from node_modules. Sharable configs (like eslint-config-airbnb) are just arrays of config objects; typescript-eslint wraps this in tseslint.config() to flatten and merge them safely.
Honest take: people treat ESLint as a second compiler. It is not. For correctness you still need TypeScript, tests, and code review. ESLint is best at consistent patterns, likely mistakes (no-constant-condition), and team conventions.
Config strategy: legacy .eslintrc vs flat config
| Aspect | .eslintrc.* (legacy) | Flat (eslint.config.js) |
|---|---|---|
| Format | JSON / YAML / JS with extends | JS/TS that exports an array of config objects |
| Merging | Cascading directories (often confusing) | Explicit order: later entries override earlier ones |
| Plugins | String names resolved implicitly | Explicit import and plugins map |
| Ignores | .eslintignore + ignorePatterns | ignores in config objects (or eslint --ignore-path in older flows) |
When to stay on legacy: a brownfield repo where many tools still emit .eslintrc only, and migration time is not budgeted. ESLint 8 can use flat; ESLint 9 prefers flat.
When to use flat: all new projects, and any time you are touching lint setup anyway. The migration helper helps:
npx @eslint/migrate-config .eslintrc.json
# Produces an eslint.config.mjs you can hand-tune
Monorepo tip: one root eslint.config.js with files: ['apps/web/**/*', 'packages/*/**/*'] and per-package overrides as separate array items is usually clearer than nested .eslintrc files that no one can trace.
Setup: ESLint 9 flat config
# Initialize ESLint (generates eslint.config.js)
npm init @eslint/config@latest
# Or install manually
npm install -D eslint
Flat config (eslint.config.js) — full baseline
// eslint.config.js — ESLint 9 default format
import js from '@eslint/js';
import globals from 'globals';
export default [
// Apply recommended JS rules globally
js.configs.recommended,
{
// Files this config applies to
files: ['**/*.{js,mjs,cjs,jsx}'],
// Language options
languageOptions: {
ecmaVersion: 2024,
sourceType: 'module',
globals: {
...globals.browser,
...globals.node,
},
},
// Custom rules
rules: {
'no-console': 'warn',
'no-unused-vars': 'error',
'eqeqeq': ['error', 'always'],
},
},
// Ignore patterns
{
ignores: ['dist/**', 'node_modules/**', '*.min.js'],
},
];
TypeScript integration (typescript-eslint)
Use the unified typescript-eslint package (replaces the older split of @typescript-eslint/parser + @typescript-eslint/eslint-plugin for setup purposes).
npm install -D typescript-eslint
Type-aware rules need parserOptions.project pointed at a tsconfig that actually includes the linted files. If your CI only uses a loose tsconfig for build, you may need a tsconfig.eslint.json that widens include so rules like no-floating-promises can see the whole graph.
// eslint.config.js — with TypeScript
import js from '@eslint/js';
import tseslint from 'typescript-eslint';
export default tseslint.config(
js.configs.recommended,
// TypeScript rules
...tseslint.configs.recommended,
// Or stricter: tseslint.configs.strict
{
files: ['**/*.{ts,tsx}'],
languageOptions: {
parserOptions: {
project: './tsconfig.json', // Or ./tsconfig.eslint.json
tsconfigRootDir: import.meta.dirname,
},
},
rules: {
// Type-aware rules (require project: tsconfig)
'@typescript-eslint/no-floating-promises': 'error', // Catch unhandled async
'@typescript-eslint/no-misused-promises': 'error',
'@typescript-eslint/await-thenable': 'error',
// Overrides from recommended
'@typescript-eslint/no-explicit-any': 'warn', // Allow any with warning
'@typescript-eslint/no-unused-vars': [
'error',
{
argsIgnorePattern: '^_', // Allow _prefix for unused args
varsIgnorePattern: '^_',
},
],
},
},
{
ignores: ['dist/**', '*.js', '*.mjs'],
},
);
Practical note: on huge repos, type-aware lint can dominate CI time. It is often worth enabling the strictest rules on src/ only, or using parserOptions.projectService (when available in your typescript-eslint version) to reduce full-program overhead.
Prettier: integrate without fighting ESLint
Prettier and ESLint both care about code shape; eslint-config-prettier turns off ESLint stylistic rules that duplicate Prettier. Apply it last in the flat config array so its disables win.
npm install -D prettier eslint-config-prettier
// eslint.config.js — add prettier last (disables conflicting rules)
import prettier from 'eslint-config-prettier';
import tseslint from 'typescript-eslint';
// ... other imports
export default tseslint.config(
// ... other configs
prettier, // Must be LAST — disables ESLint formatting rules
);
Do not run Prettier as an ESLint rule in large projects unless you have a good reason. eslint-plugin-prettier is convenient for demos, but in production it mingles formatting with lint errors and slows the loop. I standardize on: ESLint for code quality, Prettier for formatting, both in package.json scripts and both on save in the editor.
// .prettierrc
{
"semi": true,
"singleQuote": true,
"trailingComma": "es5",
"printWidth": 100,
"tabWidth": 2
}
// package.json scripts
{
"scripts": {
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"format": "prettier --write .",
"format:check": "prettier --check ."
}
}
React, Vue, and Svelte — framework-specific setup
React (and React + TypeScript)
npm install -D eslint-plugin-react eslint-plugin-react-hooks eslint-plugin-jsx-a11y
// eslint.config.js — with React
import react from 'eslint-plugin-react';
import reactHooks from 'eslint-plugin-react-hooks';
import jsxA11y from 'eslint-plugin-jsx-a11y';
import tseslint from 'typescript-eslint';
export default tseslint.config(
// ... base + TS
{
files: ['**/*.{jsx,tsx}'],
plugins: {
react,
'react-hooks': reactHooks,
'jsx-a11y': jsxA11y,
},
settings: {
react: { version: 'detect' },
},
rules: {
...react.configs.recommended.rules,
'react/react-in-jsx-scope': 'off', // Not needed with React 17+
'react/prop-types': 'off', // Use TypeScript instead
...reactHooks.configs.recommended.rules,
'react-hooks/exhaustive-deps': 'warn',
...jsxA11y.configs.recommended.rules,
},
},
);
react-hooks/exhaustive-deps will annoy you in data-fetching code. That is the point — the warning is often correct, but sometimes the closure semantics are intentional. I leave it at warn and document rare suppressions.
Vue 3 (with eslint-plugin-vue)
npm install -D eslint-plugin-vue
import pluginVue from 'eslint-plugin-vue';
import tseslint from 'typescript-eslint';
export default tseslint.config(
// ... js + tseslint recommended
...pluginVue.configs['flat/recommended'],
{
files: ['**/*.vue'],
languageOptions: {
parserOptions: { parser: tseslint.parser },
},
},
);
For .vue files, the vue-eslint-parser splits template, script, and style blocks; combine with typescript-eslint for typed <script setup lang="ts">.
Svelte (with eslint-plugin-svelte)
npm install -D eslint-plugin-svelte
Svelte 5 and runes are still evolving; pin compatible versions of svelte, svelte-eslint-parser, and the plugin, and add a files: ['**/*.svelte'] block. Use the official flat config example from the plugin docs as a template — the parser wiring is easy to get wrong the first time.
Experience: in mixed repos (React + shared UI package), I split by files glob so framework plugins do not parse unrelated file types, which also speeds up runs.
Custom rules: a practical authoring guide
- Start from a need — repeated code review comments (“use our logger, not
console”) are good rule candidates. - Name and scope the rule — one responsibility per rule; combine into a plugin if you have many.
- Use AST selectors — prefer visiting
CallExpressionwithcalleechecks over scanning everyLiteral. - Document
meta.messagesandfixcarefully — autofixes that change behavior are worse than no fix.
Example: flag hardcoded production URLs
// rules/no-hardcoded-urls.js
export default {
meta: {
type: 'problem',
docs: {
description: 'Disallow hardcoded production URLs in source code',
},
schema: [],
},
create(context) {
return {
Literal(node) {
if (typeof node.value === 'string' && node.value.includes('https://api.myapp.com')) {
context.report({
node,
message: 'Hardcoded production URL. Use process.env.API_URL instead.',
});
}
},
};
},
};
Registering in flat config
// eslint.config.js
import noHardcodedUrls from './rules/no-hardcoded-urls.js';
export default [
{
plugins: {
local: { rules: { 'no-hardcoded-urls': noHardcodedUrls } },
},
rules: {
'local/no-hardcoded-urls': 'error',
},
},
];
For testability, I extract rule logic into a pure helper and use RuleTester from eslint in a small *.test.js file.
Key rules to take seriously
Critical (error) rules
rules: {
'no-unused-vars': 'error',
'no-undef': 'error',
'eqeqeq': ['error', 'always'],
'no-var': 'error',
'no-eval': 'error',
'no-implied-eval': 'error',
}
With TypeScript, disable base no-unused-vars and use @typescript-eslint/no-unused-vars to avoid double reporting.
Quality (warning) rules
rules: {
'no-console': ['warn', { allow: ['warn', 'error'] }],
'prefer-const': 'warn',
'no-shadow': 'warn',
'max-lines-per-function': ['warn', { max: 50 }],
}
CI/CD integration
Fail the build on lint errors, not necessarily on every warning (team policy). Cache dependencies and optionally ESLint cache in GitHub Actions.
# .github/workflows/lint.yml
name: lint
on: [push, pull_request]
jobs:
lint:
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
Parallelization: in large monorepos, tools like Nx, Turborepo, or Bazel can shard lint by project. If you are not using a monorepo tool, you can at least run eslint in parallel on disjoint globs, but be careful: overlapping globs and cache files can cause races. Simpler is one eslint . with cache enabled.
PR gates: I require green lint for merge, and run the same job on main so broken commits are rare.
Performance: caching and parallel runs
eslint --cache --cache-location .eslintcache: speeds up repeated local and CI runs. Add.eslintcacheto.gitignore.- Narrow
files: do not lintdist/,build/, or generated*.d.tstrees if you can avoid it — useignores. - Type-aware rules: the slowest part on TS projects. Use a dedicated
tsconfig.eslint.jsonwith minimalinclude, or limit strict rules to critical paths. - Workers: ESLint 9 parallelizes some work internally; for repo-level parallelism, use your task runner rather than forking
eslintblindly.
Real project notes (what actually happens)
- Monorepos: a single root config with per-package
filesoverrides worked better for us than per-packageeslintthat drifted. We added a “lint” task at root that every package inherits. - Generated code: either
ignoresfor generated directories or a one-line/* eslint-disable */at the top of generated files with a clear banner — reviewers know not to hand-edit. - Legacy
any: gradual typing beats a big-bang. We set@typescript-eslint/no-explicit-anytowarnand ratchet down over sprints. - Biome in the same repo? I have only used Biome for format/lint in greenfield libs; for React+TS+plugins, ESLint still wins on rule breadth.
Migration sanity check: after converting to flat config, run npx eslint --print-config path/to/file.tsx to verify merged rules. If something feels wrong, the order of config array entries is the first place to look.
Inline suppressions (use rarely, document always)
// Disable for entire file
/* eslint-disable */
// Disable specific rule for file
/* eslint-disable @typescript-eslint/no-explicit-any */
// Disable for next line
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const data: any = JSON.parse(raw);
/* eslint-disable no-console */
console.log('intentional debug log');
/* eslint-enable no-console */
// Always explain WHY
// eslint-disable-next-line @typescript-eslint/no-explicit-any
// Third-party library returns untyped data — typed at boundary
const result: any = thirdPartyLib.getData();
VS Code integration
// .vscode/settings.json
{
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
},
"eslint.validate": [
"javascript",
"javascriptreact",
"typescript",
"typescriptreact"
]
}
Install ESLint (dbaeumer.vscode-eslint) and Prettier (esbenp.prettier-vscode).
Quick reference: legacy vs flat
# Automated migration
npx @eslint/migrate-config .eslintrc.json
// .eslintrc.json (old)
// { "extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended"], ... }
// eslint.config.js (new) — explicit imports, flat array
import js from '@eslint/js';
import tseslint from 'typescript-eslint';
export default tseslint.config(
js.configs.recommended,
...tseslint.configs.recommended,
{ rules: { 'no-console': 'warn' } },
);
Related posts
- [TypeScript 5 Complete Guide](/en/blog/typescript-5-complete-guide-en/
- [Vite Complete Guide](/en/blog/vite-complete-guide/
- [GitHub Actions CI/CD Guide](/en/blog/github-actions-complete-guide/
On-page Q&A (expanded)
When do I use this in day-to-day work?
When you add a package, change build tooling, or onboard developers — ESLint is the contract for how code should look and which foot-guns are banned. The examples above are copy-pastable; tune rule severity to your team’s tolerance for warnings in CI.
What should I read first?
If you are new to the stack, read the [TypeScript 5 guide](/en/blog/typescript-5-complete-guide-en/ and [Vite guide](/en/blog/vite-complete-guide/ for how modules and tooling connect; then wire ESLint in one pass so you do not chase parser mismatches later.
Where do I go deeper?
- ESLint official documentation — flat config, rule reference, and migration notes.
- typescript-eslint — type-aware rules and parser options.
More internal links (same series)
- TypeScript 5 | Decorators and const type parameters
- Vite | ESM, HMR, plugins, and optimization
- GitHub Actions | Workflows, CI, and deployment
Keywords (search)
ESLint, JavaScript, TypeScript, code quality, linting, flat config, Prettier, CI, React, Vue, Svelte, TypeScript-ESLint.