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. ESLint 9 made it the default; ESLint 10 deleted the old format. This monorepo has two flat configs: a root one built on typescript-eslint, and an older one inside a single blog workspace. I ran them against the same directory to write this piece, and they disagree about what counts as a finding. Both were measured on 2026-09-29 with ESLint 10.10.0, and every number below is one you can reproduce with the commands shown.
Moving rules across is the mechanical part. The part people miss 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.
What Did the Old Config Look Like?
// .eslintrc.json (ESLint 8 and earlier; deprecated in 9, removed in 10)
{
"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 { defineConfig, globalIgnores } from 'eslint/config';
import js from '@eslint/js';
import tseslint from 'typescript-eslint';
export default defineConfig([
globalIgnores(['dist/', 'node_modules/']),
{
files: ['**/*.ts', '**/*.tsx'],
extends: [js.configs.recommended, tseslint.configs.recommended],
rules: {
'@typescript-eslint/no-unused-vars': 'error',
'no-console': 'warn',
},
},
]);
Putting tseslint.configs.recommended in extends brings in three things at once, parser setup, plugin registration, and the rule set, replacing what used to be one "extends" string. defineConfig() ships with ESLint itself, and extends inside it is scoped by the files on the same object. The older tseslint.config() wrapper still works, but typescript-eslint's docs now mark it deprecated in favor of defineConfig(). Either way the helper type-checks the config object, so typos fail at compile time instead of silently at lint time.
One catch if you write the file as eslint.config.ts: on Node.js ESLint asks for jiti 2.2.0 or newer as a dev dependency, or, on Node.js 22.13.0 and up, the unstable_native_nodejs_ts_config flag with type stripping turned on. ESLint doesn't type-check that file for you.
A config that runs is not a config that works. Flat config can register the TypeScript parser, skip the plugin, and report success while running zero TypeScript rules.
What Is the Parser Without a Plugin Trap?
One blog workspace here carries its own eslint.config.ts and its own lint script, separate from the root config. 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 blogs/real-smart-home/src through both configs, the workspace one with eslint src from inside the workspace and the root one with eslint -c eslint.config.ts from the repo root:
| Workspace config | Root config | |
|---|---|---|
| Files linted | 27 | 53 |
| Problems reported | 8 | 40 |
Real @typescript-eslint findings | 0 | 19 |
| Run time | 1.6 s | 5.1 s |
The workspace run misses 20 no-var violations, 4 unused variables, and 4 non-null assertions. 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.
Six 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 },
},
},
);
Current typescript-eslint docs lead with projectService: true for this and call project the older option, so new configs should start there. If you stay on project, or inherit a config that uses it, mind the failure mode below. 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. Every file outside every project fails that way, so one gap in the tsconfig.json include list turns a lint script red instead of merely thin. With projectService the same gap reads was not found by the project service, and the fixes are the same family: add the file to include, or list a few strays under projectService.allowDefaultProject.
The root config here takes strict and skips type-checked, and its own header comment says why: no per-project tsconfig.json wiring. That's not because type-aware rules aren't worth it. Wiring a project pointer across eight workspaces is more than an evening's work, so it's debt rather than principle, and it's worth writing down as debt. Without type-checked, the root run covers 1,978 files in about 22 seconds.
When Is a Migration Actually Finished?
Not the day it runs. ESLint ships a compatibility layer, FlatCompat in @eslint/eslintrc, that wraps old-format config so it loads in flat mode. It works, and ESLint 10 still documents it even though it dropped eslintrc loading. Every check passes. So is the migration done? On paper, yes. In practice, if nothing has been rewritten in the format the repo nominally moved to, a shim meant as a bridge has become load-bearing. I'd count a migration as finished only when the shim is gone, and I'd say so out loud when it isn't.
Old Way vs Modern Way: What Changed?
| Task | .eslintrc (ESLint 8; deprecated in 9, removed in 10) | Flat config (ESLint 9 and 10) |
|---|---|---|
| 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 | globalIgnores() or an ignores-only object in eslint.config.js |
| TypeScript parser setup | "parser": "@typescript-eslint/parser" string | a tseslint.configs.* entry in extends handles it |
What Changed in ESLint 10?
The flat config steps above hold on both majors. The differences are around the edges, and all of them come from ESLint's v10 migration guide:
- eslintrc is gone.
.eslintrc.*files and theESLINT_USE_FLAT_CONFIGenvironment variable no longer do anything. On ESLint 9 you could still opt back in withESLINT_USE_FLAT_CONFIG=false; on 10 that door is closed, so a repo can't stay half-migrated. - Node.js floor. Version 20.19.0 or newer, 22.13.0 or newer, or 24 and up. Node 21 and 23 are out.
- Config lookup is per file. ESLint now starts from the directory of each linted file and walks up to the first
eslint.config.*, not from the working directory. In a monorepo with a workspace config like the one above, a file is linted by its nearest config, whichever directory you ran the command from. eslint:recommendedgrew. Three rules joined it:no-unassigned-vars,no-useless-assignmentandpreserve-caught-error. A clean ESLint 9 run can show new findings after the upgrade with no config change.
typescript-eslint's peer range accepts ESLint ^8.57.0 || ^9.0.0 || ^10.0.0 as of version 8.71.1, so the TypeScript half needs no separate move.
What Should You 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 that are not set tooff. Here the workspace config resolves 20 such keys, alloff(eslint-config-prettierswitches them off), so zero are active. The root config resolves 47, of which 28 are active. If the active 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. How little a green lint step actually proves is worth knowing before you trust one.