Integrating and Enforcing Prettier & ESLint

Matija Marohnić

There are many tools for ensuring consistency and improving the quality of JavaScript code. At first were using tools like JSLint, JSHint and JSCS, but then ESLint emerged, and later Prettier, and eventually those two became mainstream. Tools like these are powerful because they allow us to spot possible mistakes before we even run our code, which makes coding efficient especially if we’re using text editor extensions because then our mistakes get literally underlined as we type, like spell check.

Different tools cover different aspects of coding:

  • objective errors, like using undefined variables
  • patterns that might not behave like we think they do, like == instead of ===
  • code style, like indentation or semicolons

The scope of some tools is too big, for others it’s too little. I think this is important to take into account when using multiple tools in the same project. In this article I’m going to talk about ESLint and Prettier because they are popular tools often used together, even though they have an overlap in functionality, which makes integrating and enforcing them challenging.

I’ll assume that you have a basic understanding of what both of these tools do, otherwise I don’t recommend using them just yet because they will probably get in your way too much and hurt your productivity.

First, let’s recap a few ESLint and Prettier commands:

# fails if there are any ESLint errors in file.js
eslint file.js

# automatically fixes what it can, and fails only
# if there are any remaining ESLint errors
eslint --fix file.js
# fails if code in file.js is not properly formatted
prettier --check file.js

# formats code in file.js
prettier --write file.js

All caught up? Great, let’s talk about the elephant in the room.

Overlap

Overlap between ESLint and PrettierA Venn diagram showing that the intersection between ESLint and Prettier is code formattingESLintPrettiercode formatting

ESLint, in addition to countless built-in rules, has a vast ecosystem of plugins, allowing us to endlessly configure and fine-tune many aspects of authoring code. Its strengths are detecting problems, discouraging anti-patterns and enforcing consistent code practices.

On the other hand, Prettier is very opinionated; it gives us several basic options because its point is to get code formatting out of the way, so we can focus on functionality. ESLint has many rules regarding code formatting as well, but Prettier is much more powerful.

Since there’s an overlap in functionalities (e.g. both ESLint and Prettier can enforce semicolons, or lack thereof), how can we prevent these tools from stepping on each other’s toes?

Solution A: always run ESLint after Prettier

ESLint over PrettierA Venn diagram showing ESLint circle on top, covering part of Prettier circlePrettierESLint

One way to get around this would be to always run ESLint after Prettier. So Prettier first does its thing, then ESLint fixes the overlapping rules and whatever else we configured it to do. This would get rid of ESLint errors caused by Prettier.

prettier-eslint is an official tool that does this, and it has already been implemented in tools like Prettier for VS Code. However, I don’t find this solution good enough for two reasons:

  1. code processed with prettier-eslint can still be considered incorrectly formatted by Prettier because this is just a hack
  2. we’re assuming that everyone has prettier-eslint integration in their text editor, but we shouldn’t count on anything that we can’t enforce

So, let’s keep looking.

Solution B: disable conflicting ESLint rules

Prettier over ESLintA Venn diagram showing Prettier circle on top, covering part of ESLint circle, but with a white stroke, illustrating that the overlapping part of ESLint circle has been cut outESLintPrettier

Another way would be to disable all ESLint rules that are in conflict with Prettier. But there are so many of them, it would be nice to be able to reliably cover all bases.

Fortunately, there’s already an official shareable config for this. The recommended way to use it is to include it as the last shareable config in extends. For example, if we were using other shareable configs like eslint-config-airbnb, we would extend those first, then put eslint-config-prettier at the bottom so it can disable conflicting rules introduced by previous configs.

yarn add --dev eslint-config-airbnb eslint-config-prettier
{
  "extends": [
    "airbnb",
    "prettier"
  ]
}

To clarify, this solution isn’t merely the opposite of Solution A because we’re disabling a part of ESLint functionality that would interfere with Prettier, so it doesn’t matter which tool runs first because they are mutually compatible.

eslint-config-prettier has a few more tricks up its sleeve, like having shareable configs for common ESLint plugins. If we wanted to include all recommended rules from eslint-plugin-react, but disable those that conflict with Prettier, we could do that with prettier/react:

yarn add --dev eslint-plugin-react
{
  "extends": [
    "airbnb",
    "plugin:react/recommended",
    "prettier",
    "prettier/react"
  ]
}

Hot tip!

Notice that we didn’t have to add eslint-plugin-react to plugins. This is because most ESLint plugins already include themselves as part of the recommended configuration.

eslint-config-prettier also offers a way to check whether your ESLint configuration has any conflicting rules. This is very useful for ensuring that you didn’t accidentally reintroduce a conflicting rule. For example, one of the rules disabled by eslint-config-prettier is semi, which we mentioned earlier. Even though we extended the shareable config, this doesn’t prevent us from reintroducing conflicting rules:

{
  "extends": [
    "airbnb",
    "prettier"
  ],
  "rules": {
    "semi": ["error", "never"]
  }
}

Prettier by default adds semicolons, uh-oh! Luckily, eslint-config-prettier saves our asses yet again by including a CLI utility that analyzes ESLint configuration and fails if there are conflicting rules. We can create a npm script in package.json:

{
  "scripts": {
    "check-eslint-config": "eslint --print-config . | eslint-config-prettier-check"
  }
}

In the script above we print out the full ESLint configuration for the current directory and pipe that into the CLI utility.

Enforcing

Have you ever worked on a project which contains npm scripts like these?

{
  "scripts": {
    "format": "prettier --write 'src/**/*.js'",
    "lint": "eslint src"
  }
}

Maybe you have been told that you should use these scripts, but what if you forget? Probably nothing will happen. You might enable your text extensions for ESLint and Prettier and start working on an existing file, but then you realize that it hasn’t been formatted and has a bunch of distracting ESLint errors. You could make commits like “format with Prettier” and “fix ESLint errors”, but if there are many of them it’s pointless. In my case I eventually disabled my extensions so I can focus on working. 🙄

This happens often—people introduce new tools, but then they don’t enforce them. The point of tools like Prettier is to help you code better, but if you have to remember to use them (which you often won’t) they’re probably going to become a nuisance later on. So let’s think of ways to enforce them.

We shouldn’t rely on text editor extensions because we can’t control for that. Our first step might be to perform checks as part of our CI build, so it fails if any file in our codebase has ESLint errors or hasn’t been formatted with Prettier. This will prevent merge requests from passing, and it’s a minimal step required to force people to fix their code (a good analogy for this is server-side form validation). So before running tests on your CI, let’s run the following npm scripts:

{
  "scripts": {
    "check-eslint-config": "eslint --print-config . | eslint-config-prettier-check",
    "check-code": "eslint src && prettier --check 'src/**/*.js'"
  }
}
# on CI
yarn check-eslint-config && yarn check-code

This is a step up. However, we might have been working for days on a feature, then we finally opened a PR only to see a ton of errors on CI. This is a slow process especially because CI itself is slow. Let’s find a way to automatically let developers know about errors before they push their code. Maybe even before they commit it?

The first thing that might come to your mind are git hooks, but the tricky part is that you can’t force people to install them… or can you? 😈 husky is a wonderful tool that allows us to configure git hooks, which install themselves automatically to our local repository (.git folder) when we install dependencies. We have no choice, which is exactly what we want!

yarn add --dev husky

You can configure husky by adding the following to our package.json:

{
  "husky": {
    "hooks": {
      "pre-push": "yarn check-eslint-config && yarn check-code"
    }
  }
}

This is better, but what if we’ve been coding for a looong time before we decided to push our code? We might be welcomed with a wall of errors again, only sooner. We would ideally want to check our code before we commit it, but checking our entire codebase on every commit would be very slow, especially because we’re often committing stuff like Markdown files which can’t possibly result in lint or formatting errors.

This is where lint-staged comes in, which enables us to run commands only on files that have been staged with git. So much faster! ⚡️ Let’s use it with a pre-commit hook, while keeping our pre-push hook (I’ll explain why in a second):

yarn add --dev lint-staged
{
  "husky": {
    "hooks": {
      "pre-commit": "lint-staged",
      "pre-push": "yarn check-eslint-config && yarn check-code"
    }
  },
  "lint-staged": {
    "*.js": [
      "eslint",
      "prettier --check"
    ]
  }
}

This will check only staged .js files. What an amazing combination of tools, right?!

Btw, the reason why we kept our pre-push hook is because if we change Prettier or ESLint configuration, we could introduce an error somewhere in our code. lint-staged wouldn’t catch because it only checks staged files. Running a slower script on pre-push is ok because we usually don’t push code very often, and this prevents us from pushing bad code.

Moving on, lint-staged supports a neat trick: we can automatically fix and format staged files:

    "*.js": [
      "eslint --fix",
      "prettier --write",
      "git add"
    ]

With this setup you could technically commit formatted code with fixed ESLint errors without even knowing, pretty amazing!

Btw, you might have started thinking about partially staged files and wondering if this solution just commits the entire file instead of your carefully selected hunks. Well, it turns out that the author of lint-staged is a genius and managed to support applying fixes to staged files while somehow keeping them partially staged! 😵

Unifying

What do ESLint and Prettier have in common? They both convert files to an AST, perform various alterations to it, and convert it back to text. This means that our files get overwritten twice, which can be a bit slow. It would be nice if we could unify these two tools into a single process.

eslint-plugin-prettier does exactly this. It runs Prettier in the background to format our code, and behaves like any other ESLint rule.

yarn add --dev eslint-plugin-prettier
{
  "extends": [
    "airbnb",
    "prettier"
  ],
  "plugins": [
    "prettier"
  ],
  "rules": {
    "prettier/prettier": "error"
  }
}

We can also shorten the above with eslint-plugin-prettier’s recommended configuration, which does three things:

  1. adds itself to plugins
  2. enables the prettier/prettier rule
  3. adds eslint-config-prettier to extends
{
  "extends": [
    "airbnb",
    "plugin:prettier/recommended"
  ]
}

Now when you run eslint --fix index.js, ESLint will automatically fix whatever it can as well as format the file with Prettier, while writing to the file only once. This also means that you can ditch the text editor extension for Prettier, you don’t need it anymore. Just enable automatically fixing on save in your ESLint extension and you’re good to go. ✨

Now we can simplify our package.json configuration:

{
  "scripts": {
    "check-eslint-config": "eslint --print-config . | eslint-config-prettier-check",
    "check-code": "eslint src"
  },
  "husky": {
    "hooks": {
      "pre-commit": "lint-staged",
      "pre-push": "yarn check-eslint-config && yarn check-code"
    }
  },
  "lint-staged": {
    "*.js": [
      "eslint --fix",
      "git add"
    ]
  }
}

Notice that we’re no longer running Prettier directly.

Btw, you probably know that Prettier supports other languages too, like CSS, GraphQL, even CSS-in-JS! Our integration works only for .js files, but I suggest that you take a look at repositories in Prettier’s GitHub organization, where you will find projects like stylelint-config-prettier and stylelint-prettier, which allow you to do for CSS files exactly what we did for JS.

Conclusion

When introducing a new tool we should carefully think over what benefits it brings and how we can enforce using it. Either everyone should use it or nobody should, there is no point in partial integration. Otherwise we start seeing review comments like “pls run Prettier”, which is a type of a comment that we wanted to avoid in the first place.

In whichever way we integrate Prettier, it’s worth noting that the sole purpose of Prettier is code formatting, which frees up tools like ESLint and stylelint to do what they do best—detect problems, discourage anti-patterns and enforce consistent code practices.


If you’re blown away by all of this, consider convincing your company to donate:

avatar