ExpressのAPIプロダクトをTypeScriptで快適に開発する準備を整えてみました。

Table of Contents

はじめに

本記事ではexpress-generatorで作成されたサンプルプロジェクトを、TypeScriptで快適に開発できる環境へ整えていきます。

  • TypeScriptで開発できるようにする
  • JavaScriptとTypeScriptを共存させる
  • ソースコードに変更があったら自動で再コンパイル+再起動させる
  • ソースコードのdocに記載した内容を仕様書に同期させる
  • ソースコードを保存したら自動フォーマットをかける
  • テストが書ける
  • 必要な時にブラウザを自動リロードできる

前提条件

  • OSはWindows10
  • node.jsのバージョンはv10.13.0

想定する読者

TypeScriptとExpressの開発経験があり、GitHubのREADMEを読めば開発できる方を対象としています。

利用する技術について、丁寧な説明はしませんのでご了承ください。

プロジェクト作成

express-generatorをインストールして、プロジェクトを作成します。

$ npm i -g express-generator
+ [email protected]

$ express -v pug express-typescript

JavaScriptプロジェクトとして動くことを確認します。

$ cd express-typescript
$ npm i
$ npm start

http://localhost:3000にアクセスしてシンプルな画面が表示されればOKです。

TypeScript化

TypeScriptとExpressの型定義をインストールします。

$ npm i -D typescript @types/express

tsconfig.jsonを作成します。

$ npx tsc --init -t es2015

app.jsをapp.tsにする

app.jsをTypeScriptファイルにします。
そのため、importしているpackageの型定義をインストールします。

$ npm i -D @types/cookie-parser @types/morgan @types/http-errors

app.jsの代わりにapp.tsを作ります。

app.ts
import createHttpError from "http-errors";
import express from "express";
import { Request, Response, NextFunction } from "express";
import path from "path";
import cookieParser from "cookie-parser";
import logger from "morgan";

import { router as indexRouter } from "./routes/index";
// TODO: JavaScriptファイルとの共存は後でやる
// import usersRouter from "./routes/users";

const app = express();

app.set("views", path.join(__dirname, "views"));
app.set("view engine", "pug");

app.use(logger("dev"));
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
app.use(cookieParser());
app.use(express.static(path.join(__dirname, "public")));

app.use("/", indexRouter);
// TODO: JavaScriptファイルとの共存は後でやる
// app.use("/users", usersRouter);

app.use((req: Request, res: Response, next: NextFunction) =>
  next(createHttpError(404))
);
app.use((err: any, req: Request, res: Response, next: NextFunction) => {
  res.locals.message = err.message;
  res.locals.error = req.app.get("env") === "development" ? err : {};

  res.status(err.status || 500);
  res.render("error");
});

module.exports = app;

index.jsをindex.tsにする

routes配下には2つのファイルがあります。

  • index.js
  • users.js

今回はindex.jsだけをTypeScriptファイルにします。
users.jsは敢えてJavaScriptファイルのままにしておきますが、app.tsではコメントアウトされているためコンパイルに影響はありません。

index.ts
import { Router } from "express";

export const router = Router();

router.get("/", (req, res, next) => res.render("index", { title: "Express" }));

コンパイルすると、TypeScriptファイルと同じ階層に各JavaScriptファイルができます。

$ tsc

npm startで動作確認しましょう。

JavaScriptと共存する

実際の開発では全てを一度にTypeScriptファイル化するのは難しいケースが多々あります。
そのため、JavaScriptファイルを共存できるようにします。

設定の変更

tsconfig.jsonで以下のオプションを追加します。

{
  "compilerOptions": {
    "outDir": "./dist",
    "allowJs": true,
  }
}
outDirを指定するのはなぜ?

デフォルトではコンパイル後のjsファイルはtsファイルと同じ場所に生成されます。

しかし、--allowJs: trueの場合は元々あったJavaScriptファイルをコンパイル結果のJavaScriptファイルで上書きしてしまうリスクがあります。 tscはこれを防ぐためTS5055エラーを出します。

outDirを指定することにより上記のリスクを防げます。
その結果、tscはエラーを出さなくなります。

コメンアウトを戻す

routes/usersに関するコメントアウトを戻します。

  import { router as indexRouter } from "./routes/index";
- // TODO: JavaScriptファイルとの共存は後でやる
- // import usersRouter from "./routes/users";
+ import usersRouter from "./routes/users";
 
...

  app.use("/", indexRouter);
- // TODO: JavaScriptファイルとの共存は後でやる
- // app.use("/users", usersRouter);
+ app.use("/users", usersRouter);
 

相対パスの起点を変更

app.ts__dirnameを削除します。
パスの指定をdist配下からではなく、プロジェクトルートからの相対パスにしたいからです。

  const app = express();
  
- app.set("views", path.join(__dirname, "views"));
+ app.set("views", "views");
  app.set("view engine", "pug");
  
  app.use(logger("dev"));
  app.use(express.json());
  app.use(express.urlencoded({ extended: false }));
  app.use(cookieParser());
- app.use(express.static(path.join(__dirname, "public")));
+ app.use(express.static("public"));
  
  app.use("/", indexRouter);
 

起動スクリプトの修正

bin/wwwのappもパスを変更します。

- var app = require('../app');
+ var app = require('../dist/app');
 

npm startを実行してhttp://localhost:3000/usersにアクセスできればOKです。

変更があったときに自動再起動

tsc-watchを使います。
はじめはnodemonを使っていましたが、以下の理由で変更しました。

  • hot build可能な差分コンパイルをしたい
    • --incrementを使ったcold buildの差分コンパイルより速い
  • nodemonを使う場合より必要なpackageが少ない
    • 依存packageを減らすことは重要

tsc-watchtsc --watchコマンドが終了した後の挙動を指定できるpackageです。
スターの数はnodemonと比べると多くありませんが非常にCoolです👍

なぜ`tsc --watch`ではダメ?

tsc --watchはソースコードの変更があった場合に自動で再コンパイルしてくれます。
しかし、コンパイルが終わった後に処理を実行できません。

tsc --watch && exec ...は想定通り動きません。
tsc --watchが監視している間、そのコマンドは終わることがないからです。

tsc-watchのインストール

$ npm i -D tsc-watch

scriptsコマンドの設定

package.jsonscriptsdevコマンドを設定します。

  "scripts": {
    "dev": "rm -rf dist && set DEBUG=express-typescript:* & tsc-watch --noClear --onSuccess \"node ./bin/www\"",
  },
pug,html,css,jsonなどの変更検知は不要か?
不要と考えています。
それらは静的ファイルであり、大抵の場合はnode.jsの再起動が必要ないからです。
nodemonを使った場合の設定

nodemonを使った場合の設定は以下のようになりました。

  "scripts": {
    "dev": "nodemon -e ts,js --ignore dist/ --exec npm start",
    "start": "tsc && set DEBUG=express-typescript:* & node ./bin/www"
  },

tsc-watchの方がシンプルですね😉

Docと仕様書を連携させる

ソースコードのDocがそのまま仕様書になるのは何事にも代えがたい安心感があります。
以前に以下の記事で紹介したswagger-uiとExpressの連携を使います。

swagger-uiと関係packageのインストール

TypeScriptなので型定義もインストールします。

$ npm i -D swagger-ui-express swagger-jsdoc @types/swagger-ui-express @types/swagger-jsdoc

app.tsの変更

基本的に上記記事の通りです。

import createHttpError from "http-errors";
import express from "express";
import { Request, Response, NextFunction } from "express";
import cookieParser from "cookie-parser";
import logger from "morgan";

+ import swaggerUi from "swagger-ui-express";
+ import swaggerJSDoc from "swagger-jsdoc";

...

const app = express();

+ // Swagger
+ const options = {
+   swaggerDefinition: {
+     info: {
+       title: "Express TypeScript",
+       version: "1.0.0"
+     }
+   },
+   apis: ["routes/*"]
+ };
+ app.use("/spec", swaggerUi.serve, swaggerUi.setup(swaggerJSDoc(options)));

...
 

index.ts

Docを追加しただけなので差分ではなくindex.ts全てを記載します。

index.ts

import { Router } from "express";

export const router = Router();

/**
 * @swagger
 * /:
 *   get:
 *     description: タイトルを返却する
 *     produces:
 *       - application/json
 *     responses:
 *       200:
 *         description: タイトル
 */
router.get("/", (req, res, next) => res.render("index", { title: "Express" }));

http://localhost:3000/specにアクセスして仕様書が表示されればOKです。
勿論、Docを書き換えたら再起動します。(自動リロードはされません)

ファイルの自動フォーマット

Prettierを使ってファイル保存時に自動フォーマットをかけます。
先日記事を書いたばかりなので詳細は下記をご覧下さい。

Prettierのインストール

$ npm i -D prettier

.prettierrc.yamlの設定

printWidth: 120
tabWidth: 2
useTabs: false
semi: true
singleQuote: true
quoteProps: as-needed
trailingComma: all
bracketSpacing: true
arrowParens: avoid

テストを書く

Jestを使います。

Jestのインストール

TypeScriptで使うため型定義が必要です。

$ npm i -D jest @types/jest

また、TypeScriptをトランスパイルするためにBabel関連のpackageが必要です。

$ npm i -D babel-jest @babel/core @babel/preset-env @babel/preset-typescript

jest.config.jsの設定

Jestの設定を作成します。
node_modulesdistはテスト対象から外します。

module.exports = {
  verbose: true,
  collectCoverage: true,
  testPathIgnorePatterns: ["/node_modules/", "/dist/"]
};

babel.config.jsの設定

TypeScriptのプロダクトコードをテストできるよう設定ファイルを作成します。

module.exports = {
  presets: [
    ["@babel/preset-env", { targets: { node: "current" } }],
    "@babel/preset-typescript"
  ]
};

tsconfig.jsonの設定

テストファイルをコンパイルや監視対象から外します。

      "esModuleInterop": true
-   }
+   },
+   "exclude": ["dist", "node_modules", "**/*.test.ts"]
  }
 

テストコードを書く

テストの動作確認用にutilsディレクトリと以下ファイルを作成します。

utils
├── math.test.ts
└── math.ts

math.ts

export function crazySum(x: number, y: number): number {
  return x + y - 1;
}

math.test.ts

import { crazySum } from "./math";

test("crazySum is sum and minus 1", () => {
  expect(crazySum(1, 3)).toBe(3);
});

scriptsコマンドの設定

package.jsonscriptstestコマンドを設定します。

  "scripts": {
    "test": "jest"
  },

npm testを実行してテストが成功すればOKです。

ブラウザの自動リロード

これで最後です。
以下のケースでブラウザが自動リロードされるようにします。

  • .pugファイルに変更があったとき
  • .cssファイルに変更があったとき

BrowserSyncのインストール

BrowserSyncを使います。

$ npm i -D browser-sync

/bin/wwwの変更

前半のコードを以下のように変更します。

/**
 * User Browser Sync when development
 */
let defaultPort = 3000;
if (process.env.NODE_ENV !== 'production') {
  console.log('User browser sync..');
  var browserSync = require('browser-sync');
  defaultPort = 3333;
  browserSync({
    open: false,
    proxy: `localhost:${defaultPort}`,
    files: ['./**/*.pug', './**/*.css'],
  });
}

/**
 * Get port from environment and store in Express.
 */
var port = normalizePort(process.env.PORT || defaultPort);
app.set('port', port);

BrowserSyncをプロキシサーバlocalhost:3333として使います。
一方、本番稼働する場合はBrowserSyncを使わないようにします。

portの設定以降は処理を共通化できます。

`open: false`を設定しているのはなぜ?
tsc-watch/bin/wwwを再起動したとき、新しくブラウザタブが開くのを防ぐためです。
毎回トップページが開くためメリットもありません。

scriptsコマンドの設定

package.jsonscriptsstartを追加します。

  "scripts": {
    "start": "rm -rf dist && tsc && set NODE_ENV=production& node ./bin/www",
  },

今までプロダクションビルドコマンドを用意していなかったので、この機会に追加しました。
npm run devnpm startのそれぞれで、BrowserSyncが起動するかを確かめてみましょう。

`NODE_ENV=production`が効いていない場合は...

production&の間にスペースを入れてないか確認してください。
Windowsでそれは空白文字と見なされます。

  • 🆖 set NODE_ENV=production & node ./bin/www
  • 🆗 set NODE_ENV=production& node ./bin/www

pugファイルやCSSファイルを編集して、その場でブラウザが自動更新されればOKです😄

総括

ExpressのAPIプロダクトをTypeScriptで快適に開発できる環境を構築しました。

開発環境の快適さはプロダクティビティに直結します。
目先の時間に目を奪われず、腰を添えて取り組みたいですね😉