Published on

Lint on Commit

My project setup to lint and type check my TypeScript code on commits.

On a recent project, I decided to give lint-staged a try. For those who don't know, it's a tool that runs linters against staged git files, thus preventing poor code from slipping into the project code base. As I followed two example projects using lint-staged, here and here, I began to stumble down yet another rabbit hole of tooling pain.

To begin, I installed lint-staged and setup my package.json .

package.json
{
  "scripts": {
    "format": "prettier -w .",
    "lint": "eslint . --ext ts --ext tsx",
    "type-check": "tsc --pretty --noEmit"
  },
  "husky": {
    "hooks": {
      "pre-commit": "lint-staged"
    }
  },
  "lint-staged": {
    "*.@(ts|tsx)": ["yarn format", "yarn lint", "yarn type-check"]
  }
}

You'll notice I use husky - a tool that runs configured git hooks.

When I tried to commit a change, the type-check script failed, reporting errors in packages within the node_modules folder. That's odd, since tsconfig.json ignores node_modules.

The issue resides in how lint-staged calls tsc. It passes git staged files to tsc via the command line, which means tsc ignores tsconfig.json. (reference).

Fix #1

My first fix moved the call to type-check to its own git hook and removed it from the lint-staged call list.

package.json
{
  "husky": {
    "hooks": {
      "pre-commit": "lint-staged",
      "pre-push": "yarn type-check"
    }
  },
  "lint-staged": {
    "*.@(ts|tsx)": ["yarn format", "yarn lint"]
  }
}

This setup works, but I saw two consequences to this change.

  1. type-check checks the entire project on each git push.
  2. type-check runs after a commit, potentially allowing errors into the code base.

I can live with #1, but #2 defeats the whole purpose of the automatic type checking to ensure no errors get committed..

Fix #2

An alternative approach follows this example. I removed the lint-staged block from package.json and created a lint-staged-config.js file as follows:

lint-staged-config.js
module.exports = {
  '**/*.ts?(x)': () => 'yarn type-check',

  '**/*.(ts|js)?(x)': (filenames) => `yarn lint ${filenames.join(' ')}`,

  '**/*.js?(x)': (filenames) =>
    filenames.map((filename) => `prettier --write '${filename}'`),
}

In this setup, lint-staged runs type-check without command-line arguments and tsc will run using tsconfig.json. This solution is cool, but perhaps unnecessarily complex.

Husky v5

While my setup above was technically complete, I saw typicode released husky v5 and decided to try it. I installed husky v5, removed lint-staged.config.js and setup my package.json.

package.json
{
  "scripts": {
    "format": "prettier -w .",
    "lint": "eslint .",
    "type-check": "tsc --pretty --noEmit",
    "postinstall": "is-ci || husky install"
  },
  "lint-staged": {
    "*.@(js|ts|tsx)": ["yarn lint", "yarn format"]
  }
}

Next, I ran the following command.

$ npx husky add .husky/pre-commit "yarn lint-staged"

Lastly, I modified .husky/pre-commit - created in the above step - to run the type-check script. Here's my final pre-commit script.

.husky/pre-commit
#!/bin/sh

[ -n "$CI" ] && exit 0

. "$(dirname "$0")/_/husky.sh"

yarn type-check
yarn lint-staged

Wrap-up

With this last setup, I can run all my scripts manually from the command-line. The linting and formatting scripts run on staged files - keeping their execution times low. Lastly, type checking is part of the pre-commit hook - preventing errors from sneaking into the code base.

I'm happy with this setup. It's simple, clear and concise. You can see this setup in action here.