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.

· · 9 min read

Updated: October 9, 2026

Programming code on a screen

Quick Take

Flat config (`eslint.config.js`) became the default in ESLint 9 and is the only format left in ESLint 10: 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.

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.

Share this Post on X Bluesky

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 configRoot config
Files linted2753
Problems reported840
Real @typescript-eslint findings019
Run time1.6 s5.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.

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 },
    },
  },
);

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.

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

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 typeNested .eslintrc per directoryfiles glob on a config object
Ignore patterns.eslintignoreglobalIgnores() or an ignores-only object in eslint.config.js
TypeScript parser setup"parser": "@typescript-eslint/parser" stringa 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 the ESLINT_USE_FLAT_CONFIG environment variable no longer do anything. On ESLint 9 you could still opt back in with ESLINT_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:recommended grew. Three rules joined it: no-unassigned-vars, no-useless-assignment and preserve-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:

  1. --print-config on one real .ts file, and count the @typescript-eslint keys that are not set to off. Here the workspace config resolves 20 such keys, all off (eslint-config-prettier switches 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.
  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. How little a green lint step actually proves is worth knowing before you trust one.

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 a tseslint.configs.* entry inside ESLint's defineConfig() helper (the older tseslint.config() wrapper is deprecated), 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.