ESLint 9 flat config replaces .eslintrc.json and its implicit directory-cascading resolution with eslint.config.js, a single exported array of config objects scoped by explicit files globs. I read the full ESLint history of two repos for this piece, and got two different stories out of them. In one there was nothing to convert: no .eslintrc had ever existed, and flat config went in greenfield after a couple of Dependabot alerts pushed linting up the queue. In the other the migration was real, a legacy config with sixteen months of history, deleted by the same commit that brought ESLint 9 in.
Neither migration was the hard part. Both took a single session, with an agent doing most of the mechanical work. What took months to notice is that a config which runs is not a config that works, and the TypeScript half of flat config has a specific way of failing that exits clean.
Quick take: ESLint 9 requires flat config (
eslint.config.js), a single exported array of config objects with explicitfilesglobs, replacing.eslintrc's implicit directory cascade. Moving the rules across is mechanical. The two things that bite:extendsbundled parser setup with rule sets, so a config can register@typescript-eslint/parserand still run zero@typescript-eslintrules, and type-aware rule sets need an explicitparserOptions.projectthe old format never asked for.
What Did the Old Config Look Like?
// .eslintrc.json (ESLint 8 and earlier)
{
"parser": "@typescript-eslint/parser",
"plugins": ["@typescript-eslint"],
"extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended"],
"rules": {
"@typescript-eslint/no-unused-vars": "error",
"no-console": "warn"
}
}
Plus a separate .eslintignore, which flat config no longer reads at all. A rule could be active because of four extends entries across nested directories, and short of eslint --print-config on one file, nothing showed you the merged rule set.
Notice the plugins array. It's doing real work, and it's the field people drop.
What Does the Flat Config Equivalent Look Like?
// eslint.config.ts (or .js)
import tseslint from 'typescript-eslint';
import js from '@eslint/js';
export default tseslint.config(
{ ignores: ['dist/', 'node_modules/'] },
js.configs.recommended,
...tseslint.configs.recommended,
{
files: ['**/*.ts', '**/*.tsx'],
rules: {
'@typescript-eslint/no-unused-vars': 'error',
'no-console': 'warn',
},
},
);
The ...tseslint.configs.recommended spread brings in three things at once, parser setup, plugin registration, and the rule set, replacing what used to be one "extends" string. tseslint.config() itself is optional, but it type-checks the config object, so typos fail at compile time instead of silently at lint time.
The Trap: A Parser Without a Plugin
A blog project arrived in the first monorepo as a subtree merge, carrying its own eslint.config.ts and its own lint script. Abridged:
const config: Linter.FlatConfig[] = [
{
files: ['**/*.ts', '**/*.tsx'],
languageOptions: {
parser: tsParser, // parser registered
}, // plugin never registered
rules: {
...js.configs.recommended.rules,
'@typescript-eslint/no-explicit-any': 'off',
'no-console': 'error',
},
},
];
Setting languageOptions.parser only teaches ESLint how to read TypeScript. It adds no rules. Without a plugins entry or a tseslint.configs.* spread, the @typescript-eslint namespace doesn't exist there, so every rule under it resolves to nothing, and ESLint stays quiet because a rule set to 'off' is never looked up.
I ran the same directory through both configs:
| Workspace config | Root config | |
|---|---|---|
| Files linted | 21 | 45 |
| Problems reported | 8 | 30 |
Real @typescript-eslint findings | 0 | 12 |
| Run time | 2.7 s | 13.2 s |
The workspace run misses 17 no-var violations, 3 unused variables, and a stray non-null assertion. It also skips every .astro file, because its only globs are **/*.ts and **/*.tsx. Six of the 8 problems it did report were this:
src/plugins/remark-images.ts:1
@typescript-eslint/no-explicit-any
Definition for rule '@typescript-eslint/no-explicit-any' was not found.
Seven files carry a /* eslint-disable @typescript-eslint/no-explicit-any */ on line 1, suppressing a rule that isn't loaded. That message is the entire visible symptom of half the linter being absent, and it reads like a typo in a comment. The comment has outlived the rule it suppresses, and no run in between said so.
The Linter.FlatConfig[] annotation is a second tell. ESLint's own types mark it deprecated: use Config instead. A config still reaching for FlatConfig was written against an older ESLint and never revisited.
Why Does extends Not Map One-to-One?
The parser-without-plugin case is the loud version of a general problem: extends bundled things flat config makes you name separately. plugin:@typescript-eslint/recommended pulled in parser setup and a rule set. Those are two jobs now.
Type-aware rules have the same gap. If your old .eslintrc extended plugin:@typescript-eslint/recommended-requiring-type-checking, there's no automatic equivalent, you need recommendedTypeChecked plus a parserOptions.project pointer:
export default tseslint.config(
...tseslint.configs.recommendedTypeChecked, // was "recommended-requiring-type-checking"
{
languageOptions: {
parserOptions: { project: './tsconfig.json', tsconfigRootDir: import.meta.dirname },
},
},
);
That pointer has a failure mode of its own. Aim project at a tsconfig.json that doesn't cover every file and the files it missed don't get skipped, they die: "parserOptions.project" has been provided for @typescript-eslint/parser. The file was not found in any of the provided projects. In the second repo, 18 files sat outside every project that way, all 18 fatal, and the local lint script had been red for months.
The first repo took strict and skipped type-checked. Not because type-aware rules aren't worth it. Parts of that repo are older untyped code, and wiring per-project tsconfig.json paths across all of it is more than an evening's work. That's debt rather than principle, and it's worth writing down as debt. Without type-checked the root run covers 709 files in 25.2 seconds.
When Is a Migration Actually Finished?
Not the day it runs. That second repo moved to flat config about twenty-one months ago, and its config still stands on the compatibility layer that exists to wrap old-format config. It works, and every check passes. So is that migration done? On paper it closed twenty-one months ago. In practice nothing has ever been rewritten in the format the repo nominally moved to, and a shim meant as a bridge is now load-bearing.
Old Way vs Modern Way
| Task | .eslintrc (ESLint 8) | Flat config (ESLint 9) |
|---|---|---|
| Extend a shared config | "extends": ["plugin:x/recommended"] | ...xConfigs.recommended spread into the array |
| Register a plugin | "plugins": ["@typescript-eslint"] | plugins: {} entry, or a tseslint.configs.* spread |
| Scope rules to a file type | Nested .eslintrc per directory | files glob on a config object |
| Ignore patterns | .eslintignore | ignores array inside eslint.config.js |
| TypeScript parser setup | "parser": "@typescript-eslint/parser" string | tseslint.config() helper handles it |
What I'd Check After Migrating
Three commands, before you trust the green run, or one run of the lint coverage audit, which checks all three and a few more:
--print-configon one real.tsfile, and count the@typescript-eslintkeys. If the config mentions those rules but the count is zero, the plugin was never registered.- Grep the old
.eslintrcfor everyextendsentry containing "type-checking" or "type-checked", and confirm each has an explicitrecommendedTypeCheckedorstrictTypeCheckedequivalent. - Run the workspace's own
lintscript, not only the root one. Different scope, different config, possibly red since the day it was written.
A silently dropped rule set never throws. It just stops protecting you, and the build stays green the whole time, which is why "the build is green" stopped being an answer I accept.