Migrating to ESLint 9 Flat Config Without Breaking Your Lint Rules

Flat config is not a cosmetic rename of your .eslintrc. It is a different mental model, and the projects that get burned are the ones that treat the migration as a find-and-replace instead of a re-architecture of how rules resolve.

ESLint 9 shipped in April 2024 and made the flat eslint.config.js the default. ESLint 10, out in February 2026, removed the legacy escape hatch entirely: the ESLINT_USE_FLAT_CONFIG=false lever and eslintrc loading are gone. So the migration is no longer optional housekeeping. If you are still on .eslintrc, you are on a version you cannot upgrade, and an increasing number of plugins now ship flat-only. This is the port I run on client codebases, and the ordering rules that trip people up.

What actually changes, conceptually

Legacy config was a single object with implicit magic: extends pulled in shareable configs by name, env switched on globals by keyword, plugins was an array of strings resolved from node_modules, and overrides carved out exceptions. The resolution order was largely hidden from you.

Free · 4 minutes

Do you actually know what you are running — and what it is about to cost you?

Fourteen questions on the systems you depend on, the ones nobody owns, and the support dates that turn a routine upgrade into a forced re-platform. Banded finding on screen, full sheet by email.

Flat config is an ordered array of plain objects. There is no name resolution and no magic. You import a plugin as a value and register it under a key you choose. Later objects override earlier ones. That is the whole model. The four legacy concepts map like this:

  • env becomes languageOptions.globals, populated from the globals package (...globals.node, ...globals.browser) rather than a keyword.
  • plugins becomes an object, mapping a namespace to an imported module. The namespace you pick is the prefix rule names must use.
  • parserOptions moves under languageOptions, alongside parser (now an imported object, not a string).
  • overrides disappears. Each override is simply another object in the array with its own files glob.

And .eslintignore is gone. Ignores live in the config: an object containing only an ignores key is a global ignore; an ignores alongside files narrows within that object.

A working TypeScript + Prettier config

Here is the shape I land most projects on. It uses typescript-eslint v8, whose tseslint.config() helper gives you type-checking on the config itself and spreads the shared configs cleanly. Prettier is wired the modern way: eslint-config-prettier switches off the formatting rules that fight Prettier, and Prettier runs as its own step rather than through a plugin.

// eslint.config.mjs
import js from "@eslint/js";
import globals from "globals";
import tseslint from "typescript-eslint";
import { includeIgnoreFile } from "@eslint/compat";
import eslintConfigPrettier from "eslint-config-prettier";
import { fileURLToPath } from "node:url";

const gitignorePath = fileURLToPath(new URL(".gitignore", import.meta.url));

export default tseslint.config(
  // 1. Global ignores. An object with only `ignores` applies everywhere.
  includeIgnoreFile(gitignorePath),
  { ignores: ["dist/", "coverage/"] },

  // 2. Base JavaScript recommended rules.
  js.configs.recommended,

  // 3. Type-aware TypeScript rules.
  ...tseslint.configs.recommendedTypeChecked,
  {
    languageOptions: {
      parserOptions: {
        projectService: true,
        tsconfigRootDir: import.meta.dirname,
      },
      globals: { ...globals.node },
    },
  },

  // 4. Overrides are just more objects, matched by `files`.
  {
    files: ["**/*.test.ts"],
    languageOptions: { globals: { ...globals.jest } },
    rules: { "@typescript-eslint/no-explicit-any": "off" },
  },

  // 5. Prettier LAST: disables rules that would conflict with formatting.
  eslintConfigPrettier,
);

Two details earn their keep. projectService: true replaces the old project array for type-aware linting and handles files not covered by a tsconfig without you enumerating them. And eslintConfigPrettier sits at the end, because in flat config the rule that wins is the last one to set it — put Prettier first and your own overrides silently re-enable the rules it meant to kill.

The plugins that haven’t caught up

The real friction is a plugin or shareable config that still assumes eslintrc. There are two compatibility tools, and people conflate them. @eslint/compat wraps plugins whose rule/processor shape predates flat config. FlatCompat, from @eslint/eslintrc, translates eslintrc-style config — anything using extends, env or string plugins — into flat objects.

// A plugin that still ships eslintrc-shaped rules.
import { fixupPluginRules, fixupConfigRules } from "@eslint/compat";
import { FlatCompat } from "@eslint/eslintrc";
import legacyPlugin from "eslint-plugin-legacy";

const compat = new FlatCompat({ baseDirectory: import.meta.dirname });

export default [
  {
    plugins: {
      // Namespace it yourself; rule names must match, e.g. "legacy/no-foo".
      legacy: fixupPluginRules(legacyPlugin),
    },
    rules: { "legacy/no-foo": "error" },
  },

  // A shareable config that only publishes eslintrc `extends`.
  ...fixupConfigRules(compat.extends("eslint-config-airbnb-base")),
];

Treat these shims as a bridge, not a destination. Every one you carry is a plugin you have not properly upgraded, and it is exactly the kind of quiet migration debt that outlives the person who introduced it. Track them, and drop each shim the moment the upstream plugin ships a native flat build.

The ordering mistakes that break rules silently

Almost every "my rules stopped working" report after a flat migration is an ordering or namespace error, not a broken rule. The recurring ones:

  • Namespace mismatch. You registered a plugin under import but wrote import-x/order. The rule is simply never found; there is no error, it just does nothing. The prefix in rules must match the key in plugins.
  • Recommended configs placed after your overrides. Spreading tseslint.configs.recommended below your custom object re-asserts its defaults over your tweaks. Base configs go up top; your project-specific object goes underneath.
  • A polluted global ignore. Add any other key to the object that holds ignores and it stops being a global ignore — it becomes a normal config that happens to ignore some files, and everything you meant to exclude gets linted again.
  • Missing files on a type-aware block. Applying type-checked rules to JS or config files that no tsconfig covers throws parser errors on eslint.config.mjs itself. Scope the type-aware object to **/*.ts.

Verify the resolved config for a given file rather than guessing: npx eslint --print-config src/index.ts prints exactly which rules and options apply after the whole array has merged. Run it before and after the migration on a handful of representative files and diff the output. If the two match on the rules you care about, the port is sound regardless of how the config looks.

Land it in CI, not just on your laptop

A lint config that only passes locally is not migrated. Pin ESLint and typescript-eslint to exact versions, run eslint . in the pipeline, and gate merges on it — the same discipline that makes a pre-commit baseline actually enforce something rather than decorate the repo. A silently weakened ruleset does not announce itself; it shows up months later as the drift behind a rising change-failure rate.

The point of flat config is a smaller model you can actually hold in your head: an ordered array, last-write-wins, no hidden resolution. Migrate it with --print-config in hand and the smaller model is a genuine asset. Migrate it by eye and you inherit a config that looks modern and quietly lints nothing.

Build and rescue work

Hands-on delivery of this kind is handled by Sixteen Pillars Studio.

Free interactive tool

Website compliance checklist

What your site has to do, based on what it actually does

Answer as much or as little as you like — the list builds as you go. Nothing is stored against your name and no email is required.

Most technology problems are not technology problems. They are control problems.

The systems exist. The investment has been made. The question is whether leadership can understand, direct, evidence, and sustain what those systems produce. Find out where control exists — and where it only appears to.