🚄

Express + TS のTips

2022/04/10に公開

はじめに

Express+Typescriptのセットで1からAPI開発をしたときの感想とポイント。

Express+TSの肌感覚

フロントエンドでTypeScriptは十分に顔馴染みですが、バックエンドの方はそれほど親切丁寧ではない気がします。型定義を参照するには綺麗なドキュメントなど皆無でコードを読むしか無いなど。(慣れれば苦ではないのですが)

またExpress自体が設計を強制しない自由さがあるので、設計の意識合わせが他のFWよりも必要です。

こんな懸念があるなら通常のJSの方が楽ということは全くなく、個人的に静的型付けでないバックエンド開発は考えられないです。早速その恩恵を預かる準備をしましょう。

ディレクトリ設計を決めよう

ディレクトリ構成が決められていないFWなので特に意識しなくとも自由に作れますが、初期のプロトタイピング以後は苦しくなります。実際のツリーはSpringやRails...風に色々と考察。とりあえず以下の項目に着目し、分かれていればOKだと思います。

  • DAO (Repository)
  • Middleware (Controller, 共通認証)
  • Usecase (Service)
  • Util
  • バッチ系統
  • 環境変数
  • Config
  • DB系
  • カスタムError

ただしJavaScriptパッケージの構成で多くあるsrc配下に色々置くという慣習には、従った方が良さげ。

曖昧なコーディングスタイルを統一しよう

JSは書き方が色々あるので、ガイドラインを定めないと治安が悪化します。例えば着眼点には以下のようなものがあります。

import/export

ファイル単位ならデフォルトエクスポート、ファイル内のモジュール単位なら名前付きエクスポートを使う。

理由

  • JavaScriptのスタンダード
  • デフォルトエクスポートはエディタ補完がやや弱いが、VSCodeなら十分

備考

デフォルトエクスポートを許すか許さないかははっきりとプロジェクトにより別れます。許す方が標準的ですが、名前付きしか使わない有名プロジェクトも結構あります。NestJS, Angular, ChakraUIとか。

クラス

アクセス修飾子・readonlyの有無・返り値定義を省略しない

理由

  • privateメンバを使わないとき、未使用箇所がエディタでハイライトされる
  • Java等の他言語経験者から見ると暗示がなく分かりやすい
  • フロントエンドの文化で見られるコードスタイルと違うが、バックエンドのJavaらしい厳格なスタイルに寄せる方がメリットが勝ると思う
export default class Human {
  constructor(private readonly _name: string, private readonly _age: number) {}

  public format(): string {
    return `I'm ${this._name} (${this._age})`;
  }
}

関数

function fn() {...}よりconst fn = () => {...}を使う。

  • thisで余分な心配をしづらい

ESLintを入れよう

コードの安全性が担保され、入れない手はありません。

1. 導入

まずは推奨ルールを突っ込みます。

eslintrc.js
module.exports = {
  root: true,
  env: {
    es6: true,
    node: true,
  },
  parser: '@typescript-eslint/parser',
  parserOptions: {
    sourceType: 'module',
    ecmaVersion: 2019,
    tsconfigRootDir: __dirname,
    project: ['./tsconfig.eslint.json'],
  },
  plugins: ['@typescript-eslint'],
  extends: [
    'eslint:recommended',
    'plugin:@typescript-eslint/recommended',
    'plugin:@typescript-eslint/recommended-requiring-type-checking',
  ],
};

tsconfig.eslint.json
{
  "extends": "./tsconfig.json",
  "include": ["./**/*.ts", ".eslintrc.js"],
  "exclude": ["node_modules", "dist"]
}

2. 絞り込む

ここから開発の進行に応じて適宜offするものを増やしましょう。ignoreコメントが散らばっていると後々把握が難しくなるので設定ファイルで管理すること。

個人的にまず切っておきたいものは:

// 未使用引数が警告されるので (req, res, next) => {...} というリクエストハンドラが書けない
'@typescript-eslint/no-unused-vars': 'off',

// Expressのモジュールを使うと明示し難い型が結構あるので、常に戻り値を定義するルールは使いづらい
'@typescript-eslint/explicit-module-boundary-types': 'off',

// メジャーかつ型定義が未成熟なライブラリ(mysqlとか)が割とあるので、ts-ignoreの利用場面は視野に入れる
'@typescript-eslint/ban-ts-comment': 'off',

エラーハンドリングをしよう

404用、例外のログ用、例外のレスポンス出力用の3つを揃えてから機能実装に移りましょう。

エラーオブジェクトの管理については無難なものがhttp-errorsですが、型周りの設計が微妙に感じるので自前でErrorの拡張クラスを作る方針でも良いかもしれません。

テーブルに対応する型定義を用意しよう

TypeORMとか入れてる場合はそれを用いて、SQLで自前DAO(Repository)を定義している場合はメソッドの返り値に使います。バックエンドはビジネスロジックは入り込みがちなのでブロックコメントは詳しめに書き過ぎても悪いことはないです。(ここでは省略していますがリレーションも作りましょう)

// usersテーブルの1レコードをマッピングしたインターフェイス

export type User = {
  /**
   * ID (PK)
   *
   * - 自動採番
   */
  id: number;
  /**
   * ユーザ名
   */
  username: string;
  /**
   * 年齢
   */
  age: number;
  /**
   * 誕生日
   */
  birthday: Date;
  /**
   * 作成日時
   *
   * - `DEFAULT CURRENT_TIMESTAMP`により自動で作成される
   */
  created_at: Date;
  /**
   * 更新日時
   *
   * - `DEFAULT CURRENT_TIMESTAMP`により自動で作成される
   * - `ON UPDATE CURRENT_TIMESTAMP`により自動で更新される
   */
  updated_at: Date;
};

リクエストの型定義を用意しよう

これを用意する利点は、「リクエストクエリは常にstring型」「リクエストボディは常にstringまたはnumber型というWebシステムの常識を再確認できることです。

export type ReqQueryFindAllUsers = {
  /**
   * ユーザ名
   */
  username: string;
  /**
   * 年齢
   */
  age: string;
  /**
   * 誕生日
   */
  birthday: string;
  /**
   * 作成日時
   */
  created_at: string;
  /**
   * 更新日時
   */
  updated_at: string;
};

export type ReqBodyCreateUser = {
  /**
   * ユーザ名
   */
  username: string;
  /**
   * 年齢
   */
  age: number;
  /**
   * 誕生日
   */
  birthday: string;
};

export type ReqBodyUpdateUser = {
  /**
   * 年齢
   */
  age: number;
  /**
   * 誕生日
   */
  birthday: string;
};

ちなみにレスポンスの型定義に関してはやや後回しの優先度でOKだと思います。

リクエストの型定義を適用しよう

初心者にはどこに指定箇所があるのか相当わかりずらいです。知っていれば何ともないのですが、答えをいうとここです。

interface Request<
	P = core.ParamsDictionary, // <-- req.params (リクエストパスパラメータ)
	ResBody = any, // 
	ReqBody = any, // <-- req.body (リクエストボディ)
	ReqQuery = core.Query, // <-- req.query (リクエストクエリパラメータ)
	Locals extends Record<string, any> = Record<string, any>
> extends core.Request<P, ResBody, ReqBody, ReqQuery, Locals> {}

つまり、ミドルウェアのreqres引数に対して専用の型を定義し、それを使いましょう。

export type ReqFindAllUsers = Request<unknown, unknown, unknown, ReqQueryFindAllUsers>;
/* 微妙 */
(req: Request, res: Resoponse, next: NextFunction) => {
  // ...
}

/* 望ましい */
(req: ReqFindAllUsers, res: Resoponse, next: NextFunction) => {
  // ...
}

認可されたユーザの型定義を用意しよう

JWTで認証を作ったりしたら、res.localsに突っ込むための認証ユーザ情報についても型定義を用意しましょう。

export type LoggedInUser = ...

テストコードを書こう

小さなモジュールほどテストコードをすぐ作っておくことが吉です。
具体的にはとある関数をファイル化したらそのtest.tsも同じ単位で用意しましょう。

機能単位でまとめよう

言語化が難しいですが、はじめからルートディレクトリに近い階層にtypes.tsutils/を作るスタイルだとモジュールの結びつきが見えづらくなり破綻します。最初はなるべく1機能に対してユーティリティや型定義を近づけて配置し、汎用的だと充分に判断したら移動させましょう。

DBはさっさと入れよう

曖昧でもモックアップを進めることには賛成ですが、アプリケーションコードで何もかも再現せずにちゃんとDBを使いましょう。(生DBかORMかはどちらでもいい)

本来の挙動とずれたままモックアップの規模が肥大化すると取り返しがつかなくなります。

おわりに

他にもJestとかありますがこのくらいにとどめました。これを読んでExpress+TSを快適に初めましょう。

Discussion