Mert Özen Further With Every Line
Software Design Coding Conventions and Team Standards — #04

Conventions in Angular and TypeScript: The Style Guide, Prettier and Dividing Responsibility with ESLint

Mert Özen Nov 14, 2026 12 min 5 views
Conventions in Angular and TypeScript: The Style Guide, Prettier and Dividing Responsibility with ESLint

In an Angular project formatting belongs to Prettier, naming and selector rules to ESLint, and eslint-config-prettier draws the line between them: without it, two copied @stylistic rules produced 110 errors on Prettier-formatted code. The post covers the Angular 22 Style Guide's decisions, keeping suffixed names in older projects, Prettier flagging 7 files in a fresh project, the naming-convention rule and ESLint 10 ignoring .eslintrc.

Version note: The code in this post was tried with Angular 22.2 (CLI 22.2.7, TypeScript 6.0), ESLint 10.15, typescript-eslint 8.71, angular-eslint 22.6, Prettier 3.10.1 and eslint-config-prettier 10.1.8 on Node.js 24.

Guess First: How Many Files Does Prettier Flag in a New Project?

We start a fresh Angular 22 project for Bookstore's web UI. In this version, the Angular CLI adds Prettier as a devDependency and drops in a ready-made .prettierrc:

$ npx @angular/cli@22.2.7 new bookstore-web --defaults --skip-git --ssr=false --zoneless
$ cat bookstore-web/.prettierrc
{
  "printWidth": 100,
  "singleQuote": true,
  "overrides": [
    {
      "files": "*.html",
      "options": {
        "parser": "angular"
      }
    }
  ]
}

Then we add ESLint with ng add angular-eslint and, without writing a single line of code, run npx prettier --check . on the files the tools generated themselves. The CLI wrote Prettier's configuration, and the CLI wrote the files. How many files come out "not formatted"?

The Main Decisions in the Angular Style Guide

On the Angular side, the one official source of conventions is the Angular Style Guide on angular.dev. The guide describes itself as a recommended rather than mandatory set, points to Google's TypeScript style guide for general TypeScript rules, and deals with the Angular-specific part of application code. The page carries no update date; the version we read is the Angular 22 documentation. Let us summarize the decisions that affect Bookstore in our own words.

File names are separated with hyphens and match the main TypeScript identifier inside the file: the BookList class lives in book-list.ts. The test file takes the same name and ends with .spec.ts. Catch-all names like helpers, utils and common are avoided. A component's .ts, .html and .css files share the same base name.

Folders are organized by feature, not by file type: features/catalog/ and features/cart/ instead of components/ and services/ folders. Tests sit next to the code they test; each file carries a single concept where possible.

Dependencies come from the inject() function, not constructor parameters. Angular-specific properties (injected dependencies, inputs, outputs, queries) are grouped before methods. A component focuses on presentation; validation and data transformation go into separate code. Templates hold only simple expressions; complex logic moves to TypeScript, usually into a computed. Members used only by the template are protected, and the inputs, outputs, models and queries Angular initializes are readonly. class and style bindings are preferred over NgClass and NgStyle. Event handlers are named for what they do, not for the event that triggers them: addToCart, not onClick. Lifecycle hooks are kept short and call well-named methods.

The most visible change is in file names. With Angular 20, the style guide and the CLI dropped the type suffix from file names; we saw this in the Angular series' post that also covers the signal API changes, "Angular 20: Stable Signal APIs, httpResource and the New File Naming". Today's output from the Angular 22 CLI:

$ npx ng g component features/catalog/book-list
CREATE src/app/features/catalog/book-list/book-list.css (0 bytes)
CREATE src/app/features/catalog/book-list/book-list.spec.ts (547 bytes)
CREATE src/app/features/catalog/book-list/book-list.ts (196 bytes)
CREATE src/app/features/catalog/book-list/book-list.html (24 bytes)
$ npx ng g service features/catalog/book-catalog
CREATE src/app/features/catalog/book-catalog.spec.ts (346 bytes)
CREATE src/app/features/catalog/book-catalog.ts (82 bytes)

The component class is BookList and the service class is BookCatalog; no Component or Service suffix. In this version the Angular 22 CLI generated the service with the @Service() decorator; that decorator's DI behavior is not the subject of this post, what concerns us here is naming. The Bookstore component that follows these rules:

import { Component, computed, inject, input, output } from '@angular/core';

import { Book } from '../book';
import { BookCatalog } from '../book-catalog';

@Component({
  selector: 'bs-book-list',
  templateUrl: './book-list.html',
  styleUrl: './book-list.css',
})
export class BookList {
  private readonly catalog = inject(BookCatalog);

  readonly maxPrice = input(1000);
  readonly bookAdded = output<Book>();

  protected readonly books = computed(() =>
    this.catalog.findAll().filter((book) => book.price <= this.maxPrice()),
  );

  protected addToCart(book: Book): void {
    this.bookAdded.emit(book);
  }
}

The filter lives in the computed, not in the template; the template only iterates over books(). books and addToCart are protected because only the template uses them, and the selector carries Bookstore's bs prefix.

Living Alongside Older Projects

A project created before Angular 20 has hundreds of *.component.ts and *.service.ts files. Renaming them all is risky and splits the git blame history. In that case, rather than applying the new rule only to new code, it is more consistent to configure the CLI to generate with the old rule. The component, directive and service schematics have a type option, and the guard, pipe, interceptor, resolver and module schematics have a typeSeparator option. Under the project in angular.json:

"schematics": {
  "@schematics/angular:component": { "type": "component" },
  "@schematics/angular:service": { "type": "service" },
  "@schematics/angular:guard": { "typeSeparator": "." }
}
$ npx ng g component features/cart/cart-summary --dry-run
CREATE src/app/features/cart/cart-summary/cart-summary.component.css (0 bytes)
CREATE src/app/features/cart/cart-summary/cart-summary.component.spec.ts (632 bytes)
CREATE src/app/features/cart/cart-summary/cart-summary.component.ts (236 bytes)
CREATE src/app/features/cart/cart-summary/cart-summary.component.html (27 bytes)
$ npx ng g guard features/cart/checkout --dry-run
CREATE src/app/features/cart/checkout.guard.spec.ts (476 bytes)
CREATE src/app/features/cart/checkout.guard.ts (132 bytes)

Without this setting, the same guard is generated as checkout-guard.ts (the default separator is a hyphen). Which path you choose matters less; what matters is that the decision is written in angular.json and the two naming schemes do not mix randomly in the repo. If you do migrate, do it in a single PR with no other changes; the .git-blame-ignore-revs technique from the fifth post helps here too.

Prettier: Config, Ignore and Templates

In Bookstore, the formatting of TypeScript, HTML (HyperText Markup Language), CSS, SCSS and JSON (JavaScript Object Notation) files belongs to Prettier. Like CSharpier, Prettier is opinionated: the option philosophy page in its docs calls some of the existing options a historical artifact, says new option requests are not accepted, and gives the reason that each new option only moves the debate to the option's value. The gist of the "Why Prettier" page is the same: the main reason to adopt the tool is to end style debates.

The overrides section in the CLI-generated .prettierrc matters: *.html files are read with the angular parser, so Prettier understands Angular syntax such as @for, @if and {{ }}. A Bookstore template squeezed onto one line:

<ul>@for (book of books(); track book.isbn) {<li>{{book.title}} ({{book.price}} TRY) <button type="button" (click)="addToCart(book)" [disabled]="!book.inStock">Add to cart</button></li>}</ul>
$ npx prettier --write src/app/features/catalog/book-list/book-list.html
src/app/features/catalog/book-list/book-list.html 22ms
<ul>
  @for (book of books(); track book.isbn) {
    <li>
      {{ book.title }} ({{ book.price }} TRY)
      <button type="button" (click)="addToCart(book)" [disabled]="!book.inStock">
        Add to cart
      </button>
    </li>
  }
</ul>

Prettier skips node_modules and, if there is a .gitignore in the folder where it runs, follows its rules too. For files that are kept in the repo but must not be formatted, you write a .prettierignore; in Bookstore that is the output of a code generator. Without the ignore file, src/app/api/generated/payment-api.ts raised a warning; after adding it, the check passed clean:

# Code generator output
src/app/api/generated/

Now the answer to the guess. Without writing any code, after ng new and ng add angular-eslint:

$ npx prettier --check .
Checking formatting...
[warn] src/app/app.config.ts
[warn] src/app/app.html
[warn] src/app/app.spec.ts
[warn] src/index.html
[warn] src/main.ts
[warn] tsconfig.app.json
[warn] tsconfig.spec.json
[warn] Code style issues found in 7 files. Run Prettier with --write to fix.

Seven files. The CLI's templates do not match Prettier's output exactly; for example, the chain in main.ts is split over two lines, while Prettier joins it into one because it fits within 100 characters, and it also collapses the single-element arrays in tsconfig.app.json onto one line. The upshot is simple: one of the first tasks in a new project is to run npx prettier --write . and make a separate commit. If you skip it, these seven files end up in the diff of the first feature PR.

Why Formatting Is Not ESLint's Job

For years ESLint carried both quality and formatting rules: indent, quotes, semi, max-len. These rules were deprecated in ESLint 8.53.0 and are still present in ESLint 10.15; the rule metadata in the installed package says deprecatedSince: "8.53.0" for each of them. typescript-eslint first deprecated its own formatting rules and then removed them entirely in v8: the installed 8.71 has no indent, quotes, semi or member-delimiter-style rule. These rules moved to the community-maintained ESLint Stylistic project (@stylistic/eslint-plugin).

typescript-eslint's "What About Formatting?" page lays out the reasoning plainly. Lint rules run independently of each other, one rule's fix can break another, and reaching a final result can take several passes; the slowdown is especially noticeable with type-aware linting. A formatter, on the other hand, reprints code regardless of its existing layout, and is more consistent and faster. The recommendation: leave formatting to a formatter like Prettier, use ESLint for code quality and convention rules, and turn off formatting rules enabled by third-party configs with eslint-config-prettier.

Watch out for a naming confusion: tseslint.configs.stylistic in the ESLint config is not a formatting set. It enables rules about writing preferences, not layout; for example, consistency between interfaces and type aliases. It does not clash with Prettier. For a team that does not use Prettier, @stylistic/eslint-plugin is a legitimate alternative, but then formatting becomes ESLint's responsibility and the two must not be enabled together.

ESLint Rules That Are Conventions

Most of ESLint's hundreds of rules are about code quality and belong to the upcoming Static Analysis series. Two rule families that touch conventions directly, that is, naming and structure decisions, belong to this series. The first is @typescript-eslint/naming-convention. Bookstore's decisions on the TypeScript side: camelCase by default, PascalCase for types, camelCase or UPPER_CASE for const variables, PascalCase for enum members, and interfaces without an I prefix.

The last decision is the opposite of the C# side, and deliberate. In the second post we enforced the I prefix on C# interfaces because that is .NET's own convention. In the TypeScript world, Google's style guide and Angular's own code do not prefix interfaces. Each language follows its own community's convention; for a team writing both, the difference becomes one line in the convention document. The ng lint output for a deliberately broken model file:

export interface IBook {
  isbn: string;
  title: string;
  price: number;
  in_stock: boolean;
}

export enum book_format {
  HARDCOVER,
  Paperback,
}

export const MAX_CART_ITEMS = 20;
export const DefaultCurrency = 'TRY';

export function isAffordable(book: IBook, Budget: number): boolean {
  return book.price <= Budget;
}
$ npx ng lint
src/app/features/catalog/book.ts
   1:18  error  Interface name `IBook` must not match the RegExp: /^I[A-Z]/u                                    @typescript-eslint/naming-convention
   5:3   error  Type Property name `in_stock` must match one of the following formats: camelCase                @typescript-eslint/naming-convention
   8:13  error  Enum name `book_format` must match one of the following formats: PascalCase                     @typescript-eslint/naming-convention
  14:14  error  Variable name `DefaultCurrency` must match one of the following formats: camelCase, UPPER_CASE  @typescript-eslint/naming-convention
  16:43  error  Parameter name `Budget` must match one of the following formats: camelCase                      @typescript-eslint/naming-convention

One detail: the HARDCOVER enum member raised no error. A single-word, all-caps name passes the rule's PascalCase check; HARD_COVER would have been caught. The tool checks shape, and gaps remain at the edges of that shape; as we said in the first post, the last word on naming belongs to review.

The second family is angular-eslint's selector rules. In the configuration the CLI generates, the prefix is app; Bookstore's decision is bs. After changing the prefix value in angular.json and the prefix in the ESLint rules, two components still had the old prefix:

$ npx ng lint
src/app/app.ts
  6:13  error  The selector should start with one of these prefixes: "bs" (https://angular.dev/style-guide#choosing-component-selectors)  @angular-eslint/component-selector

src/app/features/catalog/book-list/book-list.ts
  5:13  error  The selector should start with one of these prefixes: "bs" (https://angular.dev/style-guide#choosing-component-selectors)  @angular-eslint/component-selector

✖ 2 problems (2 errors, 0 warnings)

Both places must change: the prefix in angular.json governs the components the CLI will generate, the ESLint rule governs the existing ones. Change only one and either the CLI generates the wrong prefix or lint misses the old ones.

ESLint 10 Flat Config: A Minimal Setup

ng add angular-eslint generates an eslint.config.js; here is its final form with Bookstore's convention rules and eslint-config-prettier added (after a pass through Prettier):

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

module.exports = defineConfig([
  {
    files: ['**/*.ts'],
    extends: [
      eslint.configs.recommended,
      tseslint.configs.recommended,
      tseslint.configs.stylistic,
      angular.configs.tsRecommended,
    ],
    processor: angular.processInlineTemplates,
    rules: {
      '@typescript-eslint/naming-convention': [
        'error',
        { selector: 'default', format: ['camelCase'] },
        { selector: 'import', format: null },
        { selector: 'variable', modifiers: ['const'], format: ['camelCase', 'UPPER_CASE'] },
        { selector: 'typeLike', format: ['PascalCase'] },
        {
          selector: 'interface',
          format: ['PascalCase'],
          custom: { regex: '^I[A-Z]', match: false },
        },
        { selector: 'enumMember', format: ['PascalCase'] },
      ],
      '@angular-eslint/directive-selector': [
        'error',
        {
          type: 'attribute',
          prefix: 'bs',
          style: 'camelCase',
        },
      ],
      '@angular-eslint/component-selector': [
        'error',
        {
          type: 'element',
          prefix: 'bs',
          style: 'kebab-case',
        },
      ],
    },
  },
  {
    files: ['**/*.html'],
    extends: [angular.configs.templateRecommended, angular.configs.templateAccessibility],
    rules: {},
  },
  eslintConfigPrettier,
]);

A flat config is an array; later elements override earlier ones. That is why eslintConfigPrettier comes last: it turns off every formatting rule enabled before it. For flat config, the package's eslint-config-prettier/flat entry point is used. Which level to set the quality rules at, type-aware linting and the contents of the angular.configs sets are the Static Analysis series' job; here we only set up the convention rules and the boundary with Prettier. With this configuration, ng lint said "All files pass linting." and prettier --check . said "All matched files use Prettier code style!".

This is also why the version note says TypeScript 6.0: the Angular 22.2 compiler requires >=6.0 <6.1 as a peer dependency, and typescript-eslint 8.71 requires <6.1.0. TypeScript 7, rewritten in Go, is published on npm, but this toolchain runs on 6.0 today.

Common Pitfalls

Enabling formatting rules without eslint-config-prettier. We added two lines copied from an old project (@stylistic/quotes double, @stylistic/indent 4) to Prettier-formatted Bookstore code. eslint src reported 110 errors, all fixable with --fix. After --fix, ESLint was clean, but this time prettier --check src found ten files not formatted; git diff --shortstat: 93 insertions, 93 deletions. It is the TypeScript version of the format war from the third post. The package's own CLI shows the conflict too:

$ npx eslint-config-prettier src/app/app.ts
The following rules are unnecessary or might conflict with Prettier:

- @stylistic/indent

The following rules are enabled but cannot be automatically checked. See:
https://github.com/prettier/eslint-config-prettier#special-rules

- @stylistic/quotes
- no-unexpected-multiline

After adding eslintConfigPrettier at the end of the array, the same command said "No rules that are unnecessary or conflict with Prettier were found." and both ESLint and Prettier came back clean. Running this command once in CI is cheap insurance against someone adding a formatting rule later.

Copying old .eslintrc examples into ESLint 10. Most Angular ESLint examples on the internet use .eslintrc.json with "extends": ["plugin:@angular-eslint/recommended", "prettier"]. ESLint 10 does not look at that file at all:

$ npx eslint src

Oops! Something went wrong! :(

ESLint: 10.15.0

ESLint couldn't find an eslint.config.* file.

From ESLint v9.0.0, the default configuration file is now eslint.config.*.
If you are using a .eslintrc.* file, please follow the migration guide
to update your configuration file to the new format:

https://eslint.org/docs/latest/use/configure/migration-guide

We also tried ESLint 9's ESLINT_USE_FLAT_CONFIG=false escape hatch; ESLint 10 ignored the variable and gave the same error. ng lint was terser: "Could not find config file." When migrating an old configuration, the "prettier" entry becomes an eslint-config-prettier/flat import, and overrides blocks become config objects with a files field.

Not pinning the Prettier version. The "prettier": "^3.8.1" caret range the CLI writes lets different machines install different Prettier versions when a new minor version comes out; the lock file partly prevents this, but npm install updates it. Prettier's install docs recommend installing an exact version (--save-exact); this is the counterpart of pinning CSharpier with the manifest.

Mistaking the style guide for lint rules. Most decisions like "name handlers for the action", "no complex logic in templates" and "components focus on presentation" have no ESLint rule. They live in review, and the convention document should mark them as "review".

A Small Experiment

★☆☆ Run npx prettier --check . in your own Angular project. If no unformatted files show up, set printWidth in .prettierrc to 80 and count how many files are affected; then revert it.

★★☆ Add a rule to @typescript-eslint/naming-convention requiring boolean variables to start with is, has or can (the types: ['boolean'] option needs type-aware linting; the error message will tell you what to do). How many violations came up, and which were really bad names?

★★★ Temporarily add an @stylistic formatting rule to your project, see the conflict with npx eslint-config-prettier, then put eslintConfigPrettier at the start of the array instead of the end and run the same command. Explain from the output why the order matters.

Now on both sides the rules are in files and tools enforce them. But tools only help when they run. In the last post of the series we automate these rules: pre-commit hooks, CI gates, gradual adoption in a legacy codebase, and .git-blame-ignore-revs, which hides the big formatting commit from git blame.