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.
Table of Contents
- 1. Why ESLint cannot parse TypeScript syntax out of the box
- 2. Setting up flat config with typescript-eslint
- 3. Type-aware rules vs. syntax rules and their cost
- 4. Keeping performance under control: scoping and caching
- 5. A sensible rule baseline for real projects
- 6. Avoiding formatting conflicts with eslint-config-prettier
- 7. package.json scripts: separating lint, lint:ci and typecheck
- 8. Integrating with tsc in CI without duplicating work
- 9. Naive setup vs. recommended setup compared
- 10. Summary
- 11. FAQ
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.