Skip to content

The Flat Config Trap That Silently Disables TS Rules

What extends does not carry into flat config, and the parser-without-plugin trap that runs zero TypeScript rules while exiting clean.

· · 7 min read
Programming code on a screen

Quick Take

Two repos, two flat config setups: one greenfield with nothing to convert, one a real migration off a legacy config. The mechanical part took a session each. The trap took months to spot.

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 explicit files globs, replacing .eslintrc's implicit directory cascade. Moving the rules across is mechanical. The two things that bite: extends bundled parser setup with rule sets, so a config can register @typescript-eslint/parser and still run zero @typescript-eslint rules, and type-aware rule sets need an explicit parserOptions.project the 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 configRoot config
Files linted2145
Problems reported830
Real @typescript-eslint findings012
Run time2.7 s13.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.

A magnifying glass resting next to a laptop keyboard, symbolizing code inspection
Photo by Agence Olloweb on Unsplash

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.

Green terminal command line output scrolling across a dark screen
Photo by Jake Walker on Unsplash

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 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

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:

  1. --print-config on one real .ts file, and count the @typescript-eslint keys. If the config mentions those rules but the count is zero, the plugin was never registered.
  2. Grep the old .eslintrc for every extends entry containing "type-checking" or "type-checked", and confirm each has an explicit recommendedTypeChecked or strictTypeChecked equivalent.
  3. Run the workspace's own lint script, 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.

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.
Why do my @typescript-eslint rules do nothing even though linting passes?
Almost always because the config registers the TypeScript parser but never registers the plugin. Setting languageOptions.parser to @typescript-eslint/parser only teaches ESLint to read TypeScript syntax, it does not add a single rule. Without a plugins entry or a spread of tseslint.configs.recommended, every @typescript-eslint rule name in your config resolves to nothing, and ESLint exits zero. The only visible symptom is a Definition for rule was not found error on files carrying an eslint-disable comment for one of those rules.
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.