Mert Özen Further With Every Line
Testing & Quality Static Analysis — #04

ESLint for Angular and TypeScript: Flat Config, typescript-eslint Type-Aware Rules and angular-eslint Template Rules

Mert Özen Nov 14, 2026 13 min 13 views
ESLint for Angular and TypeScript: Flat Config, typescript-eslint Type-Aware Rules and angular-eslint Template Rules

Only rules that use type information catch code that doesn't await its promises: in Bookstore's Angular client the config from ng add angular-eslint found 2 problems in a pull request, and strictTypeChecked with projectService found 12. The post covers ESLint 10's flat-config-only world, no-floating-promises and no-misused-promises bugs, accessibility template rules, the cost of type-aware linting, stale cache results and the TypeScript 7 incompatibility, all with real output.

Version note: The code in this post was tested with Angular 22.2 (CLI 22.2.7, TypeScript 6.0), ESLint 10.15, typescript-eslint 8.71 and angular-eslint 22.6 on Node.js 24; the GitHub Actions workflow was validated with actionlint 1.7.12 and was not run on GitHub.

Guess First

In bookstore-web, the Angular client of Bookstore, the online bookstore used as the shared example across this site's series, Emre bundles three small requests into one pull request. A "Buy now" button on the book page: add to cart, go to the cart. A "Clear cart" button on the cart. And the whole card in the catalog should be clickable, not just the title:

// book-detail-page.ts
protected buyNow(quantity: number): void {
  this.cartStore.add(this.isbn(), quantity);
  this.router.navigate(['/cart']);
}

// cart-page.ts
protected clearCart(): void {
  this.store.cart()?.items.forEach(async (line) => {
    await this.store.remove(line.isbn);
  });
}
<!-- catalog-page.html -->
<li class="book-card" (click)="open(book.isbn)">

The project has just got ESLint through ng add angular-eslint, and the configuration is exactly what the CLI generated. How many problems does ng lint find in this pull request? And what happens to that number when we turn on the rules that use type information?

ESLint 10: Flat Config Only

ESLint 10 was released on 6 February 2026, and its biggest change is that it removes the old configuration system completely. .eslintrc.* and .eslintignore files are no longer read at all, and the ESLINT_USE_FLAT_CONFIG=false escape hatch from ESLint 9 is gone; the only format is eslint.config.js (or .mjs, .ts). The Node.js requirement is ^20.19.0 || ^22.13.0 || >=24. ESLint 9 reached end of life on 6 August 2026. Two more details matter: the config file is now looked up starting from the folder of the file being linted rather than the current working directory, which makes several configs possible in a monorepo; and eslint:recommended gained new rules, so the upgrade alone can bring new errors.

On the Angular side, setup is a single command:

$ npx ng add angular-eslint@22.6.0 --skip-confirmation
    angular-eslint v22 matches this workspace's Angular v22.
    We detected that you have a single project in your workspace and no existing linter wired up, so we are configuring ESLint for you automatically.
CREATE eslint.config.js (1004 bytes)
UPDATE package.json (934 bytes)
UPDATE angular.json (2289 bytes)
✔ Packages installed successfully.

The generated eslint.config.js enables eslint.configs.recommended, typescript-eslint's recommended and stylistic sets and angular-eslint's tsRecommended for .ts files, and templateRecommended and templateAccessibility for .html templates. A flat config is an array: each element selects the files it applies to with files, and later elements override earlier ones. A lint target that runs ng lint was also added to angular.json. The first answer to the guess:

$ npx ng lint
src/app/catalog/catalog-page.html
  22:7  error  click must be accompanied by either keyup, keydown or keypress event for accessibility  @angular-eslint/template/click-events-have-key-events
  22:7  error  Elements with interaction handlers must be focusable                                    @angular-eslint/template/interactive-supports-focus

✖ 2 problems (2 errors, 0 warnings)

Two errors, both for the clickable <li> in the template. The two TypeScript methods got no warning at all. tseslint.configs.recommended only looks at the syntax tree of the code; to know whether a call returns a promise, it needs type information.

typescript-eslint: Linting with Type Information

Some of typescript-eslint's rules use the TypeScript compiler's type information: whether an expression is a Promise, whether it carries any, whether an API is marked @deprecated. Turning them on needs the TypeChecked versions of the sets and a TypeScript project for the parser. projectService finds the tsconfig that applies to each file by itself, using the mechanism editors use; with Angular's split between tsconfig.app.json and tsconfig.spec.json you don't need to write a project list by hand:

// @ts-check
const eslint = require('@eslint/js');
const { defineConfig } = require('eslint/config');
const tseslint = require('typescript-eslint');
const angular = require('angular-eslint');

module.exports = defineConfig([
  {
    files: ['**/*.ts'],
    extends: [
      eslint.configs.recommended,
      tseslint.configs.strictTypeChecked,
      tseslint.configs.stylisticTypeChecked,
      angular.configs.tsRecommended,
    ],
    languageOptions: {
      parserOptions: {
        projectService: true,
        tsconfigRootDir: __dirname,
      },
    },
    processor: angular.processInlineTemplates,
    rules: {
      '@angular-eslint/directive-selector': [
        'error',
        {
          type: 'attribute',
          prefix: 'app',
          style: 'camelCase',
        },
      ],
      '@angular-eslint/component-selector': [
        'error',
        {
          type: 'element',
          prefix: 'app',
          style: 'kebab-case',
        },
      ],
    },
  },
  {
    files: ['**/*.html'],
    extends: [angular.configs.templateRecommended, angular.configs.templateAccessibility],
    rules: {},
  },
]);

We picked strictTypeChecked instead of recommendedTypeChecked; the typescript-eslint documentation doesn't consider the strict sets "stable" under semantic versioning, so rules can change without waiting for a major release, and it recommends them for teams where a good share of the developers are highly proficient in TypeScript. In a small, well-maintained codebase like Bookstore that's acceptable; in a legacy project, start with recommendedTypeChecked. The same pull request now gives:

$ npx ng lint
src/app/app.config.ts
  9:23  error  `withFetch` is deprecated. `withFetch` is not required anymore. `FetchBackend` is the default `HttpBackend`  @typescript-eslint/no-deprecated

src/app/cart/cart-page.ts
  31:13  error  Unsafe assignment of an `any` value                                     @typescript-eslint/no-unsafe-assignment
  32:29  error  Unsafe argument of type `any` assigned to a parameter of type `string`  @typescript-eslint/no-unsafe-argument
  32:38  error  Unsafe member access .detail on an `any` value                          @typescript-eslint/no-unsafe-member-access
  32:57  error  Unsafe member access .title on an `any` value                           @typescript-eslint/no-unsafe-member-access
  37:38  error  Promise returned in function argument where a void return was expected  @typescript-eslint/no-misused-promises

src/app/catalog/book-detail-page.ts
  29:5  error  Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the `void` operator  @typescript-eslint/no-floating-promises
  30:5  error  Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the `void` operator  @typescript-eslint/no-floating-promises

src/app/catalog/catalog-page.html
  22:7  error  click must be accompanied by either keyup, keydown or keypress event for accessibility  @angular-eslint/template/click-events-have-key-events
  22:7  error  Elements with interaction handlers must be focusable                                    @angular-eslint/template/interactive-supports-focus

src/main.ts
  6:11  error  Prefer the safe `: unknown` for a `catch` callback variable                                                         @typescript-eslint/use-unknown-in-catch-callback-variable
  6:19  error  Returning a void expression from an arrow function shorthand is forbidden. Please add braces to the arrow function  @typescript-eslint/no-confusing-void-expression

✖ 12 problems (12 errors, 0 warnings)

Twelve errors. Three are in the new code of the pull request, seven are in code that existed before it; turning on type-aware rules brings the same baseline question we saw on the .NET side in the first post.

The two no-floating-promises in buyNow are a real bug. cartStore.add is an async method that sends a request to the API and waits for the answer; because it isn't awaited, router.navigate runs immediately and the cart page usually shows the old cart. If the request fails, for example because there isn't enough stock, the error is caught nowhere. The no-misused-promises in clearCart is sneakier: forEach doesn't wait for the promise the callback returns. The delete requests go out in parallel, every response replaces the whole cart state, and the last response wins; the cart can look as if one book were still in it.

The other findings are unrelated to the pull request but valuable. no-deprecated says that withFetch is no longer needed in Angular 22: FetchBackend is the default. The four no-unsafe-* errors all come from a single line: the type of HttpErrorResponse.error is any, and the problem?.detail chain is a path the compiler never checks.

Fixing the Findings

protected async buyNow(quantity: number): Promise<void> {
  await this.cartStore.add(this.isbn(), quantity);
  await this.router.navigate(['/cart']);
}
// The fields of an RFC 9457 problem response that the page shows.
interface ProblemDetails {
  title?: string;
  detail?: string;
}

// ...
const problem =
  error instanceof HttpErrorResponse ? (error.error as ProblemDetails | null) : null;

protected async clearCart(): Promise<void> {
  // One request at a time: every response replaces the whole cart state.
  for (const line of this.store.cart()?.items ?? []) {
    await this.store.remove(line.isbn);
  }
}

as ProblemDetails is a type assertion; TypeScript doesn't verify that the API really returns this shape. But now the assertion is written down explicitly, and the type of problem.detail is string | undefined. If you want to validate the API's shape at runtime you need a schema library; the linter's job is to force you to make that decision. The catch callback in main.ts became (err: unknown) => { console.error(err); }, and provideHttpClient(withFetch()) became provideHttpClient(). After these changes ng lint says "All files pass linting.", and ng build and the Vitest tests pass too.

angular-eslint and Template Rules

angular-eslint has two parts: decorator and class rules on the TypeScript side (tsRecommended; 13 rules such as prefer-inject, prefer-standalone and no-output-native), and template rules that run with a separate parser for HTML templates. templateRecommended currently contains four rules: banana-in-box, eqeqeq, no-negated-async, and prefer-control-flow, which asks for @if/@for instead of the old *ngIf/*ngFor. templateAccessibility is 11 accessibility rules; the ones that caught Bookstore's card come from this set.

The right way to make the whole card clickable is not to put (click) on the <li>. A keyboard user can't reach that element with Tab, and a screen reader doesn't announce it as a link. We stretched the existing title link over the whole card with CSS:

<li class="book-card">
  <a class="card-link" [routerLink]="['/books', book.isbn]">{{ book.title }}</a>
/* The title link covers the whole card, so the card is clickable and keyboard accessible. */
.book-card .card-link::after {
  content: "";
  position: absolute;
  inset: 0;
}

.book-card button {
  position: relative;
  z-index: 1;
}

There are rules outside the recommended sets worth a look too. @angular-eslint/no-uncalled-signals catches a signal used in a condition without being called: if (this.added) is always true. @angular-eslint/no-implicit-take-until-destroyed reports a takeUntilDestroyed() call without arguments outside an injection context. Both are off by default; for a team that uses RxJS heavily they are worth turning on.

Now a limit. Bookstore's catalog has this line, and the linter doesn't complain about it at all:

<button type="button" (click)="cartStore.add(book.isbn)">Add to cart</button>

cartStore.add returns a promise and nobody waits for it in the template; it's the same problem as in buyNow. But typescript-eslint's type-aware rules only run in .ts files, and event bindings in the template are not part of the code they see. If an error happens, Angular's provideBrowserGlobalErrorListeners catches it and writes it to the console, but the user sees nothing. Bookstore's rule: async work called from a template is wrapped in a protected async method in the component that handles the error state itself; that is a line on the review checklist, not a lint rule.

The Cost: Time, Cache and Where to Run It

The price of type information is that ESLint must first build the TypeScript program. In Bookstore's small client, the wall-clock time of npx eslint src over three measurements was 0.51–0.52 seconds with the CLI-generated config and 0.93–0.95 seconds with strictTypeChecked. The TIMING environment variable shows how long each rule took:

$ TIMING=1 npx eslint src
Rule                                                  | Time (ms) | Relative
:-----------------------------------------------------|----------:|--------:
@typescript-eslint/no-unsafe-enum-assignment          |    35.279 |    25.1%
@typescript-eslint/no-misused-promises                |    20.041 |    14.2%
@typescript-eslint/no-unsafe-assignment               |    17.792 |    12.6%
@typescript-eslint/no-unused-vars                     |    14.070 |    10.0%
@typescript-eslint/no-floating-promises               |    11.417 |     8.1%
@angular-eslint/template/click-events-have-key-events |     8.602 |     6.1%

The rule at the top of the list isn't expensive itself; since it is the first rule that asks for type information, the time to build the TypeScript program is most likely charged to it. In a large project, that setup is the real cost; the typescript-eslint documentation also says this time is negligible in small projects and longer in large ones, and adds that editor plugins hide the cost thanks to caching.

ESLint's own cache (--cache) stores results per file. In Bookstore, the first run took 1.11 seconds, the second and third runs without changes 0.53 and 0.50 seconds, and 0.74 seconds after touching a single file. But the ESLint documentation warns explicitly: the cache doesn't track dependencies between files, a file's cached result is reused when a file it depends on changes, and type-aware rules can therefore report stale results. We tried it. With CartStore.add returning void, we wrote buyNow without await and ran a cached lint, then changed add back to Promise<void> in cart-store.ts only:

$ npx eslint --cache src
src/app/catalog/book-detail-page.ts
  24:5   error  Unexpected `await` of a non-Promise (non-"Thenable") value                                              @typescript-eslint/await-thenable
  24:11  error  Placing a void expression inside another expression is forbidden. Move it to its own statement instead  @typescript-eslint/no-confusing-void-expression

✖ 2 problems (2 errors, 0 warnings)
$ npx eslint src
src/app/catalog/book-detail-page.ts
  29:5  error  Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the `void` operator  @typescript-eslint/no-floating-promises

✖ 1 problem (1 error, 0 warnings)

The cached run still counts an await that is now valid as an error and doesn't see the real floating promise; since book-detail-page.ts hadn't changed, it returned the old result. The run without cache gave the right answer. Bookstore's decision:

  • In the editor: the ESLint plugin, while typing, before saving. Type-aware rules run here too, and the plugin's cache carries the cost.
  • Before commit: staged files only, with the lint-staged setup from "Automating the Rules" in the Coding Conventions series. --cache is acceptable here, because CI has the final say.
  • In CI: no cache, the whole project, --max-warnings 0.

What you shouldn't do is run type-aware lint over the whole project from the command line on every save: the editor plugin already shows the same findings, and the command line rebuilds the TypeScript program from scratch every time.

CI and the Boundary with Prettier

A CI script in package.json and a workflow:

"lint": "ng lint",
"lint:ci": "ng lint --max-warnings 0"
name: web-lint

on:
  pull_request:
    paths: ['web/bookstore-web/**']
  push:
    branches: [main]

permissions:
  contents: read

jobs:
  lint:
    runs-on: ubuntu-latest
    defaults:
      run:
        working-directory: web/bookstore-web
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-node@v7
        with:
          node-version: 24
          cache: npm
          cache-dependency-path: web/bookstore-web/package-lock.json
      - run: npm ci
      # Full lint without --cache: type-aware results depend on other files.
      - run: npm run lint:ci

--max-warnings 0 is the opposite choice from the targeted errors on the .NET side in the second post: in ESLint, warning-level rules become invisible as their number grows, so Bookstore's warning budget on the web side is zero. Keeping a rule as a warning should not just mean "show it in the editor but don't break CI"; in that case switching the rule off is more honest. You can add this step as a separate job to the pipeline from "Angular CI with GitHub Actions" in the GitHub Actions series.

The boundary with Prettier is clear: formatting belongs to Prettier, quality and conventions to ESLint. Despite its name, tseslint.configs.stylisticTypeChecked is not a formatting set; it contains rules about writing preferences and doesn't conflict with Prettier. If you ever have to add a formatting rule, put eslint-config-prettier at the end of the array; the details are in "Conventions in Angular and TypeScript: The Style Guide, Prettier and Dividing Responsibility with ESLint" in the Coding Conventions series.

A Note on TypeScript 7

The reason the version note says TypeScript 6.0 is peer dependencies. Looking at the installed packages:

$ node -e "console.log(require('@angular/compiler-cli/package.json').peerDependencies.typescript)"
>=6.0 <6.1
$ npm view typescript-eslint@8.72.2 peerDependencies
{
  eslint: '^8.57.0 || ^9.0.0 || ^10.0.0',
  typescript: '>=4.8.4 <6.1.0'
}

Angular 22.2's compiler wants TypeScript 6.0; the latest typescript-eslint at the time of writing (8.72.2, 11 November 2026) also stays below 6.1. TypeScript 7.0, rewritten in Go, is published on npm under the latest tag, but 7.0 offers no programmatic API, and typescript-eslint's type-aware rules depend on exactly that API. The maintainers' answer in the related typescript-eslint issue on GitHub is short: there is nothing they can do until TS 7 provides an API. The TypeScript 7.1 beta (October 2026) comes with a new API; when typescript-eslint will support it is not clear at the time of writing.

One TypeScript 6.0 change matters for this post too: strict is now on by default. Bookstore's tsconfig.json has no "strict": true line; when we tried x.length in a file without a tsconfig, the compiler still gave error TS18047: 'x' is possibly 'null'. The type checker, the cheapest layer of the spectrum, is in place.

In Production

Read strict set upgrades carefully. typescript-eslint's strict sets can gain new rules in minor releases; with a caret range in package.json, an npm install can break CI. That's why ng add writing typescript-eslint with an exact version (8.71.0) is a good default; do the upgrade in a separate pull request, like AnalysisLevel on the .NET side.

Don't turn on type-aware lint all at once in a legacy Angular project. Bookstore had 7 errors outside the pull request; in a hundred-thousand-line project that number is in the thousands. Turn on a few rules that catch real bugs, such as no-floating-promises and no-misused-promises, one at a time first, and add the no-unsafe-* family as the any cleanup progresses.

Don't treat the linter as the only quality gate. There are areas tools don't see, such as promises in templates; those are caught by the Angular compiler's extended diagnostics, by tests and in review.

Common Pitfalls

Turning on a type-aware set without projectService. If the parser isn't given a TypeScript project, the rules that need type information can't run. In Bookstore, after deleting the two lines ESLint stopped at the first type-aware rule: Error while loading rule '@typescript-eslint/await-thenable': You have used a rule which requires type information, but don't have parserOptions set to generate type information for this file. Don't forget tsconfigRootDir either; otherwise running the linter from another folder can pick up the wrong tsconfig.

Carrying old .eslintrc.json examples over to ESLint 10. Most Angular ESLint examples on the internet are still in .eslintrc.json form; ESLint 10 doesn't read that file. Migration, and old projects coming from TSLint, are the topic of the sixth post.

Silencing with void. The easiest way past no-floating-promises is to write void this.router.navigate(...). Sometimes that's right, like the void this.cartStore.load() in Bookstore's App component; but in buyNow it would have hidden the bug. void means "I deliberately don't care about this promise's result or error"; question that sentence in code review.

Putting the cache file into the CI cache. If you keep .eslintcache in CI, the stale result problem above happens in everyone's pull request.

A Small Experiment

★☆☆ In your own Angular project, replace tseslint.configs.recommended with recommendedTypeChecked and turn on projectService. How many new errors appeared? How many of them are no-floating-promises or no-misused-promises?

★★☆ Run npx eslint --cache and npx eslint back to back in your own project and compare the times. Then change the return type of a function whose callers live in another file, and run both commands again. When does the cached result mislead you?

★★★ Turn on @angular-eslint/no-uncalled-signals and no-implicit-take-until-destroyed, and tie an RxJS subscription in your project to takeUntilDestroyed() outside the constructor. What does the rule say? Compare it with how Angular reports the same mistake at runtime.

Rules now run during build and lint on both the .NET and the Angular side. But seeing both sets of results in one place, over time and focused on new code only, needs another tool. In the next post we bring up SonarQube next to Bookstore with Docker and set up a quality gate.