Configuring ESLint for TypeScript Correctly
AI generated
<T>
type
TypeScript · ESLint · Static Analysis · CI/CD
Configuring ESLint for TypeScript Correctly
Flat config, type-aware rules and a CI without duplicate work

A misconfigured ESLint setup either misses real type errors or slows down every commit with unnecessarily slow type-aware rules. With modern flat config, the typescript-eslint package, and a clear split between fast syntax linting and full type-checking, both goals are achievable at once: reliable error detection and a development workflow that stays fast.

14 min read typescript-eslint · Flat Config · Type-Aware Linting ESLint 9 · TypeScript 5.x · CI/CD

1. Why ESLint cannot parse TypeScript syntax out of the box

ESLint was originally built for JavaScript and internally uses the Espree parser, which only understands valid ECMAScript syntax. Interfaces, generics, type annotations, enum declarations and as type assertions simply do not exist in the JavaScript grammar. As soon as Espree encounters function identity<T>(value: T): T, parsing fails with a syntax error long before any lint rule ever runs. Without a TypeScript-aware parser, ESLint is effectively non-functional for .ts and .tsx files.

The package @typescript-eslint/parser solves this by using the TypeScript compiler to translate source code into an ESLint-compatible AST that additionally includes all TypeScript-specific nodes. @typescript-eslint/eslint-plugin builds on top of that with its own rules that evaluate exactly those nodes: flagging unused type parameters, reporting inconsistent interface vs. type alias usage, or warning about redundant type annotations. Both packages have for some time been bundled into the shared meta-package typescript-eslint, which provides parser, plugin and configuration helpers from a single source and avoids version mismatches between the individual packages.

2. Setting up flat config with typescript-eslint

Since ESLint 9, flat config in eslint.config.js is the standard, replacing the nested .eslintrc inheritance model with a simple array of configuration objects merged from top to bottom. For TypeScript projects, the typescript-eslint package provides the tseslint.config() helper, which gives the configuration proper type inference and exports several ready-made rule presets: tseslint.configs.recommended for pure syntax rules, and tseslint.configs.recommendedTypeChecked for the type-aware variant, which additionally requires the full TypeScript program.

The decisive configuration point for type-aware rules is languageOptions.parserOptions.project, which points at the appropriate tsconfig.json. Only with this entry can the parser build a real TypeScript Program with complete type information, which the type-aware rules then query. Without it, only syntactic rules run; type-aware rules either throw a configuration error or are silently skipped, depending on the ESLint version.


// eslint.config.js - flat config using the typescript-eslint helper
import tseslint from 'typescript-eslint';
import eslintConfigPrettier from 'eslint-config-prettier';

export default tseslint.config(
  // Ignore build output and generated files globally
  { ignores: ['dist/**', 'coverage/**', '**/*.generated.ts'] },

  // Type-aware recommended rules, applied only to source files
  ...tseslint.configs.recommendedTypeChecked,

  {
    files: ['src/**/*.ts', 'src/**/*.tsx'],
    languageOptions: {
      parserOptions: {
        // Points at the project's tsconfig so the full TS program is built
        project: './tsconfig.json',
        tsconfigRootDir: import.meta.dirname,
      },
    },
    rules: {
      // Project-specific overrides layered on top of the preset
      '@typescript-eslint/no-explicit-any': 'warn',
      '@typescript-eslint/no-floating-promises': 'error',
    },
  },

  // Must be last: turns off formatting-related rules that conflict with Prettier
  eslintConfigPrettier,
);

The order in the array matters: presets are included first, project-specific overrides come after, and eslintConfigPrettier always sits at the very end so it reliably disables all formatting rules enabled earlier. For plain JavaScript files in the same repository, files: ['**/*.ts', '**/*.tsx'] should scope the type-aware blocks deliberately, otherwise ESLint tries to run .js files through the TypeScript compiler too, which causes unnecessary errors in mixed projects.

3. Type-aware rules vs. syntax rules and their cost

Syntax rules operate purely on the AST of a single file and have no type information from other files. They catch things like unused variables, incorrect indentation or forbidden syntax patterns, but not whether a function is actually called with the correct type. Type-aware rules, on the other hand, query the full TypeScript TypeChecker and can catch errors that only become visible through type inference: no-unsafe-assignment flags assignments of any values to typed variables, no-floating-promises detects ignored promises that are neither await-ed nor explicitly handled with .catch(), and restrict-template-expressions prevents objects without a meaningful toString() implementation from ending up in template literals.

This added value comes at a real cost: type-aware rules need a fully built TypeScript program, including all imported modules, for every linted file, which in large repositories can slow down the lint run by a factor of five to ten compared to pure syntax rules. The reason is that the TypeScript compiler does not just parse the current file but transitively resolves and type-checks all dependencies, similar to tsc itself. Applying type-aware linting indiscriminately across an entire monorepo risks lint runs that take longer than the actual build.


// Flagged by @typescript-eslint/no-floating-promises (type-aware rule)
async function syncInventory(productId: string): Promise<void> {
  // ...
}

function handleProductUpdate(productId: string): void {
  // WRONG: the returned promise is never awaited or handled,
  // a rejection here would be a silent, unhandled error
  syncInventory(productId);
}

// FIX: either await the call...
async function handleProductUpdateFixed(productId: string): Promise<void> {
  await syncInventory(productId);
}

// ...or explicitly mark it as intentionally not awaited
function handleProductUpdateVoid(productId: string): void {
  void syncInventory(productId).catch((error: unknown) => {
    console.error('Inventory sync failed', error);
  });
}

4. Keeping performance under control: scoping and caching

The most effective measure against long lint runtimes is scoping parserOptions.project deliberately. Instead of using a single monolithic tsconfig.json for source code, tests and configuration files, a separate, leaner tsconfig.eslint.json that only includes the files actually being linted, and excludes unnecessary type definitions for build tools, is worth setting up. This lets the TypeScript compiler build a smaller program, which noticeably reduces analysis time without limiting the value of type-aware rules for the actual application code.

A second effective measure is not applying type-aware rules on every local save, but only at targeted triggers like pre-commit hooks or the CI pipeline. ESLint itself has supported a built-in result-caching feature since version 9 via --cache and --cache-location, which only re-analyzes changed files on repeated runs. In monorepos with TypeScript project references (references in tsconfig.json), it is additionally worth maintaining a small, per-package ESLint configuration with its own project path instead of running a single root configuration across all packages.

5. A sensible rule baseline for real projects

When starting on a new or existing TypeScript project, it pays to avoid setting every available rule to error immediately. A proven baseline first enables the rules with the biggest safety payoff: no-explicit-any forces deliberate typing instead of a silent escape hatch, no-floating-promises prevents unnoticed swallowed errors in asynchronous code, consistent-type-imports consistently separates pure type imports from value imports and thereby improves tree-shaking, and the TS-aware variant of no-unused-vars catches unused type parameters that the built-in ESLint ruleset misses.

Other rules like naming-convention or no-magic-numbers often produce hundreds of initial findings in existing codebases and should either run as warn instead of error at first, or be configured with loosened options, so the team is not immediately discouraged by the sheer number of findings. It makes sense to gradually promote new rules from warn to error once the existing code has been cleaned up, rather than introducing everything at once.


// Baseline rule severities with reasoning for a real-world TypeScript project
export const baselineRules = {
  // High-value rules: turn on early, catch real bugs
  '@typescript-eslint/no-explicit-any': 'error',
  '@typescript-eslint/no-floating-promises': 'error',
  '@typescript-eslint/consistent-type-imports': 'error',
  '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],

  // Noisy in existing codebases: start as warnings, tighten later
  '@typescript-eslint/naming-convention': 'off',
  '@typescript-eslint/no-magic-numbers': 'off',

  // Type-aware but genuinely worth the runtime cost
  '@typescript-eslint/no-unsafe-assignment': 'warn',
  '@typescript-eslint/restrict-template-expressions': 'warn',

  // Formatting rules: disabled entirely, handled by Prettier instead
  '@typescript-eslint/indent': 'off',
};

6. Avoiding formatting conflicts with eslint-config-prettier

A common mistake is trying to enforce formatting through ESLint rules like indent or quotes while Prettier is also running in the project. Both tools follow different philosophies: Prettier makes formatting decisions deterministically and without configuration leeway, while ESLint formatting rules are configurable and almost inevitably collide with Prettier's decisions once both are active at the same time. The result is contradictory error messages, where ESLint demands a formatting that Prettier immediately reverts in the same moment.

The established solution is eslint-config-prettier, a configuration package that defines no rules of its own but specifically disables every formatting rule that could conflict with Prettier. It must be the last element in the flat config chain so it reliably overrides all conflict-prone rules enabled earlier. Prettier itself runs as a separate formatting step, usually via a pre-commit hook or an IDE plugin, letting ESLint focus exclusively on substantive code quality instead of whitespace and quote style.

7. package.json scripts: separating lint, lint:ci and typecheck

A productive setup separates three different check levels into their own npm scripts instead of bundling everything into one slow command. A fast lint script without type-aware rules runs in seconds and is suited to the pre-commit hook or the editor's watch mode. A separate lint:type-aware script enables the full type-aware rules and typically only runs in the CI pipeline or deliberately before a release, where the longer runtime matters less. A standalone typecheck script with tsc --noEmit finally covers all type errors that ESLint does not even check, such as missing return values or incompatible function signatures across module boundaries.

This separation stops developers from waiting on a slow type-aware lint run during everyday local work, while the CI pipeline still gets the full depth of checking. It is important not to run lint:type-aware and typecheck redundantly: tsc --noEmit checks type correctness completely and quickly through the native compiler, while type-aware ESLint rules surface additional style issues and best-practice violations that tsc itself does not treat as errors.


{
  "scripts": {
    "lint": "eslint . --max-warnings=0",
    "lint:type-aware": "eslint . --config eslint.config.type-aware.js",
    "typecheck": "tsc --noEmit -p tsconfig.json",
    "ci": "npm run typecheck && npm run lint:type-aware"
  }
}

8. Integrating with tsc in CI without duplicating work

In the CI pipeline, tsc --noEmit and ESLint can run in parallel instead of sequentially, since both work independently and share no common artifacts. tsc --noEmit takes on the complete, authoritative type check across the entire project including all module boundaries, while ESLint runs in parallel with a narrower, possibly non-type-aware configuration to find style violations and simple bugs. A common mistake is treating type-aware ESLint rules as a replacement for tsc: ESLint checks files individually and is simply the wrong tool for some complex type errors, such as circular generic constraints.

Caching additionally reduces CI runtime significantly. ESLint's --cache flag combined with a persistent cache directory between CI runs saves the full re-analysis of unchanged files. For TypeScript itself, tsc --build with project references plays a similar role in monorepos: only packages with actual changes get re-type-checked, unchanged packages return their cached result directly. Together, both caching mechanisms often reduce CI runtime in large repositories by more than half.


# .github/workflows/ci.yml - lint and typecheck run in parallel with caching
name: CI

on: [push, pull_request]

jobs:
  typecheck:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'npm'
      - run: npm ci
      - run: npm run typecheck

  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'npm'
      - run: npm ci
      # Restore ESLint's own result cache between runs
      - uses: actions/cache@v4
        with:
          path: .eslintcache
          key: eslint-${{ hashFiles('**/*.ts', '**/*.tsx') }}
      - run: npm run lint:type-aware -- --cache --cache-location .eslintcache

9. Naive setup vs. recommended setup compared

Many teams adopt an ESLint TypeScript setup from a tutorial or boilerplate repo without questioning the consequences for performance and error coverage. The table below compares typical misconfigurations against the recommended approach.

Aspect Naive setup Recommended setup Benefit
Parser configuration No parser, ESLint fails on TS syntax typescript-eslint with tseslint.config() TS syntax is parsed at all
Type-aware rules recommendedTypeChecked everywhere, even locally Type-aware only in CI/pre-commit, scoped project Lint stays fast locally
Formatting ESLint indent/quotes alongside Prettier eslint-config-prettier as the last element No contradictory errors
tsc vs. ESLint Only ESLint, tsc --noEmit missing from CI Both in parallel as separate CI jobs Full type coverage across module boundaries
Caching No --cache, every run starts from scratch --cache plus persistent CI cache Significantly shorter CI runtime

The common denominator of the naive mistakes is almost always the same: type-aware linting gets applied indiscriminately everywhere instead of deliberately where it delivers the most value at an acceptable runtime cost. Consistently applying the five points from the table results in an ESLint setup that stays reliable and fast, both in the local editor and in the CI pipeline.

Mironsoft

TypeScript tooling, ESLint configuration and CI pipelines for frontend teams

Want an ESLint setup that does not slow you down?

We analyze your existing ESLint and TypeScript setup, identify unnecessarily slow type-aware rules, and build a clear split between fast local linting and full CI-level checking.

Flat config migration

Moving from .eslintrc to eslint.config.js with typescript-eslint

Performance tuning

Scoping parserOptions.project and caching strategies for fast CI

Rule baseline

A sensible rule set definition without hundreds of findings in existing code

10. Summary

A cleanly configured ESLint setup for TypeScript starts with the typescript-eslint package, which provides a parser and rules that actually understand the TypeScript AST. Modern flat config with tseslint.config() and parserOptions.project builds a complete TypeScript program, which is what makes type-aware rules like no-floating-promises or no-unsafe-assignment possible in the first place. These rules catch real bugs that pure syntax rules cannot detect, but they cost measurable runtime because they require the full TypeScript compiler.

The practical solution lies in separation: a fast, non-type-aware lint for everyday local work and pre-commit hooks, and a type-aware lint plus tsc --noEmit as two parallel, cached jobs in the CI pipeline. A gradually introduced rule baseline and eslint-config-prettier against formatting conflicts round off a setup that reliably finds real errors without slowing down day-to-day development.

Configuring ESLint for TypeScript Correctly - The Key Points at a Glance

Parser and plugin

typescript-eslint bundles a parser and rules that understand the TypeScript AST. Without it, ESLint fails on TS syntax.

Type-aware rules

no-floating-promises and no-unsafe-assignment catch real bugs, but cost 5-10x more runtime than syntax rules.

Performance scoping

A dedicated tsconfig.eslint.json, --cache, and type-aware rules only in CI instead of on every local save.

CI integration

tsc --noEmit and ESLint run in parallel as separate jobs, no redundancy, both with a caching strategy.

11. FAQ: Configuring ESLint for TypeScript Correctly

1Why can't ESLint read TypeScript files without an extra package?
The default Espree parser only understands JavaScript syntax. Interfaces, generics and type annotations do not exist there, so parsing fails immediately without @typescript-eslint/parser.
2What is the difference between typescript-eslint and the individual packages?
typescript-eslint bundles the parser, plugin and configuration helpers like tseslint.config() from a single source and avoids version mismatches. The recommended entry point for new projects.
3What makes a rule type-aware as opposed to syntax rules?
Type-aware rules query the full TypeChecker and have type information across file boundaries. Syntax rules only operate on the AST of a single file.
4How much do type-aware rules actually slow down the lint run?
Often by a factor of 5 to 10 in large repos, because a complete TypeScript program including all dependencies must be built for every file.
5What does parserOptions.project actually do?
Points at the tsconfig.json and lets the parser build a real TypeScript program with full type information, which is what makes type-aware rules functional in the first place.
6Should I set all rules to error from the start?
No. Enable high-value rules early, let noisy rules like naming-convention run as warn at first, so existing code is not immediately flooded.
7Why do I need eslint-config-prettier in addition to Prettier?
It disables all ESLint formatting rules that could conflict with Prettier's decisions. Without this package, both tools can report contradictory errors.
8Does type-aware ESLint linting replace calling tsc --noEmit?
No. tsc --noEmit remains the authoritative, complete type check. ESLint finds additional style issues but does not replace the compiler's correctness check.
9How should ESLint and tsc be combined in CI?
As two parallel jobs rather than sequential ones: one job for tsc --noEmit, one job for type-aware ESLint with --cache. Both check different things.
10How do I reduce lint runtime in a monorepo?
A dedicated lean tsconfig.eslint.json per package, persist ESLint's --cache across CI runs, use TypeScript project references so tsc --build only re-checks changed packages.