The Spots That Quietly Collapse When You Drop eslintrc for Flat Config
When migrating from eslintrc to flat config, what breaks most often isn't the rules but plugin compatibility and the order in which configs are merged. As string-based extends disappears and gives way to arrays of objects, plugin registration, ignores handling, and editor integration all change. This article points out the places that fail silently during real migrations and offers criteria for deciding what to move first and what to throw away.
Published by DevInsight.
There are situations where, after upgrading to ESLint 9, CI is green but red squiggles remain in the editor. The opposite is also common. The editor stays quiet while lint run from the command line spews new errors. It isn't that the rules broke. It's that the same lint is being run in two different worlds. eslintrc and flat config differ not just in file name but in the very path by which config is read and assembled. So this migration is less about moving a list of rules and more about redrawing the order in which config is built.
The moment a rule dies is usually silent
The most dangerous failure isn't one that throws an error. If a plugin isn't loaded, ESLint skips those rules without any warning. Even if react-hooks/rules-of-hooks is missing, lint passes. Call a hook inside a conditional, or empty a dependency array, and nothing happens. Since lint is green, it's easy to assume all is well and move on.
The way to confirm this silence is to count rules. Dumping the config with npx eslint --print-config src/App.tsx shows every rule actually applied to a given file. Diffing this against the previous version's output immediately reveals which rules disappeared. Making a file that deliberately violates a rule and checking whether it gets caught is also fast. The criterion for the first step of migration isn't "does lint run" but "are the intended rules actually firing."
What the extends string used to do
In eslintrc, listing names like extends: ["airbnb", "plugin:react/recommended"] let ESLint handle the rest of the interpretation. Flat config has no such key. You list config objects directly, and shared configs are spread into that array. Strings like plugin:react/recommended don't work.
If a plugin ships flat support, you put an object like react.configs.flat.recommended into the array. If it doesn't yet, there's a workaround of converting the old config with FlatCompat from the @eslint/eslintrc package and inserting it. But FlatCompat isn't a universal adapter. Converting a config with overrides and ignorePatterns mixed together makes file matching subtly off. This is why, even after finishing the conversion, you re-check the rule set for a specific file with --print-config. Shared configs whose support has been cut off will linger on this path for a long time, so it's better to switch to a maintained alternative.
Plugins come in as objects, not names
In eslintrc, plugins: ["react"] was a list of strings. In flat config, you import the plugin module and put it in as an object. The problem is the namespace. If you register it with plugins: { react }, the rule reference also becomes react/jsx-.... Change the key name and the rule id changes entirely along with it. The same goes for scoped packages like @typescript-eslint. Using the flat preset a plugin provides handles this registration automatically, but when listing rules by hand you have to visually match the key against the rule prefix.
Parser location moved too. In eslintrc, parser and parserOptions go under languageOptions in flat config. ecmaVersion, sourceType, and globals gather in the same place. The env key is gone. env: { browser: true } changes to directly spreading globals.browser into languageOptions.globals. Skip this conversion and globals like document or window get caught by no-undef. In projects with type-aware linting enabled, option names like languageOptions.parserOptions.projectService also changed between versions, so aligning to the flat examples in the typescript-eslint docs is safer.
The array's order is the merge order
In flat config, config accumulates from top to bottom, and redefining the same rule later means the later one wins. The priority that eslintrc's extends set implicitly is now expressed directly through array order. Put base config first, then framework and type config, and finally project-specific overrides. Get the order wrong and recommended overrides the rule you explicitly set.
import js from "@eslint/js"; import globals from "globals"; import tseslint from "typescript-eslint"; export default [ { ignores: ["dist/**", "coverage/**"] }, js.configs.recommended, ...tseslint.configs.recommended, { files: ["src/**/*.{ts,tsx}"], languageOptions: { globals: globals.browser }, rules: { "no-console": "warn" }, }, ];
Presets spread with a spread operator like ...tseslint.configs.recommended contain multiple objects internally, so putting this later pushes out the preceding config entirely. This is why presets go first and hand-written exceptions go after. Modules that replace the old env, like the globals package, are added by picking only the environments you need.
With ignores, it's all about where you put it
eslintrc's ignorePatterns applied uniformly across the whole config. Flat config's ignores changes meaning depending on position. An object with only ignores and no files acts as a global ignore. When together with files, it excludes only from that file set. Not knowing this difference means the target of inspection is either emptied entirely or, conversely, a dist you meant to ignore is still inspected under some config. The .eslintignore file is also not read by default in flat config. The ignore list must be moved to a global ignores entry near the front of the config array.
The CLI side needs work too. --ext is gone, so extension specification is absorbed by files patterns. --ignore-path is gone as well. If these flags remain in package.json scripts, they're either silently ignored or cause an error.
The editor may be looking at a different ESLint
The VS Code ESLint extension supports flat config differently depending on version. If the extension is old or the flat config flag is off, it can't find the root eslint.config.js and goes looking down the old .eslintrc path. This is the point where CI and editor results diverge. Three things to check: the extension version, the eslint.useFlatConfig setting, and the ESLint version the extension actually loaded. In a monorepo, ESLint may be installed separately per workspace, causing the editor to pick up the wrong version. Lining up npx eslint --version against the path logged by the extension reveals the cause quickly.
The order of moving
Changing everything at once makes it impossible to trace where things went wrong. I recommend this order.
- Leave the old config as is and create one flat file. Wrap the existing eslintrc entirely with
FlatCompat. The goal at this stage is rule parity, and no new rules are added. - For a few key files, pull
--print-configoutput and diff it against the previous version. The rules that differ are what FlatCompat missed. - Replace plugins one by one with flat-native config. Re-run the diff after each replacement.
- Finally, tidy up
ignores, parser, and globals, and delete.eslintignore.
If you reverse the order and add new rules first, you can't tell whether broken rule parity is the cause or whether a new rule caught it.
What to discard and what to keep
There's one criterion. Does the plugin whose support was cut off actually deliver value on that project. An eslintrc-only plugin that stopped being maintained is better removed while you're at it during the flat migration. Even if you force-wrap it with FlatCompat, the risk of rules silently dropping during conversion remains. Conversely, for a small plugin that only uses a few rules, registering it directly as an object finishes things cleanly without depending on FlatCompat.
The migration is finished only when you've confirmed that the editor shows the same result as CI. Green in CI alone is only half.
Comments
Loading comments.
Good Follow-up Reads
Posts connected to the topic you just read.
ESLint Flat Config 마이그레이션 실패 일지와 살아남는 체크리스트
ESLint 9의 flat config로 넘어가면서 extends가 사라지고, 플러그인 호환성 문제, VS Code ESLint 확장과의 설정 불일치, 글로벌 변수 선언 방식 변화 등 현장에서 마주치는 장애물을 해결 순서대로 정리한다. 삽질을 줄이는 실전 체크리스트. 2025년 4월, ESLint 9가 정식 릴리스되면서 파일은 deprecated 경고를 넘어 아예 무시되기 시작했다.
A Week Facing 2,731 Type Errors: The Reality Between Flipping strict On and Off
Enabling TypeScript's strict-family options all at once typically floods the build with thousands of type errors and stalls work. Based on a real project case, this compares each option's migration difficulty and blast radius, and lays out a step-by-step roadmap covering which order is safe to enable, what to fix automatically, and what has to be reviewed by hand.
Only after deleting node_modules twice did I take another look at package managers
Choosing a package manager is a balancing act between three axes: install speed, disk usage, and phantom dependencies. In a standalone repo, even a careless choice rarely causes problems, but once you move to a monorepo, differences in hoisting strategies show up directly as CI time and disk blowups. This compares how npm, pnpm, and yarn each store and share dependencies, and lays out a decision flow for choosing a tool based on real conditions like team size and monorepo maturity.
Previous post
When Search Keeps Pulling the Wrong Documents, What to Suspect Before Embeddings
DevInsight Digest
Keep every new article in one calm feed.
Follow the full publication feed without promotional alerts.