6 minute 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

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
# .prettierignore

node_modules
dist

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

$ touch .prettierrc

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"
}

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": {
    // ...
  }
}

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

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
# .eslintignore

node_modules
dist

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

$ touch .eslintrc
// .eslintrc

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

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": {
    // ...
  }
}

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.

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);
  }
};

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);
  }
};

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

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;
};

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,
};

Running yarn lint again should show no warnings:

$ yarn lint

yarn run v1.22.10
$ eslint . --fix
✨  Done in 0.86s.

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

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"
  ]
}

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

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

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 .

And commit them to source control:

$ git commit

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