TypeScript+Expressの快適な開発環境を作ってみた
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-watchはtsc --watchコマンドが終了した後の挙動を指定できるpackageです。
スターの数はnodemonと比べると多くありませんが非常にCoolです👍
なぜ`tsc --watch`ではダメ?
tsc --watchはソースコードの変更があった場合に自動で再コンパイルしてくれます。
しかし、コンパイルが終わった後に処理を実行できません。
tsc --watch && exec ...は想定通り動きません。
tsc --watchが監視している間、そのコマンドは終わることがないからです。
tsc-watchのインストール
$ npm i -D tsc-watch
scriptsコマンドの設定
package.jsonのscriptsにdevコマンドを設定します。
  "scripts": {
    "dev": "rm -rf dist && set DEBUG=express-typescript:* & tsc-watch --noClear --onSuccess \"node ./bin/www\"",
  },
pug,html,css,jsonなどの変更検知は不要か?
それらは静的ファイルであり、大抵の場合はnode.jsの再起動が必要ないからです。
nodemonを使った場合の設定
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_modulesとdistはテスト対象から外します。
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.jsonのscriptsにtestコマンドを設定します。
  "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`を設定しているのはなぜ?
scriptsコマンドの設定
package.jsonのscriptsにstartを追加します。
  "scripts": {
    "start": "rm -rf dist && tsc && set NODE_ENV=production& node ./bin/www",
  },
今までプロダクションビルドコマンドを用意していなかったので、この機会に追加しました。
npm run devとnpm 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で快適に開発できる環境を構築しました。
開発環境の快適さはプロダクティビティに直結します。
目先の時間に目を奪われず、腰を添えて取り組みたいですね😉