Streamline Your Angular Projects: Easy ESLint and Prettier Configuration

Contents

  1. Introduction
  2. That's the worst
  3. Why?
  4. A necessary compromise
  5. What is Prettier?
  6. What is ESLint?
  7. Environment setup
  8. Project installation
  9. ESLint installation
  10. Prettier installation
  11. Visual Studio Code extension installation
  12. Visual Studio Code configuration
  13. Prettier configuration
  14. Extended Prettier configuration
  15. ESLint configuration
  16. Advanced ESLint configuration
  17. Summary

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:

8. Project installation

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

npm install -g @angular/cli@latest

If you already have the AngularCLI 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:

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 AngularCLI Documentation.

9. ESLint installation

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

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.
  • Your angular.json updated.

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:

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:

Note: Do not install Prettier ESLint extension. This hybrid plugin has long been unsupported and relies on an older version of ESLint (^8.0.0).

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.

{
  "editor.codeActionsOnSave": {
    "source.formatDocument": "explicit",
    "source.fixAll.eslint": "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.
    • source.formatDocument - Works like pressing Ctrl+Alt+L, formatting the document with the default formatter - in our case, Prettier.
    • source.fixAll.eslint - Runs ESLint fixes after Prettier has finished formatting.
  • 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 Prettier and ESLint in tandem simply by pressing Ctrl+S. By defining the order, Prettier will format the code first, and then ESLint will address any remaining style or code-quality issues.

Note: Running Prettier before ESLint ensures that code styling remains consistent, allowing ESLint to focus on code quality and error detection. This minimizes redundant warnings and improves the developer experience by separating code formatting from other lint checks. Overall, this order keeps your codebase consistent, efficient, and maintainable.

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.

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:

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:

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.

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:

// @ts-check
import eslint from '@eslint/js';
import tseslint from 'typescript-eslint';
import angular from 'angular-eslint';

export default tseslint.config(
  {
    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: 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.

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:

npm install -D eslint-config-prettier

Then, import it and add it at the end of your export array. Your ESLint configuration might now look like this:

// @ts-check
import eslint from '@eslint/js';
import tseslint from 'typescript-eslint';
import angular from 'angular-eslint';
import eslintConfigPrettier from 'eslint-config-prettier'; // 1. Import eslint-config-prettier plugin

// 2. Export both configuration
export default [
  ...tseslint.config(
    {
      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, // 3. Add default 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 in templates.
  • @angular-eslint/prefer-standalone - Enforces the use of standalone components.
  • @angular-eslint/template/prefer-ngsrc - Encourages using the ngSrc directive to optimize image loading.
  • @angular-eslint/template/prefer-control-flow - Enforces usage of the new control-flow syntax instead of traditional structural directives.
  • @angular-eslint/prefer-on-push-component-change-detection - Enforces OnPush change detection.
  • @angular-eslint/no-pipe-impure - Disallows impure pipes.
  • @angular-eslint/prefer-signals - Encourages using Angular Signals instead of 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 using async alongside lifecycle hooks.

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:

// @ts-check
import eslint from '@eslint/js';
import tseslint from 'typescript-eslint';
import angular from 'angular-eslint';
import eslintConfigPrettier from 'eslint-config-prettier';

export default [
  ...tseslint.config(
    {
      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 a file such as app.component.ts. You should see a warning about switching to OnPush change detection - this confirms your lint rules are working. The other rules should also trigger warnings or errors as appropriate.

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:

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

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

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

export default [
  ...tseslint.config(
    {
      ...
      plugins: {
        perfectionist,
        '@stylistic/ts': stylisticTs,
      },
      ...
    },
  ),
  ...
];

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/ts/lines-between-class-members - enforces a blank line between class members, excluding overloaded methods and inline declarations
  • 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:

// @ts-check
import eslint from '@eslint/js';
import tseslint from 'typescript-eslint';
import angular from 'angular-eslint';
import eslintConfigPrettier from 'eslint-config-prettier';
import perfectionist from 'eslint-plugin-perfectionist';
import stylisticTs from '@stylistic/eslint-plugin-ts';

export default [
  ...tseslint.config(
    {
      files: ['**/*.ts'],
      extends: [
        eslint.configs.recommended,
        ...tseslint.configs.recommended,
        ...tseslint.configs.stylistic,
        ...angular.configs.tsRecommended,
      ],
      plugins: {
        perfectionist,
        '@stylistic/ts': stylisticTs,
      },
      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/ts/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/ts/lines-between-class-members, perfectionist/sort-heritage-clauses, perfectionist/sort-classes - and expand their configurations to address this.

Extended configuration

@stylistic/ts/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:

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

With this configuration, the formatter will always insert a blank line between methods but will skip the exceptions mentioned above.

perfectionist/sort-heritage-clauses

This rule targets sorting all class elements that appear after the extends or implements keywords - basically, whenever you extend a class or implement an interface.

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:

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

It's fairly straightforward. The values in the groups array define the order in which the interfaces will appear, and the strings match the keys in customGroups. Each key's value is a regex that defines the interface name and how it's identified.

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 interfaces but to extended classes 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:

'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.component.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, while lifecycle hooks go 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:

// @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: '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 fields should NOT have protected/private
  {
    groupName: 'input',
    modifiers: ['public'],
    functions: ['input.required', 'input'],
  },
  // output fields should NOT have protected/private
  {
    groupName: 'output',
    modifiers: ['public'],
    functions: ['output'],
  },
  {
    groupName: 'model',
    modifiers: ['public'],
    functions: ['model'],
  },
  {
    groupName: 'signal',
    modifiers: ['public', 'protected', 'private'],
    functions: ['signal'],
  },
  {
    groupName: 'computed',
    modifiers: ['public', 'protected', 'private'],
    functions: ['computed'],
  },
  {
    groupName: 'linkedSignal',
    modifiers: ['public', 'protected', 'private'],
    functions: ['linkedSignal'],
  },
];

/**
 * 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 starts with e.g., `inject(` or `viewChild...`
        elementValuePattern: `^${fn}.*$`,
      })),
    }));
  });

  // 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() can't be private), 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. Note that the list of Angular functions might not be exhaustive - if you're missing something, just add it to the configuration object, set the order appropriately, and restart your IDE.

Finally, you'll import these generated objects into your main ESLint configuration and place them where needed. Once all configurations are in place, your final file should look like this:

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

export default [
  ...tseslint.config(
    {
      files: ['**/*.ts'],
      extends: [
        eslint.configs.recommended,
        ...tseslint.configs.recommended,
        ...tseslint.configs.stylistic,
        ...angular.configs.tsRecommended,
      ],
      plugins: {
        perfectionist,
        '@stylistic/ts': stylisticTs,
      },
      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 format rules
        '@stylistic/ts/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'],
              '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],
          },
        ],
        'perfectionist/sort-enums': ['error'],
        'perfectionist/sort-exports': ['error'],
        'perfectionist/sort-heritage-clauses': [
          'error',
          {
            groups: [
              'onChanges',
              'onInit',
              'doCheck',
              'afterContentInit',
              'afterContentChecked',
              'afterViewInit',
              'afterViewChecked',
              'onDestroy',
              'unknown',
            ],
            customGroups: {
              onChanges: '^OnChanges$',
              onInit: '^OnInit$',
              doCheck: '^DoCheck$',
              afterContentInit: '^AfterContentInit$',
              afterContentChecked: '^AfterContentChecked$',
              afterViewInit: '^AfterViewInit$',
              afterViewChecked: '^AfterViewChecked$',
              onDestroy: '^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:

// 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.