DEV Community

loading...

How to set up ESLint and Prettier with pre-commit hooks (with Typescript)

Jon Webb
Originally published at jonwebb.dev ・6 min read

The baseline project

This post assumes you are following along with the baseline project tutorial, but the concepts apply to any Typescript project.

Linting and formatting

As our codebase expands, so does the risk of introducing bugs and inconsistent styling. We are going to use two tools to help enforce some Typescript best practices and general code formatting guidelines:

eslint is a linter, which is a static code analysis tools that can scan our codebase and throw errors and warnings if there are any issues in our codebase.

prettier is an automatic code formatter that automatically formats our code with consistent styling.

We can manually run these tools to lint and format our codebase, but we are also going to enforce these rules by setting up git hooks - scripts that can be run at certain points in the git lifecycle. We are going to use a tool called husky to store our git hooks in our repository, and lint-staged to run eslint and prettier on any staged files before they are committed.

Adding prettier

Let's install prettier as a development dependency:

$ yarn add -D prettier
Enter fullscreen mode Exit fullscreen mode

We don't want prettier to format code in our node_modules folder (where our dependencies are stored), or the dist folder (where our compiled Javascript code goes). We can create a file in our project root called .prettierignore (similar to .gitignore) to specify while directories prettier skips when it formats our code:

$ touch .prettierignore
Enter fullscreen mode Exit fullscreen mode
# .prettierignore

node_modules
dist
Enter fullscreen mode Exit fullscreen mode

prettier comes with sane defaults, but you can also customize the configuration by creating a .prettierrc file in the project root:

$ touch .prettierrc
Enter fullscreen mode Exit fullscreen mode

Here is the configuration I use (see the Prettier docs for all of the options):

// .prettierrc

{
  "printWidth": 80,
  "tabWidth": 2,
  "useTabs": false,
  "semi": true,
  "singleQuote": false,
  "jsxSingleQuote": false,
  "quoteProps": "consistent",
  "trailingComma": "es5",
  "bracketSpacing": true,
  "jsxBracketSameLine": false,
  "arrowParens": "avoid",
  "endOfLine": "lf",
  "embeddedLanguageFormatting": "auto"
}
Enter fullscreen mode Exit fullscreen mode

Finally, let's add a yarn script to our package.json file to run the formatter:

// package.json

{
  "name": "baseline",
  "version": "0.0.0",
  "main": "dist/index.js",
  "author": "Jon Webb",
  "license": "MIT",
  "scripts": {
    "dev": "NODE_ENV=development nodemon",
    "build": "rimraf dist && tsc",
    "start": "NODE_ENV=production node .",
    "format": "prettier . --write"
  },
  "devDependencies": {
    // ...
  },
  "dependencies": {
    // ...
  }
}
Enter fullscreen mode Exit fullscreen mode

Running yarn format will automatically format your codebase according to your prettier configuration.

Adding eslint

Let's install eslint, along with the plugins that will allow us to parse and lint Typescript, as development dependencies:

$ yarn add -D eslint @typescript-eslint/eslint-plugin @typescript-eslint/parser
Enter fullscreen mode Exit fullscreen mode

As with prettier, we are going to create a file called .eslintignore in our project root, telling eslint to ignore the node_modules and dist directories:

$ touch .eslintignore
Enter fullscreen mode Exit fullscreen mode
# .eslintignore

node_modules
dist
Enter fullscreen mode Exit fullscreen mode

Now, let's create an .eslintrc file in our project root that will store our eslint configuration:

$ touch .eslintrc
Enter fullscreen mode Exit fullscreen mode
// .eslintrc

{
  "parser": "@typescript-eslint/parser",
  "plugins": ["@typescript-eslint"],
  "extends": [
    "eslint:recommended",
    "plugin:@typescript-eslint/eslint-recommended",
    "plugin:@typescript-eslint/recommended"
  ]
}
Enter fullscreen mode Exit fullscreen mode

This configuration sets up the Typescript parser for eslint, installs the Typescript plugin, and extends the eslint:recommended rules with the the recommended rules for Typescript.

Finally, we can add a yarn script in our package.json file to lint our codebase:

// package.json

{
  "name": "baseline",
  "version": "0.0.0",
  "main": "dist/index.js",
  "author": "Jon Webb",
  "license": "MIT",
  "scripts": {
    "dev": "NODE_ENV=development nodemon",
    "build": "rimraf dist && tsc",
    "start": "NODE_ENV=production node .",
    "format": "prettier . --write",
    "lint": "eslint . --fix"
  },
  "devDependencies": {
    // ...
  },
  "dependencies": {
    // ...
  }
}
Enter fullscreen mode Exit fullscreen mode

Running yarn lint will scan our codebase, fix any issues that can be corrected automatically, and warn us of any others.

If you're following along with the baseline tutorial, you'll notice eslint warns us about some issues in the code we already wrote:

$ yarn lint

yarn run v1.22.10
$ eslint . --fix

/Users/jonwebb/Projects/baseline/src/util/error.ts
  13:35  warning  Missing return type on function   @typescript-eslint/explicit-module-boundary-types
  21:32  warning  Missing return type on function   @typescript-eslint/explicit-module-boundary-types
  25:3   warning  'next' is defined but never used  @typescript-eslint/no-unused-vars

/Users/jonwebb/Projects/baseline/src/util/load-config.ts
  6:27  warning  Missing return type on function  @typescript-eslint/explicit-module-boundary-types

✖ 4 problems (0 errors, 4 warnings)

✨  Done in 0.83s.
Enter fullscreen mode Exit fullscreen mode

Let's go ahead and fix those issues.

In src/util/error.ts, eslint would like us to explicitly specify a return type for our notFoundMiddleware and errorMiddleware functions. Since these are both void functions that don't return anything, we can do that by adding the void type after we declare our parameters:

// src/util/error.ts

// existing code ...

export const notFoundMiddleware = (
  req: Request,
  res: Response,
  next: NextFunction
): void => {
  next(boom.notFound("The requested resource does not exist."));
};

export const errorMiddleware = (
  err: Error,
  req: Request,
  res: Response,
  next: NextFunction
): void => {
  const {
    output: { payload: error, statusCode },
  } = boom.boomify(err);

  res.status(statusCode).json({ error });
  if (statusCode >= 500) {
    handle(err);
  }
};
Enter fullscreen mode Exit fullscreen mode

We are also getting another warning from this file. In our errorMiddleware function, the next parameter is never used in the function body.

This is generally a bad practice, but in this case, we are doing this on purpose. express detects whether a middleware function is error handling middleware by the presence of a fourth parameter. For this reason, we will use a comment to disable eslint for that line of code:

// src/util/error.ts

// existing code ...

export const errorMiddleware = (
  err: Error,
  req: Request,
  res: Response,
  next: NextFunction // eslint-disable-line
): void => {
  const {
    output: { payload: error, statusCode },
  } = boom.boomify(err);

  res.status(statusCode).json({ error });
  if (statusCode >= 500) {
    handle(err);
  }
};
Enter fullscreen mode Exit fullscreen mode

eslint will detect the comment and will not parse this line of code.

The final issue is in the loadConfig function in src/util/load-config.ts. When joi parses our process.env, the returned parameter value has a type of any. Later, we return value as the result of the function.

Let's explicitly define the return type of that function by exporting an interface in src/config.ts that describes the type we expect for our environmental variables:

// src/config.ts

// existing imports ...

export interface Env {
  NODE_ENV: "development" | "test" | "production";
  PORT: number;
}

// existing code ...
Enter fullscreen mode Exit fullscreen mode

Now, in src/util/load-config.ts, we can set the return type of the loadConfig function as Env:

// src/util/load-config.ts

// existing imports ...

import type { Env } from "../config";

export const loadConfig = (schema: Schema): Env => {
  dotenv.config({ path: __dirname + "/../../.env" });

  const { value, error } = schema.validate(process.env);
  if (error) {
    handle(new Error(`Invalid environment: ${error.message}`));
  }
  return value;
};
Enter fullscreen mode Exit fullscreen mode

As an added benefit, we no longer need to type cast the properties of our exported config object in src/config.ts:

// src/config.ts

// existing code ...

export const config = {
  env: env.NODE_ENV,
  port: env.PORT,
};
Enter fullscreen mode Exit fullscreen mode

Running yarn lint again should show no warnings:

$ yarn lint

yarn run v1.22.10
$ eslint . --fix
✨  Done in 0.86s.
Enter fullscreen mode Exit fullscreen mode

Configuring git hooks

To enforce linting and formatting before we commit to the codebase, we are going to use husky and lint-staged.

husky allows us to commit git hooks to our repository and sets them up for us when we clone it locally. lint-staged staged allows us to perform linting on files that we are staging for commit.

lint-staged

Install lint-staged as a development dependency:

$ yarn add -D lint-staged
Enter fullscreen mode Exit fullscreen mode

Next, create a configuration file named .lintstagedrc in the project root that will define the operations lint-staged performs on staged Typescript files:

# .lintstagedrc

{
  "*.ts": [
    "eslint --max-warnings=0",
    "prettier --write"
  ]
}

Enter fullscreen mode Exit fullscreen mode

I'm running eslint with the flag --max-warnings=0 because I would like lint-staged to abort the commit if there are any warnings (by default, eslint will only fail if there are errors, not warnings). This flag is optional.

husky

To install husky, run the CLI tool using npx and then run yarn:

$ npx husky-init && yarn
Enter fullscreen mode Exit fullscreen mode

The script will scaffold a .husky directory in your project root, and add a prepare hook in your package.json file that installs your git hooks whenever you run the yarn command.

Let's edit the ./husky/pre-commit file that was generated, replacing the contents with the following:

#!/bin/sh
. "$(dirname "$0")/_/husky.sh"

npx lint-staged
Enter fullscreen mode Exit fullscreen mode

The script will execute before start the commit process and run lint-staged with the configuration we set up in the previous section.

Commit

Go ahead and stage your changes:

$ git add .
Enter fullscreen mode Exit fullscreen mode

And commit them to source control:

$ git commit
Enter fullscreen mode Exit fullscreen mode

You should see output that commit-lint is executing your lint commands on your .ts files.

Discussion (0)