2025.03.25

フルスタックTSでhttp通信を型安全にする

はじめに

みなさんはフルスタックWebアプリケーションを開発する際、どのような技術スタックを採用されるでしょうか。株式会社スーパーハムスターではフロントエンドにReact、バックエンドにNode.jsを採用することが多く、どちらもTypeScriptで実装しています。

今回はフルスタックTSで開発する際に感じた課題と、すべてTypeScriptで実装するからこそ取れる解決策を紹介します。

フルスタックTSとは

フルスタックTSとは、フロントエンドとバックエンドの両方をTypeScriptで開発する手法を指します。

最近では、フロントエンドにはReactやNext.jsなどを採用する方も多いでしょう。ここで、バックエンドにはExpress、NestJS、Honoを採用することで、フロントエンドもバックエンドもTypeScriptで統一することができます。

そもそもサーバーサイドの開発には、TypeScriptよりも適した言語やフレームワークがあるのではないかという意見もありますが、ここではフロントエンドもバックエンドもTypeScriptで揃えることで得られるメリットに注目します。

まず、TypeScriptで統一することで、型定義の実装を共有でき、実装コストを落としたり型安全性を高められることが考えられます。一方、ドメインモデルの型定義を共通化することで、バックエンドの都合で変更した内容を意図せずフロントエンドに影響を与えてしまうこともあるため、ここはプロジェクトの性質に応じた設計が必要です。

また、言語が統一されることでフロントエンドとバックエンドの実装時のスイッチングコストの低さも挙げられます。実装者としては意外とこれが一番大きいかもしれません。

組織作りの観点では、エンジニアの採用しやすさも考えられます。フロントエンドエンジニアもバックエンドでやっていることをなんとなく理解できたり、その逆もあり、お互いの領域への越境のしやすさがあるかもしれません。一方で、フロントエンド/バックエンドの専門性が求められる場面では越境したところで貢献できるのかという反論もあります。それでも、新しい言語の学習コストを抑えられるので未経験の領域に挑戦しやすかったり、緊急時の対応で担当領域ではない人も関わりやすかったりすることはあるでしょう。

まとめると、以下のメリットが挙げられます。

  1. 型安全性の向上: フロントエンドとバックエンドの間でデータの型を統一し、型エラーを事前に防ぐ。
  2. 開発効率の向上: 一つの言語で統一されるため、学習コストが低く、スイッチングコストも低いため、チームの生産性を向上しやすい。
  3. 越境のしやすさ: 一つの言語で統一されることでフロントエンドエンジニア/バックエンドエンジニアがお互いの領域に関わりやすい。

課題感

フロントエンドもバックエンドもTypeScriptで実装することで、ドメインモデルの型定義を共通化することが可能です。モノレポで開発する場合、共通の型定義パッケージを用意し、それぞれフロントエンドとバックエンドにインストールすることができます。

ここで、フロントエンドとバックエンドの通信レイヤーは型が定義されておらず、型安全とは言えません。バックエンドはフロントエンドから投げられるリクエストボディが期待通りか検証できず、フロントエンドはバックエンドから必要十分なレスポンスが返ってきているか判断できません。実際にAPIを叩いてどのパラメータが足りないかを目視で探す必要があるため、ここのデバッグで苦労した方も多いのではないでしょうか。

この課題への解決策として、OpenAPIを使ったスキーマ駆動な開発にしたり、TypeScriptを使っているならtRPCを使う選択肢が挙げられるでしょう。Honoを使っていればHono RPCという仕組みを使うこともできます。

しかし、どれもtoo muchな感じがするため、もう少し低コストな方法を探していました。

シンプルなRPCという解決策

今回はReact+Expressを使った小規模なアプリケーション開発なため、できるだけ低コストな手段を選択したいです。

そこで、httpレイヤーの型定義もパッケージ化し、フロントエンドとバックエンドで共通の型定義を利用するという方法を選びました。考え方はHonoRPCと似ていますが、こちらを手動でやることにしました。手動で実装していくと、APIを変更した際の変更コストが気になってくるタイミングが来るかもしれません。今回は、小規模なプロジェクトでの一つのアプローチとしての紹介です。

Zodでスキーマを実装し、z.infer<typeof hoge>を使って型を生成します。

例えば、ユーザーの名前を変更する機能を実装することを考えます。

まず、リクエストボディは次のように実装します。

import { z } from 'zod'

// API /users/:id PUTへのリクエストボディのスキーマ
export const updateUserRequestSchema = z.object({
  name: z.string(),
})

export type UpdateUserRequest = z.infer<typeof updateUserRequestSchema>

ここで生成した型をフロントエンドとバックエンドで使用します。

フロントエンドでは、リクエストを投げる前に、バックエンドが期待しているスキーマになっている検証します。

const parsedData = updateUserRequestSchema.safeParse(data)
if (!parsedData.success) {
  setError('入力データにエラーがあります。')
  console.error(parsedData.error.errors)
  return
}

const response = await fetch(userByIdKey(userId), {
  method: 'PUT',
  headers: {
    'Content-Type': 'application/json',
  },
  body: JSON.stringify(parsedData.data),
})

バックエンドでは、受け取ったリクエストボディを再度検証します。

const result = updateUserRequestSchema.safeParse({ ...req.body })
if (!result.success) {
  res.status(400).json({ message: 'Invalid request body' })
  return
}

このように実装することで、リクエストボディやレスポンスデータが期待されたものと異なる際に、どのパラメータが足りないかをランタイム上でエラーを受け取ることができます。

さいごに

フルスタックTSでhttpレイヤーの型定義を共通化することで、型安全なAPI開発を行う方法を紹介しました。

OpenAPIやHonoRPCを使用するプロジェクトではこのような方法を取る必要がないため、あくまでExpressを使った小規模な開発のときの解決策の一つです。

他にも良い方法があったら教えてください。