Skip to content
Back to blog

Streamline Your Angular Projects: Easy ESLint and Prettier Configuration

11 March 2025 33 min read
# eslint

1. Introduction

In this post, I'll present one of the most effective and fastest ESLint and Prettier configurations that I've recently been using in my Angular projects. Let's face it: we've probably all been there - spending hours configuring these tools, only to lose a fair bit of patience along the way.

2. That's the worst

One of the most frustrating parts of this process is that, for practically every single rule we want to implement - be it object formatting, sorting imports or interfaces, or introducing a strict code-quality rule - we end up having to install a separate package. This leads to more dependencies in our project, and these packages can become outdated or incompatible with one another, resulting in all sorts of issues. To make life a bit easier, you can use packages that bundle several rules together.

3. Why?

Before we dive into the main topic, let's discuss why setting up these tools is worth the effort. I remember a project where there was no configuration at all: no code formatting and no way to detect potential errors. The result? A ton of trivial mistakes and broken builds sneaking into the main branch. Code reviews were next to impossible because everyone formatted and wrote code however they liked. Working with code in that state can lead to burnout and reduced morale, which might eventually turn into indifference - and that's when people start looking for different projects to join.

By applying the right rules, you'll not only improve the overall quality of your code, but also give developers real-time feedback. For example, they might discover they need to structure their code differently or realize they forgot something important. Plus, they won't have to memorize in which order to arrange class elements or how to sort interfaces - the tools will do that for them automatically.

4. A necessary compromise

The solution I'm going to propose isn't perfect because, frankly, these tools aren't perfect either. You'll need to find a balance that fits your needs. I'll guide you through the process step by step so you understand exactly what's happening and can easily adapt it to your own requirements - or those of your team.

5. What is Prettier?

Arguably one of the most popular code formatting tools, Prettier automatically enforces a consistent coding style across projects based on predefined rules. By removing the need for manual formatting, it saves developers time and helps maintain a clean, uniform look throughout the codebase.

6. What is ESLint?

One of the most widely used static analysis tools, ESLint helps developers identify and fix issues in their code. Its core functionality lies in improving code quality by detecting errors, highlighting potential problems, and enforcing consistent practices in a project - ultimately nudging developers toward adopting best practices as defined by the chosen set of rules.

7. Environment setup

Before we begin the configuration process, make sure your environment is properly set up:

Note: This guide has been verified with Angular 22, ESLint 10, typescript-eslint 8, angular-eslint 22, eslint-plugin-perfectionist 5, @stylistic/eslint-plugin 5 and Prettier 3.

8. Project installation

First, install the @angular/cli package globally by running the following command in your terminal:

text
npm install -g @angular/cli@latest

If you already have the Angular CLI installed, you can skip this step - just remember to update it to the latest version if needed.

Next, generate a new Angular project by using the following command:

text
ng new angular-eslint-prettier-template --commit=false --create-application=true \
  --inline-style=false --inline-template=false --package-manager=npm --prefix=aept \
  --routing=true --skip-git=true --skip-tests=true --ssr=false --standalone=true \
  --strict=true --style=scss

For a detailed explanation of each parameter, refer to the official Angular CLI Documentation.

Note: Since Angular 20 the CLI follows the 2025 style guide, so the root component is generated as app.ts (class App) instead of app.component.ts (class AppComponent). Standalone components are the default, and since Angular 22 so is the OnPush change detection strategy.

9. ESLint installation

We'll use the angular-eslint package to set up ESLint. Because we're working with the Angular CLI, we can simply add the schematic by running:

text
ng add angular-eslint

After a short while, you'll be prompted to confirm the installation. Type Y and press Enter.

You should then see:

  • An eslint.config.js file created (I recommend renaming it to eslint.config.mjs).
  • Your package.json updated (eslint, @eslint/js, angular-eslint and typescript-eslint added to devDependencies, plus a lint script).
  • Your angular.json updated (a lint target, so ng lint works out of the box).

And that's all it takes! Now we have ESLint configured with a set of recommended rules. But… this is just the beginning!

10. Prettier installation

Before we move on to the details, let's install Prettier. In your terminal, run:

text
npm install -D prettier

After the installation, create a new file named prettier.config.mjs in the root directory of your project. Leave it empty for now - we'll come back and fill it in later.

11. Visual Studio Code extension installation

You'll need to install three extensions:

  • Prettier - Code Formatter - required for integrating Prettier in VS Code.
  • ESLint - required for integrating ESLint in VS Code.
  • Format Code Action - exposes formatting as the source.formatDocument code action, which lets us control the order of formatting actions on save.

Note: Do not install the Prettier ESLint extension. This hybrid extension hasn't been updated since early 2024 and still bundles ESLint 8, so it doesn't work with the ESLint 10 flat config used in this guide.

12. Visual Studio Code configuration

To allow our code to be formatted automatically, we need to adjust a few settings in VS Code. Press Ctrl + Shift + P (Windows/Linux) or Cmd + Shift + P (macOS) to open the Command Palette, and search for Preferences: Open User Settings (JSON).

When you select this option, your personal VS Code configuration file will open. While I won't go into all the possible settings here, let's focus on the essentials for integrating our tools. Simply copy and paste the code below, or merge it with your existing configuration.

json
{
  "editor.codeActionsOnSave": {
    "source.fixAll.eslint": "explicit",
    "source.formatDocument": "explicit"
  },
  "editor.defaultFormatter": "esbenp.prettier-vscode",
  "editor.formatOnPaste": false,
  "editor.formatOnSave": false,
  "editor.formatOnType": false
}

Let me explain each of the options:

  • editor.codeActionsOnSave - This option specifies which actions to run on file save and, most importantly, in which order to run them (VS Code executes them in the order the keys are listed).
    • source.fixAll.eslint - Applies all auto-fixable ESLint rules first (sorting imports, class members, objects and so on).
    • source.formatDocument - Provided by the Format Code Action extension. It works like the Format Document command (Shift+Alt+F on Windows, Ctrl+Shift+I on Linux, Shift+Option+F on macOS), formatting the document with the default formatter - in our case, Prettier.
  • editor.defaultFormatter - Defines the default formatter, which we've set to Prettier.
  • Disabling the defaults: editor.formatOnPaste, editor.formatOnSave, and editor.formatOnType - We turn these off because we're now specifying the actions to run manually in the editor.codeActionsOnSave property.

In summary, this setup lets us use both ESLint and Prettier in tandem simply by pressing Ctrl+S (Cmd+S on macOS). By defining the order, ESLint fixes code-quality and ordering issues first, and then Prettier gives the result its final shape.

Note: Running Prettier last guarantees that the saved file is always formatted exactly as Prettier would format it. ESLint auto-fixes (for example, sorting named imports) can change line lengths or wrapping, and Prettier cleans that up afterwards. Thanks to eslint-config-prettier (covered later), ESLint never reports formatting issues that Prettier is responsible for, so the two tools don't fight each other.

13. Prettier configuration

Let's begin with a simple Prettier config. Below is a standard configuration I use in almost all of my projects. If you'd like more details, refer to the official Prettier documentation.

javascript
export default {
  printWidth: 120, // default is 80
  singleQuote: true, // default false
  trailingComma: 'es5', // default 'all'
};

The remaining options have default values.

Copy this snippet into your newly created prettier.config.mjs file. Then, in Visual Studio Code, press Ctrl+Shift+P and select Developer: Reload Window to restart VS Code (ensuring all processes and extensions reload properly).

To test it out, open any file and mess it up a bit by adding extra spaces, new lines, or indentation. Then press Ctrl+S to save.

Does it work? Pretty simple, right?

This is just a basic setup - later on, we'll extend it with some helpful plugins.

14. Extended Prettier configuration

Continuing our discussion on Prettier, we can enhance its functionality with a few additional plugins. Beyond standard code formatting in the IDE, you can also configure Prettier to sort JSON files, package.json, HTML attributes, or even Tailwind CSS classes, among other options.

Below are four plugins worth considering (make sure to check out their documentation to fully explore their capabilities):

Install them using:

text
npm install -D prettier-plugin-organize-attributes prettier-plugin-packagejson prettier-plugin-sort-json prettier-plugin-tailwindcss

By their names alone, it's fairly clear which plugin does what. Next, open your prettier.config.mjs file and add the following configuration:

javascript
export default {
  attributeGroups: [
    // plugins configuration
    '$CODE_GUIDE',
    '$ANGULAR_ELEMENT_REF',
    '$ANGULAR_STRUCTURAL_DIRECTIVE',
    '$ANGULAR_ANIMATION',
    '$ANGULAR_ANIMATION_INPUT',
    '$ANGULAR_TWO_WAY_BINDING',
    '$ANGULAR_INPUT',
    '$ANGULAR_OUTPUT',
  ],
  attributeSort: 'ASC',
  jsonRecursiveSort: true,
  plugins: [
    'prettier-plugin-organize-attributes',
    'prettier-plugin-packagejson',
    'prettier-plugin-sort-json',
    'prettier-plugin-tailwindcss',
  ],

  // default prettier options
  printWidth: 120,
  singleQuote: true,
  trailingComma: 'es5',
};
  1. attributeGroups and attributeSort - These options belong to the prettier-plugin-organize-attributes plugin.
    • attributeGroups defines the order in which HTML attributes are sorted. The first value references the predefined Code Guide by @mdo preset. The remaining entries ensure that Angular-specific attributes appear in a particular order. Documentation for this plugin is somewhat sparse, so you may need to check its source code to see all possible attribute groups.
    • attributeSort specifies how attributes should be sorted - in this case, ascending.
  2. jsonRecursiveSort - This belongs to prettier-plugin-sort-json. Setting this value to true means that the entire JSON structure (including nested properties) will be sorted. Note that this plugin does not sort package.json, which is why we use a separate plugin for that.
  3. plugins - This array tells Prettier which plugins to load.

The other two plugins, prettier-plugin-packagejson and prettier-plugin-tailwindcss, do not require extra configuration - just installing and referencing them is enough.

Note: Keep prettier-plugin-tailwindcss as the last entry in the plugins array - it has to be loaded after other plugins that touch the same files. If you're on Tailwind CSS v4 and your stylesheet defines a custom theme or utilities (@theme, @utility), also point the plugin at your CSS entry file with the tailwindStylesheet option (for example, tailwindStylesheet: './src/styles.css'), otherwise classes are sorted using the default Tailwind theme only.

By extending your Prettier setup with these plugins, you'll ensure that your HTML attributes, JSON files, package.json, and Tailwind CSS classes remain well-organized and consistently formatted.

15. ESLint configuration

After renaming the file to eslint.config.mjs, your ESLint configuration should look like this:

javascript
// @ts-check
import eslint from '@eslint/js';
import { defineConfig } from 'eslint/config';
import tseslint from 'typescript-eslint';
import angular from 'angular-eslint';

export default defineConfig([
  {
    files: ['**/*.ts'],
    extends: [
      eslint.configs.recommended,
      tseslint.configs.recommended,
      tseslint.configs.stylistic,
      angular.configs.tsRecommended,
    ],
    processor: angular.processInlineTemplates,
    rules: {
      '@angular-eslint/directive-selector': ['error', { type: 'attribute', prefix: 'aept', style: 'camelCase' }],
      '@angular-eslint/component-selector': ['error', { type: 'element', prefix: 'aept', style: 'kebab-case' }],
    },
  },
  {
    files: ['**/*.html'],
    extends: [angular.configs.templateRecommended, angular.configs.templateAccessibility],
    rules: {},
  },
]);

Note: Unless your package.json contains "type": "module", the schematic generates a CommonJS file. After renaming it to .mjs, you must manually replace any require() statements with import, and switch from module.exports to export default. This ensures you're using the modern ES module approach in your configuration file.

Note: defineConfig() comes from ESLint itself (available since ESLint 9.22) and supports extends natively, so there's no need to spread (...) the shared configs. Older tutorials use tseslint.config() instead - it still works, but typescript-eslint has deprecated it in favor of defineConfig().

Preventing conflicts with Prettier

Since we're using both ESLint and Prettier, there can be conflicting rules between the two. To avoid this, install eslint-config-prettier by running:

text
npm install -D eslint-config-prettier

Then, import it and add it as the last element of the array passed to defineConfig(), so it can turn off every rule that conflicts with Prettier. Your ESLint configuration might now look like this:

javascript
// @ts-check
import eslint from '@eslint/js';
import { defineConfig } from 'eslint/config';
import tseslint from 'typescript-eslint';
import angular from 'angular-eslint';
import eslintConfigPrettier from 'eslint-config-prettier/flat'; // 1. import the flat config of eslint-config-prettier

export default defineConfig([
  {
    files: ['**/*.ts'],
    extends: [
      eslint.configs.recommended,
      tseslint.configs.recommended,
      tseslint.configs.stylistic,
      angular.configs.tsRecommended,
    ],
    processor: angular.processInlineTemplates,
    rules: {
      '@angular-eslint/directive-selector': ['error', { type: 'attribute', prefix: 'aept', style: 'camelCase' }],
      '@angular-eslint/component-selector': ['error', { type: 'element', prefix: 'aept', style: 'kebab-case' }],
    },
  },
  {
    files: ['**/*.html'],
    extends: [angular.configs.templateRecommended, angular.configs.templateAccessibility],
    rules: {},
  },
  eslintConfigPrettier, // 2. add it as the very last config
]);

Given the previous steps - installing the necessary extensions and updating your VS Code settings - ESLint should now work without any issues.

Adding additional Angular rules

Because we're using Angular, we want a few extra rules that enforce or encourage modern Angular best practices. Below are some examples:

  • @angular-eslint/template/prefer-self-closing-tags - Enforces self-closing tags for elements without content (e.g. <app-header />).
  • @angular-eslint/prefer-standalone - Prevents components, directives and pipes from opting out of standalone (standalone: false).
  • @angular-eslint/template/prefer-ngsrc - Encourages using the ngSrc directive (NgOptimizedImage) to optimize image loading.
  • @angular-eslint/template/prefer-control-flow - Enforces usage of the built-in control flow (@if, @for, @switch) instead of traditional structural directives.
  • @angular-eslint/prefer-on-push-component-change-detection - Prevents components from opting out of OnPush change detection (ChangeDetectionStrategy.Eager, or the deprecated Default).
  • @angular-eslint/no-pipe-impure - Disallows impure pipes.
  • @angular-eslint/prefer-signals - Encourages using signal-based APIs (input(), viewChild(), etc.) instead of the legacy @Input(), @ViewChild() and similar decorators.
  • @angular-eslint/use-injectable-provided-in - Requires specifying providedIn in the @Injectable() decorator (recommended value: root).
  • @angular-eslint/contextual-lifecycle - Prevents using lifecycle hooks outside their proper context.
  • @angular-eslint/no-async-lifecycle-method - Disallows marking lifecycle hooks as async (Angular doesn't wait for them to resolve).

Note: prefer-standalone, prefer-on-push-component-change-detection and contextual-lifecycle are already part of angular.configs.tsRecommended, and template/prefer-control-flow is part of angular.configs.templateRecommended (angular-eslint 22). Listing them explicitly is still useful - it documents your intent and lets you adjust their severity (here, OnPush is lowered to warn).

There are many more rules available for not only Angular-specific code but also TypeScript in general. You can find the full list in the angular-eslint repository.

For testing purposes, add all the rules listed above to your ESLint config. Your final file might look like this:

javascript
// @ts-check
import eslint from '@eslint/js';
import { defineConfig } from 'eslint/config';
import tseslint from 'typescript-eslint';
import angular from 'angular-eslint';
import eslintConfigPrettier from 'eslint-config-prettier/flat';

export default defineConfig([
  {
    files: ['**/*.ts'],
    extends: [
      eslint.configs.recommended,
      tseslint.configs.recommended,
      tseslint.configs.stylistic,
      angular.configs.tsRecommended,
    ],
    processor: angular.processInlineTemplates,
    rules: {
      '@angular-eslint/directive-selector': ['error', { type: 'attribute', prefix: 'aept', style: 'camelCase' }],
      '@angular-eslint/component-selector': ['error', { type: 'element', prefix: 'aept', style: 'kebab-case' }],
      '@angular-eslint/prefer-standalone': ['error'],
      '@angular-eslint/prefer-on-push-component-change-detection': ['warn'],
      '@angular-eslint/no-pipe-impure': ['error'],
      '@angular-eslint/prefer-signals': ['error'],
      '@angular-eslint/use-injectable-provided-in': ['error'],
      '@angular-eslint/contextual-lifecycle': ['error'],
      '@angular-eslint/no-async-lifecycle-method': ['error'],
    },
  },
  {
    files: ['**/*.html'],
    extends: [angular.configs.templateRecommended, angular.configs.templateAccessibility],
    rules: {
      '@angular-eslint/template/prefer-self-closing-tags': ['error'],
      '@angular-eslint/template/prefer-ngsrc': ['error'],
      '@angular-eslint/template/prefer-control-flow': ['error'],
    },
  },
  eslintConfigPrettier,
]);

Restart VS Code, and then open app.ts. Since Angular 22 uses OnPush by default, the freshly generated component is already compliant - to see the rule in action, add changeDetection: ChangeDetectionStrategy.Eager to the @Component() decorator. You should see a warning that components should not opt out of OnPush - this confirms your lint rules are working. Similarly, adding an <img src="..."> or a *ngIf to app.html should trigger the prefer-ngsrc and prefer-control-flow errors. You can also run ng lint in the terminal to check the whole project at once.

Discuss with your team which additional restrictions or rules you want to enforce to improve code quality and maintainability in your project. Think about existing issues you've encountered and see if there are rules that can help prevent those bad practices from reoccurring.

Configuring rules is fairly straightforward: pick a rule, copy its name, decide the severity (off, warn, or error), and add it under the rules section.

Now that we've nailed down the linting part, let's move on to formatting our code.

16. Advanced ESLint configuration

This configuration goes beyond simple linting to account for Angular-specific patterns and new language features like inject(), signal(), input(), output(), and more. It focuses on grouping and ordering class members in a logical manner - for example, placing inject() calls at the top and grouping any input() and output() in the same area. Additionally, it addresses sorting rules for class members, interfaces, enums, and so on.

While there's no perfect configuration, it's worth discussing these rules with your team to find an approach that works for everyone. Personally, I prefer a mixed style that groups Angular-specific methods in a particular place, while class properties and methods are organized by access modifiers.

Install necessary plugins

We'll start by installing two plugins: Perfectionist and ESLint Stylistic. Run the following commands to install them:

text
npm install -D eslint-plugin-perfectionist @stylistic/eslint-plugin

Note: Install the unified @stylistic/eslint-plugin package. The language-specific packages, such as @stylistic/eslint-plugin-ts, are deprecated - all their rules (including the TypeScript-aware ones) now live in the main package.

After installing these packages, we need to add them to our ESLint configuration. Import them and register them under the plugins property, like so:

javascript
// @ts-check
import stylistic from '@stylistic/eslint-plugin';
import perfectionist from 'eslint-plugin-perfectionist';

export default defineConfig([
  {
    ...
    plugins: {
      '@stylistic': stylistic,
      perfectionist,
    },
    ...
  },
  ...
]);

Note: The key used in plugins becomes the rule prefix. With '@stylistic': stylistic the rules are named @stylistic/<rule>. If you're migrating from the old @stylistic/eslint-plugin-ts package and want to keep your existing @stylistic/ts/<rule> names, register the unified plugin under that key instead: '@stylistic/ts': stylistic.

Adding additional formatting rules

By registering these plugins, we can use individual configurations. Note that I'm deliberately not adding the recommended, all-in-one rule sets, as that could reduce our control over the specific rules we want to enforce. Instead, I'm including only what I find essential.

Let's begin with some predefined rules. Below is a list of rules I personally use in my projects, along with brief descriptions:

  • @stylistic/lines-between-class-members - enforces a blank line between class members (we'll exclude single-line declarations and overloaded methods later)
  • perfectionist/sort-array-includes - enforces sorted array values if the includes() method is called immediately after the array is created
  • perfectionist/sort-classes - enforces sorted class members
  • perfectionist/sort-enums - enforces sorted TypeScript enum members
  • perfectionist/sort-exports - enforces sorted exports
  • perfectionist/sort-heritage-clauses - enforces sorted heritage clauses
  • perfectionist/sort-imports - enforces sorted imports
  • perfectionist/sort-interfaces - enforces sorted TypeScript interface properties
  • perfectionist/sort-intersection-types - enforces sorted intersection types in TypeScript
  • perfectionist/sort-modules - enforces sorted module members
  • perfectionist/sort-named-exports - enforces sorted named exports
  • perfectionist/sort-named-imports - enforces sorted named imports
  • perfectionist/sort-object-types - enforces sorted object types
  • perfectionist/sort-objects - enforces sorted objects
  • perfectionist/sort-switch-case - enforces sorted switch-case statements
  • perfectionist/sort-union-types - enforces sorted TypeScript union types
  • perfectionist/sort-variable-declarations - enforces sorted variable declarations within a scope

For most of these rules, simply adding an appropriate severity level (for example, error or warn) is sufficient. However, some rules require a bit more complex configuration. Below, I'm providing a default setup for these rules, setting them to error for now. In the later parts of this article, I'll expand on them in greater detail. Once you add these rules, your configuration file should look as follows for the time being:

javascript
// @ts-check
import eslint from '@eslint/js';
import stylistic from '@stylistic/eslint-plugin';
import angular from 'angular-eslint';
import eslintConfigPrettier from 'eslint-config-prettier/flat';
import perfectionist from 'eslint-plugin-perfectionist';
import { defineConfig } from 'eslint/config';
import tseslint from 'typescript-eslint';

export default defineConfig([
  {
    files: ['**/*.ts'],
    extends: [
      eslint.configs.recommended,
      tseslint.configs.recommended,
      tseslint.configs.stylistic,
      angular.configs.tsRecommended,
    ],
    plugins: {
      '@stylistic': stylistic,
      perfectionist,
    },
    processor: angular.processInlineTemplates,
    rules: {
      // angular lint rules
      '@angular-eslint/directive-selector': ['error', { type: 'attribute', prefix: 'aept', style: 'camelCase' }],
      '@angular-eslint/component-selector': ['error', { type: 'element', prefix: 'aept', style: 'kebab-case' }],
      '@angular-eslint/prefer-standalone': ['error'],
      '@angular-eslint/prefer-on-push-component-change-detection': ['warn'],
      '@angular-eslint/no-pipe-impure': ['error'],
      '@angular-eslint/prefer-signals': ['error'],
      '@angular-eslint/use-injectable-provided-in': ['error'],
      '@angular-eslint/contextual-lifecycle': ['error'],
      '@angular-eslint/no-async-lifecycle-method': ['error'],

      // angular formatting rules
      '@stylistic/lines-between-class-members': ['error'],
      'perfectionist/sort-array-includes': ['error'],
      'perfectionist/sort-classes': ['error'],
      'perfectionist/sort-enums': ['error'],
      'perfectionist/sort-exports': ['error'],
      'perfectionist/sort-heritage-clauses': ['error'],
      'perfectionist/sort-imports': ['error'],
      'perfectionist/sort-interfaces': ['error'],
      'perfectionist/sort-intersection-types': ['error'],
      'perfectionist/sort-modules': ['error'],
      'perfectionist/sort-named-exports': ['error'],
      'perfectionist/sort-named-imports': ['error'],
      'perfectionist/sort-object-types': ['error'],
      'perfectionist/sort-objects': ['error'],
      'perfectionist/sort-switch-case': ['error'],
      'perfectionist/sort-union-types': ['error'],
      'perfectionist/sort-variable-declarations': ['error'],
    },
  },
  {
    files: ['**/*.html'],
    extends: [angular.configs.templateRecommended, angular.configs.templateAccessibility],
    rules: {
      '@angular-eslint/template/prefer-self-closing-tags': ['error'],
      '@angular-eslint/template/prefer-ngsrc': ['error'],
      '@angular-eslint/template/prefer-control-flow': ['error'],
    },
  },
  eslintConfigPrettier,
]);

After saving and restarting Visual Studio Code, these rules should now properly format your code. However, you might notice that not everything behaves exactly as you'd like. In most cases, alphabetical sorting isn't a bad approach, but it doesn't always produce ideal results for classes. In the next section, we'll take a closer look at three specific rules - @stylistic/lines-between-class-members, perfectionist/sort-heritage-clauses, perfectionist/sort-classes - and expand their configurations to address this.

Extended configuration

@stylistic/lines-between-class-members

As the name implies, this rule enforces consistent spacing between members in a class. However, let's say you want to disable it for single-line property declarations and overloaded methods. How can you do that? Simply replace your current rule definition with the following:

javascript
'@stylistic/lines-between-class-members': [
  'error',
  'always',
  { exceptAfterSingleLine: true, exceptAfterOverload: true },
]

With this configuration, ESLint's auto-fix will always insert a blank line between methods but will skip the exceptions mentioned above.

perfectionist/sort-heritage-clauses

This rule sorts the list of types that appear after the implements keyword of a class (and after the extends keyword of an interface, which, unlike a class, can extend several interfaces at once).

By default, this rule sorts them alphabetically. However, you could change the sorting strategy to something like length of the class name or a custom order that fits your team's style.

In Angular, you might frequently implement lifecycle hooks. A logical approach is to sort those hooks based on their order of execution in the component lifecycle. Not only is this more organized, but it also aligns with how most developers think of the component lifecycle. After all, if you need to modify something in OnDestroy, you're more likely to look for it at the end rather than in the middle or beginning of the list.

Before applying the configuration below, think carefully about what you want to achieve or discuss it with your team so you can all agree on a consistent approach:

javascript
'perfectionist/sort-heritage-clauses': [
  'error',
  {
    groups: [
      'onChanges',
      'onInit',
      'doCheck',
      'afterContentInit',
      'afterContentChecked',
      'afterViewInit',
      'afterViewChecked',
      'onDestroy',
      'unknown',
    ],
    customGroups: [
      { groupName: 'onChanges', elementNamePattern: '^OnChanges$' },
      { groupName: 'onInit', elementNamePattern: '^OnInit$' },
      { groupName: 'doCheck', elementNamePattern: '^DoCheck$' },
      { groupName: 'afterContentInit', elementNamePattern: '^AfterContentInit$' },
      { groupName: 'afterContentChecked', elementNamePattern: '^AfterContentChecked$' },
      { groupName: 'afterViewInit', elementNamePattern: '^AfterViewInit$' },
      { groupName: 'afterViewChecked', elementNamePattern: '^AfterViewChecked$' },
      { groupName: 'onDestroy', elementNamePattern: '^OnDestroy$' },
    ],
  },
]

It's fairly straightforward. The values in the groups array define the order in which the interfaces will appear, and each string matches the groupName of an entry in customGroups. The elementNamePattern of each entry is a regex that matches the interface name.

Note: Older versions of Perfectionist (before v5) accepted customGroups for this rule as an object ({ onInit: '^OnInit$', ... }). That syntax was removed in Perfectionist 5 - ESLint will refuse to start with a should be array error if you still use it.

The configuration structure is quite straightforward: you can add any groups you want and determine their order based on your own preferences or the project's chosen style guide. Take note of the unknown value in the groups array. It covers any interfaces not explicitly defined by you and is, by default, always appended at the end. From this, you can easily infer how to customize your groups while also handling any remaining interfaces.

Also, keep in mind that this configuration applies not only to the implements list of your classes but to the extends list of your interfaces as well.

perfectionist/sort-classes

This is one of the most important rules because it's responsible for sorting all elements in a class, including properties, methods, lifecycle hooks, and any new Angular features.

The basic configuration is pretty straightforward; generally, you want to organize your groups by access modifiers - private class members at the bottom, then protected, and finally public at the top - while also maintaining alphabetical sorting by name.

Problems arise when, for example, you want your lifecycle hooks to appear directly below the constructor in a specific order, or you need legacy decorators above the constructor, while newer features like inject() remain at the very top, all without breaking your existing ordering scheme.

To solve this, you'll need not only the standard configuration provided by the Perfectionist documentation but also a bit of custom code that automatically generates specific groups based on function names. Trust me, there are so many possible functions that you wouldn't want to do this by hand.

Let's start with a standard extension of the rule, placing all regular class elements in the correct order:

javascript
'perfectionist/sort-classes': [
  'error',
  {
    groups: [
      'index-signature',
      ['static-property', 'static-accessor-property'],
      ['static-get-method', 'static-set-method'],
      ['protected-static-property', 'protected-static-accessor-property'],
      ['protected-static-get-method', 'protected-static-set-method'],
      ['private-static-property', 'private-static-accessor-property'],
      ['private-static-get-method', 'private-static-set-method'],
      'static-block',
      ['property', 'accessor-property'],
      ['protected-property', 'protected-accessor-property'],
      ['private-property', 'private-accessor-property'],
      'constructor',
      ['get-method', 'set-method'],
      ['protected-get-method', 'protected-set-method'],
      ['private-get-method', 'private-set-method'],
      ['method', 'function-property'],
      ['protected-method', 'protected-function-property'],
      ['private-method', 'private-function-property'],
      ['static-method', 'static-function-property'],
      ['protected-static-method', 'protected-static-function-property'],
      ['private-static-method', 'private-static-function-property'],
      'unknown',
    ],
  },
]

The group names are fairly intuitive, making it clear what should come before or after each element. For example, getters and setters should be placed below the constructor, while private properties should appear above it. It's pretty simple, right?

You can find the full list of configuration options here: sort-classes

For testing purposes, you can add some methods, fields, and lifecycle hooks to app.ts with different access modifiers to see the results after saving. As mentioned earlier, alphabetical sorting doesn't work perfectly for everything.

Our ultimate goal is to place Angular-specific functions right after index-signature (essentially at the top) and group them by name, keep legacy @Input() and @Output() decorated members right above the constructor, and put lifecycle hooks right below the constructor (specifically under getters and setters).

Below, you'll find a snippet of code that generates the appropriate objects, which we'll then use in our ESLint configuration. At root level create a file named eslint.angular-config.mjs and paste in the following code:

javascript
// @ts-check
// define the order of Angular lifecycle hooks
const angularLifecycleHooksConfig = [
  'ngOnChanges',
  'ngOnInit',
  'ngDoCheck',
  'ngAfterContentInit',
  'ngAfterContentChecked',
  'ngAfterViewInit',
  'ngAfterViewChecked',
  'ngOnDestroy',
];

// define the order of Angular functions config
const angularFunctionConfig = [
  {
    groupName: 'inject',
    modifiers: ['public', 'protected', 'private'],
    functions: ['inject'],
  },
  {
    groupName: 'toSignal',
    modifiers: ['public', 'protected', 'private'],
    functions: ['toSignal'],
  },
  {
    groupName: 'viewChildren',
    modifiers: ['public', 'protected', 'private'],
    functions: ['viewChildren'],
  },
  {
    groupName: 'viewChild',
    modifiers: ['public', 'protected', 'private'],
    functions: ['viewChild.required', 'viewChild'],
  },
  {
    groupName: 'contentChildren',
    modifiers: ['public', 'protected', 'private'],
    functions: ['contentChildren'],
  },
  {
    groupName: 'contentChild',
    modifiers: ['public', 'protected', 'private'],
    functions: ['contentChild.required', 'contentChild'],
  },
  // input, output and model fields must be public - enforced by the `no-restricted-syntax` rule
  {
    groupName: 'input',
    modifiers: ['public'],
    functions: ['input.required', 'input'],
  },
  {
    groupName: 'output',
    modifiers: ['public'],
    functions: ['output', 'outputFromObservable'],
  },
  {
    groupName: 'model',
    modifiers: ['public'],
    functions: ['model.required', 'model'],
  },
  {
    groupName: 'signal',
    modifiers: ['public', 'protected', 'private'],
    functions: ['signal'],
  },
  {
    groupName: 'resource',
    modifiers: ['public', 'protected', 'private'],
    functions: [
      'resource',
      'rxResource',
      'httpResource.arrayBuffer',
      'httpResource.blob',
      'httpResource.text',
      'httpResource',
    ],
  },
  {
    groupName: 'computed',
    modifiers: ['public', 'protected', 'private'],
    functions: ['computed'],
  },
  {
    groupName: 'linkedSignal',
    modifiers: ['public', 'protected', 'private'],
    functions: ['linkedSignal'],
  },
  {
    groupName: 'toObservable',
    modifiers: ['public', 'protected', 'private'],
    functions: ['toObservable'],
  },
  {
    groupName: 'effect',
    modifiers: ['public', 'protected', 'private'],
    functions: ['effect', 'afterRenderEffect'],
  },
];

/**
 * Creates custom groups and group names based on the lifecycle hooks config.
 *
 * @param {string[]} hooks - The names of Angular hooks in the recommended order
 * @returns {{
 *   lifecycleHookGroups: object[],
 *   lifecycleHookGroupNames: string[]
 * }}
 */
function createLifecycleHooksGroups(hooks) {
  const lifecycleHookGroups = hooks.map((hook) => ({
    groupName: `${hook}-hook`,
    type: 'alphabetical',
    anyOf: [{ selector: 'method', elementNamePattern: `^${hook}$` }],
  }));

  return {
    lifecycleHookGroups,
    lifecycleHookGroupNames: lifecycleHookGroups.map(({ groupName }) => groupName),
  };
}

/**
 * Creates custom groups and group names based on the Angular functions config (inject/viewChild, etc.).
 *
 * @param {Array<{
 *   groupName: string;
 *   modifiers: string[];
 *   functions: string[];
 * }>} functionsConfig
 * @returns {{
 *   angularFunctionGroups: object[],
 *   angularFunctionGroupNames: string[]
 * }}
 */
function createAngularFunctionsGroups(functionsConfig) {
  // create custom groups
  const angularFunctionGroups = functionsConfig.flatMap(({ groupName, modifiers, functions }) => {
    // each config element generates N groups (for public, protected, private) - depending on "modifiers"
    return modifiers.map((modifier) => ({
      groupName: `angular-${modifier}-${groupName}-function`,
      type: 'alphabetical',
      anyOf: functions.map((fn) => ({
        selector: 'property',
        modifiers: [`${modifier}`],
        // elementValuePattern checks if the property value is a call of e.g. `inject(` or `viewChild.required<`
        // the dot is escaped and the name must be followed by `(` or `<` - so `signalFactory()` does not match,
        // and no `.*$` is used, because `.` does not match newlines and multi-line values would be skipped
        elementValuePattern: `^${fn.replaceAll('.', '\\.')}[<(]`,
      })),
    }));
  });

  // extract group names (for registering in `groups`)
  // e.g., ["angular-public-inject-function", "angular-protected-inject-function", ...]
  const angularFunctionGroupNames = angularFunctionGroups.map(({ groupName }) => groupName);
  return { angularFunctionGroups, angularFunctionGroupNames };
}

// generate final objects
const { lifecycleHookGroups, lifecycleHookGroupNames } = createLifecycleHooksGroups(angularLifecycleHooksConfig);
const { angularFunctionGroups, angularFunctionGroupNames } = createAngularFunctionsGroups(angularFunctionConfig);

// combine custom groups into a single array
const angularCustomGroups = [...lifecycleHookGroups, ...angularFunctionGroups];

// export what is needed in the main ESLint configuration
export { angularCustomGroups, lifecycleHookGroupNames, angularFunctionGroupNames };

As you can see, we need to create objects that will be registered in customGroups, as well as arrays containing their names to define the order.

This file contains two key variables for defining the configuration: angularLifecycleHooksConfig and angularFunctionConfig. Their order is crucial.

Below is a quick explanation of how to modify these objects to suit your needs:

  • angularLifecycleHooksConfig is a simple string array that defines the order in which lifecycle hooks should appear. We automatically create new ESLint sorting rules from it. If you want to change the order, just move a hook up or down in the array. If you have a different set of methods you'd like to sort in this manner, you can extend the array or implement a similar function to createLifecycleHooksGroups, along with a new configuration object.
  • angularFunctionConfig is a more complex object containing groupName (a unique identifier used to build the group name), modifiers (an array of access modifiers allowed for that function - e.g., input(), output() and model() are part of the component's public API, so they get a public group only; the no-restricted-syntax rule in the final config reports them when they are marked protected, private or use a #private name), and functions (a list of functions to define).

By reviewing the file, you'll notice it's essentially a data-mapping process for generating specific objects based on the provided configurations. The list covers the signal-based APIs, resources (resource(), rxResource(), httpResource()), the RxJS interop helpers (toSignal(), toObservable(), outputFromObservable()) and effects (effect(), afterRenderEffect()). Resources are placed before computed(), because computed values usually read from them. If you're missing something, just add it to the configuration object, set the order appropriately, and restart your IDE. Variants called through a property, such as input.required() or httpResource.text(), have to be listed explicitly.

Note: Each pattern matches the function name at the beginning of the property value, followed directly by ( or < - for example ^viewChild\.required[<(]. Avoid a pattern like ^viewChild.*$: perfectionist builds the regex without flags, so . does not match line breaks and $ only matches the end of the whole value. A multi-line value such as computed(() => {...}) or toSignal(source$, {...}) would then silently fall into the regular property group. Such a pattern would also match unrelated names - ^signal.*$ catches signalFactory(), and ^viewChild.*$ catches viewChildren(...). A class member is assigned to the first custom group it matches, so keeping more specific entries, such as viewChildren or contentChildren, before their shorter counterparts is still a good habit.

Finally, you'll import these generated objects into your main ESLint configuration and place them where needed. On top of that, we'll add two more custom groups directly in the main configuration - input-decorator-properties and output-decorator-properties. They use decoratorNamePattern to catch members decorated with the legacy @Input() and @Output() decorators and place them right above the constructor (the patterns are anchored - ^Input$ - so a custom decorator such as @CustomInput() is not caught by accident), which is handy in projects that are still migrating to signal-based APIs. Once all configurations are in place, your final file should look like this:

javascript
// @ts-check
import eslint from '@eslint/js';
import stylistic from '@stylistic/eslint-plugin';
import angular from 'angular-eslint';
import eslintConfigPrettier from 'eslint-config-prettier/flat';
import perfectionist from 'eslint-plugin-perfectionist';
import { defineConfig } from 'eslint/config';
import tseslint from 'typescript-eslint';
import { angularCustomGroups, angularFunctionGroupNames, lifecycleHookGroupNames } from './eslint.angular-config.mjs';

export default defineConfig([
  {
    files: ['**/*.ts'],
    extends: [
      eslint.configs.recommended,
      tseslint.configs.recommended,
      tseslint.configs.stylistic,
      angular.configs.tsRecommended,
    ],
    plugins: {
      '@stylistic': stylistic,
      perfectionist,
    },
    processor: angular.processInlineTemplates,
    rules: {
      // angular lint rules
      '@angular-eslint/directive-selector': ['error', { type: 'attribute', prefix: 'aept', style: 'camelCase' }],
      '@angular-eslint/component-selector': ['error', { type: 'element', prefix: 'aept', style: 'kebab-case' }],
      '@angular-eslint/prefer-standalone': ['error'],
      '@angular-eslint/prefer-on-push-component-change-detection': ['warn'],
      '@angular-eslint/no-pipe-impure': ['error'],
      '@angular-eslint/prefer-signals': ['error'],
      '@angular-eslint/use-injectable-provided-in': ['error'],
      '@angular-eslint/contextual-lifecycle': ['error'],
      '@angular-eslint/no-async-lifecycle-method': ['error'],
      // input(), output() and model() are part of the component's public api - they must not be protected/private
      'no-restricted-syntax': [
        'error',
        {
          selector:
            "PropertyDefinition:matches([accessibility='protected'], [accessibility='private'], [key.type='PrivateIdentifier']) > CallExpression.value:matches([callee.name=/^(input|model|output|outputFromObservable)$/], [callee.object.name=/^(input|model)$/])",
          message: 'input(), output() and model() must be public - remove the protected/private modifier.',
        },
      ],

      // angular format rules
      '@stylistic/lines-between-class-members': [
        'error',
        'always',
        { exceptAfterSingleLine: true, exceptAfterOverload: true },
      ],
      'perfectionist/sort-array-includes': ['error'],
      'perfectionist/sort-classes': [
        'error',
        {
          groups: [
            'index-signature',
            ...angularFunctionGroupNames,
            ['static-property', 'static-accessor-property'],
            ['static-get-method', 'static-set-method'],
            ['protected-static-property', 'protected-static-accessor-property'],
            ['protected-static-get-method', 'protected-static-set-method'],
            ['private-static-property', 'private-static-accessor-property'],
            ['private-static-get-method', 'private-static-set-method'],
            'static-block',
            ['property', 'accessor-property'],
            ['protected-property', 'protected-accessor-property'],
            ['private-property', 'private-accessor-property'],
            'input-decorator-properties', // legacy @Input() decorator
            'output-decorator-properties', // legacy @Output() decorator
            'constructor',
            ['get-method', 'set-method'],
            ['protected-get-method', 'protected-set-method'],
            ['private-get-method', 'private-set-method'],
            ...lifecycleHookGroupNames,
            ['method', 'function-property'],
            ['protected-method', 'protected-function-property'],
            ['private-method', 'private-function-property'],
            ['static-method', 'static-function-property'],
            ['protected-static-method', 'protected-static-function-property'],
            ['private-static-method', 'private-static-function-property'],
            'unknown',
          ],
          customGroups: [
            ...angularCustomGroups,
            {
              groupName: 'input-decorator-properties',
              type: 'alphabetical',
              anyOf: [
                { selector: 'set-method', decoratorNamePattern: '^Input$' },
                { selector: 'property', decoratorNamePattern: '^Input$' },
              ],
            },
            {
              groupName: 'output-decorator-properties',
              type: 'alphabetical',
              anyOf: [{ selector: 'property', decoratorNamePattern: '^Output$' }],
            },
          ],
        },
      ],
      'perfectionist/sort-enums': ['error'],
      'perfectionist/sort-exports': ['error'],
      'perfectionist/sort-heritage-clauses': [
        'error',
        {
          groups: [
            'onChanges',
            'onInit',
            'doCheck',
            'afterContentInit',
            'afterContentChecked',
            'afterViewInit',
            'afterViewChecked',
            'onDestroy',
            'unknown',
          ],
          customGroups: [
            { groupName: 'onChanges', elementNamePattern: '^OnChanges$' },
            { groupName: 'onInit', elementNamePattern: '^OnInit$' },
            { groupName: 'doCheck', elementNamePattern: '^DoCheck$' },
            { groupName: 'afterContentInit', elementNamePattern: '^AfterContentInit$' },
            { groupName: 'afterContentChecked', elementNamePattern: '^AfterContentChecked$' },
            { groupName: 'afterViewInit', elementNamePattern: '^AfterViewInit$' },
            { groupName: 'afterViewChecked', elementNamePattern: '^AfterViewChecked$' },
            { groupName: 'onDestroy', elementNamePattern: '^OnDestroy$' },
          ],
        },
      ],
      'perfectionist/sort-imports': ['error'],
      'perfectionist/sort-interfaces': ['error'],
      'perfectionist/sort-intersection-types': ['error'],
      'perfectionist/sort-modules': ['error'],
      'perfectionist/sort-named-exports': ['error'],
      'perfectionist/sort-named-imports': ['error'],
      'perfectionist/sort-object-types': ['error'],
      'perfectionist/sort-objects': ['error'],
      'perfectionist/sort-switch-case': ['error'],
      'perfectionist/sort-union-types': ['error'],
      'perfectionist/sort-variable-declarations': ['error'],
    },
  },
  {
    files: ['**/*.html'],
    extends: [angular.configs.templateRecommended, angular.configs.templateAccessibility],
    rules: {
      '@angular-eslint/template/prefer-self-closing-tags': ['error'],
      '@angular-eslint/template/prefer-ngsrc': ['error'],
      '@angular-eslint/template/prefer-control-flow': ['error'],
    },
  },
  eslintConfigPrettier,
]);

If sorting by function names and grouping them this way doesn't suit your needs, and you'd prefer to stick with the standard sorting approach while still keeping lifecycle hooks sorted, simply remove angularFunctionGroupNames from your eslint.config.mjs file. Then, in your eslint.angular-config.mjs file, modify this line:

javascript
// combine custom groups into a single array
const angularCustomGroups = [...lifecycleHookGroups /*...angularFunctionGroups*/];

This way, only the lifecycle hooks will be included in your custom sorting logic, and function-based grouping will be skipped.

17. Summary

Configuring ESLint and Prettier in an Angular project is an investment that may initially appear time-consuming. However, spending a few hours to properly set up these tools yields long-term benefits, not only for individual developers but for the entire team working on the project.

Why is it worth the effort?

  • Consistent code style - Prettier automatically ensures uniform formatting, so every team member writes code in the same style
  • Better quality and readability - ESLint identifies potential errors, risky constructs, and non-standard practices. As a result, the code is clearer and easier to maintain
  • Automation and increased productivity - with linting rules in place, many repetitive corrections happen automatically, removing the burden of manually enforcing coding style
  • Streamlined code review - code that's both formatted and linted makes for a faster review process, sparing developers from debates over formatting or minor conventions

Advantages of using these tools

  • Real-time error detection - integration with editors (like VS Code) lets you see errors right away
  • Consistency in large teams - a well-configured setup prevents chaos when multiple developers work on the same project
  • Flexibility - ESLint and Prettier can be extended with plugins tailored to specific needs (e.g., sorting imports, attributes, or Tailwind CSS classes)
  • Promotion of best practices - Angular-specific rules can easily enforce patterns like OnPush, lifecycle hooks, and Signals

Potential drawbacks and challenges

  • Managing many dependencies – A large number of plugins can be difficult to maintain, especially if some aren't regularly updated or conflict with other packages
  • Initial learning curve – Configuration can be complex and may require a deep understanding of Angular and the tools themselves
  • Rule conflicts – Additional time may be needed to troubleshoot errors and adjust settings to avoid conflicting messages (e.g., between ESLint and Prettier)

Conclusion

A thoughtfully designed ESLint and Prettier configuration in an Angular project brings numerous benefits in terms of code consistency, readability, and ease of development. While the initial setup process can be time-consuming and may require experimentation (particularly when working with advanced plugins for sorting, grouping, or enforcing specific practices), it ultimately saves a great deal of time, facilitates team collaboration, and significantly improves the overall development and maintenance of the application.

Additional advice

Before introducing new rules or plugins, discuss them with your team. Reaching a mutual agreement on the rules and understanding the purpose behind these changes will increase acceptance and give everyone a sense of ownership over the shared standards.

Keep reading