Express + TS API開発で先にやっておきたいリスト


はじめに

Express+Typescript開発は良くも悪くもハマりどころがあります。APIサーバの実装をテーマとして先々決めておきたいことを紹介します。(とは言いつつView込みとかでも恐らくエッセンスは同じでしょう)

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らしい厳格なスタイルに寄せる方がよい

関数

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に突っ込むための認証ユーザ情報についても型定義を用意しましょう。エンティティやAPIレスポンスの型定義で代用しないように。

export type LoggedInUser = {
  id: number;
  // ...
};

おわりに

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