3秒でわかる
API のリクエストやレスポンスの型を、フロントとバックエンドで同じ定義から参照する手法。片方だけ直したときのずれをコンパイル時に検出できる。
もう少し詳しく
どういうものか
API でやり取りするデータの型定義を一箇所に置き、フロントエンドとバックエンドの双方がそこを参照する作り方です。モノレポの共有パッケージに置く、バックエンドの型を直接インポートする、スキーマから両方の型を生成する、といった実現方法があります。
同じ形を 2 回書かないことが要点です。片方を直せば、もう片方は型が合わなくなってコンパイルエラーになります。伝え忘れという事故を、仕組みで防ぐという発想です。
なぜ必要か
API の変更が一番壊れやすい継ぎ目だからです。サーバー側で name を fullName に変えたとき、フロントの user.name は静かに undefined になります。TypeScript を使っていても、型を別々に書いていれば古い定義がそのまま通ってしまい、画面に空白が出て初めて気付きます。
型を共有していれば、この変更はビルド時点で赤くなります。実行して画面を開いて確かめるまでの往復が消え、直す場所も型エラーが出た行として提示されます。人数が増えるほど、また API の数が増えるほど効いてきます。
具体例
共有パッケージに型だけを置き、両側から読みます。
// packages/shared/src/api.ts
export type User = {
id: number;
fullName: string;
email: string;
};
export type GetUsersResponse = {
users: User[];
total: number;
};// apps/api/src/routes/users.ts
import type { GetUsersResponse } from "@app/shared";
app.get("/users", async (req, res) => {
const users = await db.user.findMany();
const body: GetUsersResponse = { users, total: users.length };
res.json(body);
});// apps/web/src/api/users.ts
import type { GetUsersResponse } from "@app/shared";
export async function fetchUsers(): Promise<GetUsersResponse> {
const res = await fetch("/api/users");
return res.json();
}Zod のようなスキーマを使えば、型と入力検証を 1 つの定義から導けます。
import { z } from "zod";
export const userSchema = z.object({
id: z.number(),
fullName: z.string(),
email: z.string().email(),
});
export type User = z.infer<typeof userSchema>;つまずきやすいところ
import type に統一すると混入を防げますfetch の結果に型注釈を付けただけで安心する。実行時に検証しているわけではないので、サーバーが違う形を返せばそのまま通ります。境界ではスキーマで検証します似た用語との違い
| 手法 | 型の出どころ |
|---|---|
| 型定義の共有 | 手で書いた TypeScript の型を両側で参照する |
| スキーマからの生成 | Zod や OpenAPI の定義から型を導く |
| tRPC | サーバーの実装そのものから型が推論される |
下に行くほど手で書く量は減りますが、構成の自由度は下がります。