automating linting: using husky and lint-staged

2020-04-22

 | 

~6 min read

 | 

1053 words

I’ve written in the past about updating a basic prettier config to handle multiple file types. I’ve also discussed using git hooks to ensure better commit messages.

This post is about combining these concepts to create an automated setup for linting (and much more)!

Tools We’ll Be Using

To demonstrate this concept, we’ll be relying on a few tools. The specific tools don’t matter as much as the concepts they convey, though I do think they make it easy to get up and running.

The keys are:

  1. Static analysis and linting tools, e.g., Prettier, Typescript, ESLint
  2. Git hooks (made easy with Husky)
  3. Running commands against staged files (made easy with lint-staged)

I’m assuming that we already have prettier, ESLint, Typescript, or some combination or another configured in the project.

Since we’ll be using husky and lint-staged, let’s make sure they’re installed now:

$ npm install --save-dev husky lint-staged

This will add them to our package.json under devDependencies:

package.json
{
    "devDependencies": {
        "husky": "^4.3.6",
        "lint-staged": "^10.5.3"
    }
}

Instead of putting the configurations directly into package.json, we can configure husky and lint-staged in their own rc files (.huskyrc and .lintstagedrc respectively) in the root of the project. Both support multiple file types, but for simplicity we’ll use YAML.

It’s also worth noting that we have several scripts in our package.json that we might want to use to ensure that everything looks as it should:

package.json
{
    "scripts": {
        "build": "babel --extensions .js,.ts,.tsx src --out-dir dist",
        "lint": "eslint --ignore-path .gitignore --ext .js,.ts,.tsx .",
        "prettier": "prettier --ignore-path .gitignore \"**/*.+(js|json|ts|tsx)\"",
        "format": "npm run prettier -- --write",
        "check-types": "tsc",
        "check-format": "npm run prettier -- --list-different",
        "validate": "npm run check-types && npm run check-format && npm run lint && npm run build"
    }
}

Configuring Husky

When we installed husky, we added a whole host of git hooks to our project. These are visible within the .git/hooks directory of our project:

$ ls .git/hooks
.git/hooks
applypatch-msg                  post-merge                      pre-push
applypatch-msg.sample           post-rewrite                    pre-push.sample
commit-msg                      post-update                     pre-rebase
commit-msg.sample               post-update.sample              pre-rebase.sample
fsmonitor-watchman.sample       pre-applypatch                  pre-receive.sample
husky.local.sh                  pre-applypatch.sample           prepare-commit-msg
husky.sh                        pre-auto-gc                     prepare-commit-msg.sample
post-applypatch                 pre-commit                      push-to-checkout
post-checkout                   pre-commit.sample               sendemail-validate
post-commit                     pre-merge-commit                update.sample

In a .huskyrc file we can specify commands to run on any one of those hooks.

For example, using the pre-commit hook we can run the lint command every time we try to commit code:1

{
    "hooks": {
        "pre-commit": "npm run lint" //highlight-line
    }
}

With this in place, assuming we have files staged and try to commit, we’ll see this pre-commit hook kick off the lint command before allowing us to commit the code.

$ git commit
husky > pre-commit (node v12.16.1)
$ npm run prettier -- --write"
Checking formatting...
src/models/Schedulizer.ts
Code style issues found in the above file(s). Forgot to run Prettier?
error Command failed with exit code 1.//highlight-line
info Visit https://yarnpkg.com/en/docs/cli/run for documentation about this command.
husky > pre-commit hook failed (add --no-verify to bypass)

At this point, we have a git hook configured, but it can still be a frustrating experience for teammates who may not have automatic formatting configured in their editors. If the code fails, as it did in this case, they’d have to manually fix the code, find the script that would fix it for them, or bypass the check. To discourage the latter, we want to make doing the right thing as easy as possible.

That’s where lint-staged comes in. So, let’s go configure that to see how it can help.

Configuring Lint Staged

The basics behind lint-staged are the keys indicate which types of files are targeted and the value is a (list of) command(s) to be run against those files when they are staged.

A primitive example would run prettier against every file that’s staged:

    "lint-staged": {
        "**/*": "prettier --write"
    }

A slightly more sophisticated set of rules will specify specific file types and multiple commands to run. For example:

{
    "*.+(js|ts|tsx)": ["eslint"],
    "**/*.+(js|json|ts|tsx)": [
        "prettier --write",
        "npm run validate"
    ]
}

It’s worth noting that as a result of this, we could likely get away with simpler commands in our .lintstagedrc. Instead of using the scripts specified in package.json (which include —ignore-files and other parameters, etc.), since the globbing is done upfront in lint-staged, we could just do prettier --write for example.

Note: Previously, adding git add to the array of commands would ensure that any command that modified files would automatically add them to the index. This is no longer necessary.

For example:

"lint-staged": {
    "**/*": ["prettier --write", "git add"]
}

This rule will print the following warning to the console:

⚠ Some of your tasks use `git add` command. Please remove it from the config since all modifications made by tasks will be automatically added to the git commit index.

The only step left is to make sure that lint-staged is actually called. To do this, we need to return to our .huskyrc and add lint-staged to the commands we run in our hook:

{
    "hooks": {
        "pre-commit": "lint-staged && npm run build"
    }
}

Now, whenever we add files to staging and try to commit them, we first run the files through lint-staged. If they match our glob patterns, they are automatically passed to the command(s) specified.

hustky-lint-staged

In this screen shot, we see that as soon as we try to commit the code, the pre-commit hook is hit and based on the file types defined in our tasks (each key of the .lintstagedrc), it automatically runs those files through the different commands (eslint, prettier --write, and npm run validate).

Conclusion

By combining lint-staged with husky, we’re able to run code through a series of processing steps before its ever committed and do so in a way where all of our teammates can benefit from it, without getting annoying error messages!

Footnotes

  • 1 Alternatively, we could use standard git hooks. Husky makes them so simple that I haven’t felt the need to explore git hooks directly yet. This might change if I need to use them in a non-open-source project and I needed to use v5 as Husky recently changed their licensing.

Hi there and thanks for reading! My name's Stephen. I live in Chicago with my wife, Kate, and dog, Finn. Want more? See about and get in touch!