I migrated a project to flat config and shipped it without noticing two @typescript-eslint rules had silently stopped running, no error, no warning, they just weren't in the config anymore because the old extends chain didn't translate the way I assumed it would. Flat config isn't hard, but a few TypeScript-specific gotchas aren't covered in the generic migration guide.
TL;DR: ESLint 9 requires flat config (
eslint.config.js), a single exported array of config objects with explicitfilesglobs, replacing.eslintrc's implicit directory-cascading resolution. For TypeScript projects, usetypescript-eslint'stseslint.config()helper instead of the oldparser/plugins/extendsstring fields..eslintignoreis no longer read at all, ignore patterns move into the config file as anignoresarray.
The Old Config, for Comparison
// .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"
}
}
# .eslintignore
dist/
node_modules/
Two files, string-based extends, and ignore patterns living in a completely separate file that ESLint reads implicitly.
The Flat Config Equivalent
// 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',
},
},
);
tseslint.config() is a thin helper from the typescript-eslint package that flattens nested config arrays and gives you type checking on the config object itself. ...tseslint.configs.recommended spreads an array of config objects (parser setup, plugin registration, and the recommended rule set) into your own array, replacing what used to be a single "extends" string.
The Gotcha: extends Doesn't Map One-to-One
The mistake that cost me two silently-dropped rules: plugin:@typescript-eslint/recommended in the old format pulled in both the parser setup and a specific rule set. In flat config, tseslint.configs.recommended covers the equivalent rule set, but if your old .eslintrc also extended plugin:@typescript-eslint/recommended-requiring-type-checking (a separate, stricter set requiring type info), that has no automatic equivalent, you need tseslint.configs.recommendedTypeChecked explicitly, plus a languageOptions.parserOptions.project pointing at your tsconfig.json:
export default tseslint.config(
{
ignores: ['dist/'],
},
js.configs.recommended,
...tseslint.configs.recommendedTypeChecked, // type-aware rules, was "recommended-requiring-type-checking"
{
languageOptions: {
parserOptions: {
project: './tsconfig.json',
tsconfigRootDir: import.meta.dirname,
},
},
},
);
If your project used the type-checked rule set before, run npx eslint . --format json | jq '.[] | .messages | length' before and after the migration and compare rule violation counts. A drop to near-zero without any actual fixes is the tell that a rule set silently didn't carry over.
Scoping Rules to File Types (the Monorepo Case)
Flat config's files glob is the feature that actually made me prefer it once I'd migrated. Different rules for different parts of a monorepo used to mean nested .eslintrc files at each directory level, implicit and easy to lose track of. Flat config makes it one explicit array:
export default tseslint.config(
{ ignores: ['dist/', '**/node_modules/'] },
js.configs.recommended,
...tseslint.configs.recommended,
{
files: ['blogs/*/src/**/*.ts', 'blogs/*/src/**/*.tsx'],
rules: {
'no-console': 'error', // stricter in blog site code
},
},
{
files: ['packages/shared/scripts/**/*.ts'],
rules: {
'no-console': 'off', // CLI scripts print to stdout on purpose
},
},
);
Both blocks live in one file, in one place, instead of two separate .eslintrc files nested in different directories that you'd have to know to look for.
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 |
| 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 |
Conclusion
The mechanical part of a flat config migration, moving rules into an array of objects, is straightforward. The part worth double-checking is whether your old extends chain pulled in more than the direct equivalent covers, especially type-aware TypeScript rule sets, which need an explicit parserOptions.project pointer that the old config format didn't require you to think about separately.