Express+TypeScriptでレスポンスボディに「ストレスなく」型を付ける

Express+TypeScriptでレスポンスボディに「ストレスなく」型を付ける

はじめまして!
アイスタイルAdvent Calender2021 の12/17担当、新卒1年目Webエンジニアのkudomaです!
現在はアットコスメで使用するAPIの開発に携わっています。

今回の記事では、Express+TypeScriptでレスポンスボディに「ストレスなく」型を付けられるようにしてみたので、その内容を執筆させていただきます。

目次

  • はじめに
  • 課題
  • やったこと
    • 他サービスで使用されているエラーのレスポンスボディのプロパティを洗い出す
    • Web API The Good Partsを読んだ上で洗い出したプロパティが必要か考える
    • 実装
  • 結果
    • 実装前と実装後で何が変化したか
    • 得られたメリット
  • 最後に
  • 参考文献

はじめに

ある日、先輩社員とのペアプロ中に最高の出会いがありました。
ExpressのResponse型の型引数に、任意で型を入れることで、レスポンスボディに型を付けられるという最高の出会いが。

// 成功時の型
interface FruitResponse {
  fruitId: number;
  fruitName: string;
}

// エラーの型
interface ErrorResponse {
  message: string;
}

// レスポンスボディに型を付ける
public getFruit(req: Request, res: Response<FruitResponse | ErrorResponse>): void {
}

僕はその日の夜にシャワーを浴びながら、頭の中で以下のようなことを考えていました。
「そういえば、今日先輩に教えてもらったレスポンスボディに型を付けられるやつ最高だったな。エラーの型もデフォルトの型を設定することで、毎度定義しなくてもいいようにできたし。」

// 成功時の型
interface FruitResponse {
  fruitId: number;
  fruitName: string;
}

// エラーのデフォルトの型
interface BaseErrorResponse {
  message: string;
}

// デフォルトの型を拡張するための型
interface ExtensionErrorResponse {
  message; string;
  hoge: string;
  fuga: number;
}

// ジェネリクスを用いた成功時とエラーのユニオン型
// 第1引数には、成功時の型を指定する
// 第2引数は任意だが型を指定したら、エラーの型を拡張できる
type ApplicationResponse<T, U extends BaseErrorResponse = BaseErrorResponse> = T | U;

// 成功時の型だけ指定した場合
// 成功時はFruitResponse、エラーはBaseErrorResponseが型になる
public getFruit(req: Request, res: Response<ApplicationResponse<FruitResponse>>): void {
}

// 成功時とエラーの型を指定した場合
// 成功時はFruitResponse、エラーはExtensionErrorResponseが型になる
public getFruit(req: Request, res: Response<ApplicationResponse<FruitResponse, ExtensionErrorResponse>>): void {
}

「ふー、TSもシャワーも最高だった。TypeScript半端ないって。」

僕はバスタオルで髪を拭きながら、以下のようなことをふと思いました。
「もっと楽に、レスポンスボディに型を付けることができるのでは。既存のプロジェクトでこれができたら、ヒーローになれるのでは」(にやにや)

課題とゴール

課題

  • BaseErrorResposneを型引数で拡張する方式だと、拡張が必要か毎度意識してしまって集中力が切れる
// 拡張しない場合
public getFruit(req: Request, res: Response<ApplicationResponse<FruitResponse>>): void {
}

// 拡張する場合
public getFruit(req: Request, res: Response<ApplicationResponse<FruitResponse, ExtensionErrorResponse>>): void {
}
  • Responseの型引数に毎度ApplicationResponseを入れるのは冗長な気がする
  • エンドポイント100個あった時、100回レスポンスボディにApplicationResponseをつけるってのは…ちょっと…
// フルーツを1件取得
public getFruit(req: Request, res: Response<ApplicationResponse<FruitResponse>>): void {
}

// フルーツを全件取得
public getFruits(req: Request, res: Response<ApplicationResponse<Array<FruitResponse>>>): void {
}

ゴール

  • エラー時にレスポンスボディで必要になるプロパティを全て持つ型を用意すれば、型の拡張という選択肢がなくなる
  • 開発者はエラーの型の拡張をガン無視して開発できる、なんて最高なんだ
interface ErrorResponse {
  message: string;
  hoge1?: string;
  hoge2?: number;
}

// ジェネリクスの第二引数がなくなる
type ApplicationResponse<T> = T | ErrorResponse;
  • 成功時の型を入れるだけで、成功時とエラー時のユニオン型を付けられるようにしたい
  • ApplicationResponseを毎度インポートしてResponseの型引数に入れるという作業が発生しない
  • Responseの型引数がネストしないので、スッキリして超読みやすい!はず
interface FruitResponse {
  fruitId: number;
  fruitName: string;
}

// ApplicationResponseがなくてもエラー時にエラー用の型が付いてくれる
public getFruit(req: Request, res: Response<FruitResponse>): void {
}

やったこと

  • APIのエラー時のレスポンスボディにどんなプロパティが含まれていると使いやすいかわからなかったので、一般的なAPIのエラーの実装パターンを調べてみます
  • 洗い出したプロパティが社内APIで不要か必須か任意か判断がつかなかったため、Web API The Good Partsを読んだ上で洗い出したプロパティが必要か考えてみます
  • 実装

他サービスで使用されているエラーのレスポンスボディのプロパティを洗い出す

この記事 の「エラー内容の比較」から複数のサービスで使用されているプロパティを洗い出します!
プロパティにどのような内容が含まれるかも上記の記事を参考にしています。

  • エラーメッセージ
    • 開発者が読んで分かるメッセージ
  • エラーコード
    • クライアントのプログラムがエラーハンドリングの手がかりになる情報
    • 加えて、ドキュメントではエラーコードの一覧を公開する
  • 複数エラー
    • 複数エラーが表現できる
  • ステータスコード
  • 詳細URL
    • ドキュメントが整っているなら、開発者の探す手間がなくなる

Web API The Good Partsを読んだ上で洗い出したプロパティが必要か考える

「3.6 エラーの表現」の内容を用いて、洗い出したプロパティが今回必要かどうか考えてみます!
プロパティをオプショナルにするべきかも検討したいので、必須、任意、不要で分類していきます!

ステータスコードで表すことができるのはあくまでエラーのカテゴリや概要であり、実際に起こったエラーが具体的にどんなものであるのかを知ることまではできないことが多くなっています。

メッセージは、クライアントがエラーの原因をきちんと理解するために絶対必要になるので、必須にします。

400に関して言えば、単に何かが間違っているということしかわからず、利用者は何を直してよいのか、それだけではまったく意味がわからないはずです。

エラーコードは、クライアント側のハンドリングの手掛かりになるために絶対必要になるので、必須にします。と言いたいところですが、Internal Server Error などステータスコードだけでハンドリングができてしまうケースもあるので任意にします。

Twitterはエラーが配列で返るようになっています。これは複数のエラーが同時に発生した場合に合理的な方法といえます。たとえば、パラメータが2箇所間違っていた場合に、2箇所のパラメータ違いを別途エラーとして指定するほうが、開発者にとっては親切なことだといえるからです。

複数エラーは、2つ以上のエラーにも対応できるので必要です。
必須項目であるエラーメッセージを持ったオブジェクトを配列で持つので必須になります。

ステータスコードはヘッダーに含むので不要になります。

詳細URLは、サービスというより開発時にだけ必要な情報だから、ドキュメントを社内で事前に共有、一元管理することで不要にできると思っています。

残ったプロパティは以下の3つになります。

  • エラーメッセージ
  • エラーコード
  • 複数エラー

実装

それでは最初に、エラーのレスポンスボディで持つであろうプロパティを全て備えた型を実装していきます。

interface ErrorResponse {
  errors: Array<{
    message: string;
    code?: number;
  }>;
}

次にApplicationResponseを毎度呼ばなくていいように、こちらの記事を参考に、ExpressのResponse自体を拡張します。
って言いたかったのですが、スキルが足りないとか、そもそもアンビエント宣言の使い方が間違っているとか、原因は色々考えられるのですが、現状だとできませんでした。一応自分がやりたかったイメージを紹介します。

// @types/global.d.ts

import { Response } from 'express'

interface ErrorResponse {
  errors: Array<{
    message: string;
    code?: number;
  }>;
}

// 予定: レスポンスボディの型は、T or ErrorResponseになる
declare global {
  namespace Express {
    interface Response<T extends T | ErrorResponse>{};
  }
}
// index.ts

import { Request, Response } from 'express'

interface FruitResponse {
  fruitId: number;
  fruitName: string;
}

// 予定: レスポンスボディの型は、FruitResponse or ErrorResponseになる
// 実際: レスポンスボディの型は、FruitResponseだけ
public getFruit(req: Request, res: Response<FruitResponse>): void {
  // 怒られない
  res.status(200).json({
    fruitId: 1,
    fruitName: 'banana'
  });
  // 予定: 怒られない
  // 実際: 怒られる
  res.status(400).json({
    errors: [
      {
        message: 'invalid fruit id.',
        code: 4001
      },
      {
        message: 'invalid fruit name.',
        code: 4002
      }
    ]
  });
}

実際に僕のエディターでは、エラー情報を送ろうとしている箇所で「FruitResponseにあなたはマッチしないんだからね!」みたいな感じで怒られてしまいます。
スクリーンショット 2021-12-15 18.21.16.png

「ここまでやってできませんでしたは、アドバイスくれた先輩方に申し訳ない(僕のメンタルもきつい)。絶対同じようなこと考えてる人はいるはずだ。」と思って検索を繰り返していたら、Responseを継承した新しい型を定義して、レスポンスボディの型を拡張することができる記事を見つけることができました。
さっそく実装してみます!

// response.d.ts

import { Response } from 'express'
import { Send } from 'express-serve-static-core';

interface ErrorResponse {
  errors: Array<{
    message: string;
    code?: number;
  }>;
}

export interface ExResponse<ResBody> extends Response {
  json: Send<ResBody | ErrorResponse>
}
// index.ts

// expressのResponseではなく、型定義ファイルのExResponseを使用する
import { Request } from 'express'
import { ExResponse } from './response';

interface FruitResponse {
  fruitId: number;
  fruitName: string;
}

// レスポンスボディの型は、FruitResponse or ErrorResponseになった
public getFruit(req: Request, res: ExResponse<FruitResponse>): void {
  // 怒られない
  res.status(200).json({
    fruitId: 1,
    fruitName: 'banana'
  });
  // 怒られない
  res.status(400).json({
    errors: [
      {
        message: 'invalid fruit id.',
        code: 4001
      },
      {
        message: 'invalid fruit name.',
        code: 4002
      }
    ]
  });
}

「キタァ!!!!!FruitResponseを型引数に入れただけでエラーの型も考慮するようになってくれた!」
ということで、本来はExpressのResponse自体を拡張しようと思っていましたが、Responseを継承した新しい型を定義して、その型を使用することでレスポンスボディに「ストレスなく」型を付けることができました。

結果

実装前と実装後で何が変化したか

  • レスポンスボディに型を付ける時は、ExpressのResponseを継承したExResponseを使用する
  • ApplicationResponseのインポートが不要
  • ApplicationResponseを型引数に入れなくてよくなった
  • エラーのレスポンスボディの型を拡張するという選択肢がなくなった

得られたメリット

  • とにかく見た目がスッキリして見やすくなった
  • エラーのレスポンスボディのプロパティは補完が出る
  • レスポンスボディに型を付ける時にExResponseの型引数に成功時の型を入れるだけでよくなった

最後に

最後に皆さん気になるヒーローになれたのかという話をしていきたいと思います。

結論、「まだ」なれていません。(言い訳ではないですよ?これからなれるかもしれないので!)
理由は、きちんと理解できていない箇所があって、提案するレベルにまで到達していないと思ったからです。

今回のブログのタイトルにもなっている「ストレスなく」レスポンスボディに型を付けるという面では、成功できたと思っています。
提案はできませんでしたが、いずれリベンジしたいと思います。
実際に課題に感じてることにチャレンジするのは、すごいワクワクしたし楽しかったです。

最後まで読んでくださりありがとうございました!

参考文献

1年目です。趣味は、アニメと漫画です!好きなアニメは、のんのんびよりです。