Skip to content

ESLint 9 Flat Config Migration Guide for TypeScript

Migrating .eslintrc to Flat Config Without Losing TS Rules

A real migration from .eslintrc.json to ESLint 9's flat config, covering typescript-eslint setup, plugin syntax changes, and common breakages.

· · 4 min read

Quick Take

I put off migrating to flat config for months because every guide I found skipped the TypeScript-specific parts. Here's the migration I actually ran, including the two rules that silently stopped firing.

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 explicit files globs, replacing .eslintrc's implicit directory-cascading resolution. For TypeScript projects, use typescript-eslint's tseslint.config() helper instead of the old parser/plugins/extends string fields. .eslintignore is no longer read at all, ignore patterns move into the config file as an ignores array.

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 typeNested .eslintrc per directoryfiles glob on a config object
Ignore patterns.eslintignoreignores array inside eslint.config.js
TypeScript parser setup"parser": "@typescript-eslint/parser" stringtseslint.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.

Frequently Asked Questions

Why did ESLint switch to flat config?
The old .eslintrc format resolved config through implicit file-system cascading, ESLint would walk up the directory tree merging .eslintrc files, which made it hard to reason about which rule config actually applied to a given file, especially in monorepos. Flat config (eslint.config.js) replaces that with a single exported array of config objects, each with explicit files globs, so what applies to what is visible directly in the config file instead of inferred from folder structure.
Do I need to change how I write TypeScript-specific rules?
The rules themselves keep the same names (@typescript-eslint/no-unused-vars, etc.), but how you register the parser and plugin changes. Flat config expects the typescript-eslint package's tseslint.config() helper, or manual languageOptions.parser and plugins entries, instead of the old parser/plugins/extends string-based fields in .eslintrc.
Will my .eslintignore file still work?
No, .eslintignore is not read by flat config at all. Ignore patterns move into the config file itself, either as a dedicated { ignores: [...] } config object, or as an ignores property scoped to a specific config block. Forgetting this is the most common cause of ESLint suddenly linting a dist/ or node_modules/ folder after a flat config migration.