バックエンドとフロントエンドをどちらもTypeScriptで開発していると、一度は試みたくなるのが型定義の共通化ではないでしょうか?
たとえば、monorepo(私はTurborepoを使ってます)を使った環境で、以下のように型定義を共通化する構成を考えたことがあるかもしれません。
apps
├── backend
└── frontend
packages
└── types
└── users.ts
import { z } from 'zod';
const IMAGE_TYPES = ["image/png", "image/jpg"];
export const userSchema = z.object({
createDate: z.date(),
email: z.string().email().min(1),
image: z.custom<File>().refine((file) => IMAGE_TYPES.includes(file.type), {
message: 'Invalid file type',
}),
name: z.string().min(1),
});
export type UserData = z.infer<typeof userSchema>;
この方法では、Zodを活用してスキーマを定義し、それを元にz.inferで型を生成しています。これによりスキーマと型の二重管理が不要になるため、一見効率的に思えるでしょう。そして、この型定義をバックエンドとフロントエンドで共通して使えたらもっと効率的だと考えることでしょう。
ですが、実際には型を共通化しないほうが良い場面も多くあります。本記事では、その理由と回避策を解説します。
なぜ型を共通化しないほうが良いのか?
日付データの扱いが異なる
バックエンドではDate型の日付を返却しますが、フロントエンドで受け取るときにはstring型に変わります。この差異を吸収するためには、受け取った文字列型を再度日付型に変換する必要があります。
以下のように対応することは可能です:
const parsedDate = new Date(user.createDate);
しかし、これでは型共通化のメリットが薄れます。どのみち変換処理が必要であれば、バックエンドとフロントエンドで型定義を分け、レビュー時のルール化やコーディングガイドラインに組み込んだほうが効率的です。
ファイルアップロードの非互換性
ファイル型はさらに厄介です。フロントエンドではFile型で扱いますが、バックエンドではmultipart/form-data形式でリクエストを受け取ります。このギャップは技術的に埋められないため、型を共通化すること自体が無理なケースです。
例: 型のギャップ
フロントエンド:
const file: File = new File(["content"], "example.png", { type: "image/png" });
バックエンド(Express.jsを利用している場合):
const file = req.file;
このような場合、型共通化を目指すよりも、それぞれに適した型を別々に定義するほうが現実的です。
型定義を「いい感じ」にするための方法
スキーマ定義を間に挟む
このような問題はもはや語り尽くされた議論であり、過去の叡智を結集して解決策を提示してくれているOSSがあるのです。
型共通化を無理に目指すのではなく、スキーマ定義をバックエンドとフロントエンドの間に挟むアプローチが有効でしょう。例えばOpenAPIやGraphQLを利用することで、スキーマベースで型の整合性を保つことが可能になります。最近ではtRPC、gRPCという選択肢もありますね。
具体的な解決策については以下のような記事を参考にしてください。
型を明確に分離しルールで管理する
最もシンプルな解決策は、バックエンドとフロントエンドの型を明確に分離することです。さらに命名規則を導入して可読性を高めることで、管理コストを下げられます。
DDDでもDTOを使う前提だと型変換が必要になるので、面倒でもそれぞれの型を定義したほうが柔軟性は上がると思います。
例: 命名規則による型定義の分離
import { z } from 'zod';
// バックエンド (受信時の型)
export type UserRequestDataBackend = {
createDate: string; // フロントエンドから受信する文字列型の日付
email: string;
name: string;
};
// バックエンド (内部処理・DB登録用の型)
export type UserBackendData = {
createDate: Date; // 内部処理やDB用のDate型
email: string;
name: string;
};
// ------------------------------------------------------
// zodのスキーマ定義
export const userRequestSchema = z.object({
createDate: z.date(), // Date型を期待
email: z.string().email().min(1),
name: z.string().min(1),
});
// フロントエンド (リクエスト送信時の型)
export type UserRequestDataFrontend = z.infer<typeof userRequestSchema>;
// フロントエンド (レスポンス受信時の型)
export type UserResponseDataFrontend = {
createDate: string; // バックエンドから受信する文字列型の日付
email: string;
name: string;
};
また、上記とは別にバックエンドで未加工のデータはスネークケース、加工済みのデータはキャメルケースにするというのも割と有効かなと思います。
例えばDBの命名規則に従い user_name
や user_image
などというカラムを、そのままバックエンドから返す場合は以下のようになります。
/** フロントエンド (レスポンス受信時の型) */
export type UserResponseDataFrontend = {
create_date: string; // 未加工
email: string; // 未加工かどうかわからない
user_name: string; // 未加工
};
この方法のデメリットとしては、上記のように email
のように単語の区切りのないデータについては加工・未加工がわからなくなってしまうので注意が必要です。
まとめ: 型共通化は「やらない勇気」も必要
当たり前すぎることですが、バックエンドとフロントエンドは、実際には両者の役割やデータの扱いが異なるため、共通化はかえって負担を増やす場合があります。
型を分離し、それぞれに適した定義を行うことで、プロジェクト全体の開発体験を向上させることが結局正解のパターンが多いと思います。
コピペエンジニアはコピペ時のコメント文の修正に特に注意しましょう!