はじめに
この記事は 株式会社TRAILBLAZER Advent Calendar 2025 の4日目の記事です。
対象:フロントエンド開発においてAPIからのResponse型定義についてどうしようかなと模索中のプロジェクト
対象外:Schema駆動開発がすでに実現できているプロジェクトなど、型安全が担保されているプロジェクト(tRPCとか最高ですよね)
TRAILBLAZERでフロントエンドエンジニアをしている田原です。
フロントエンドとしてAPIからのResponse型定義について悩むことがありますが皆さんどのように対応しておられますでしょうか?
今回はそんな型定義に困っている際の一つの解決策を検討してみたのでご紹介します。
困りポイント
// fetchはany or Promise<any>をデフォルトでは返すので
const data = await response.json(); // any型...😵💫
as User[]のようにアサーションでキャストすれば型を付与することはできますが、実行時にサーバーから返ってくるデータが本当にその型と一致している保証を担保することはできません。TypeScriptの型は実行時には消えてしまうからです😶🌫️
なぜランタイムバリデーションが必要なのか
type User = {
id: number;
name: string;
email: string;
};
const response = await fetch('/api/users');
const user = (await response.json()) as User;
console.log(user.email.toLowerCase()); // emailが無いとエラー
上記の場合、サーバーからのResponseにもしemailがない場合はランタイムエラーになってしまう為
よくあるランタイムエラーの事象について
-
APIの仕様変更
🫤「あれ、API仕様変わりましたっけ?」(聞いたけど忘れてたり) -
null/undefinedの扱い
🧐「あれ、、ぇ、、なんでや?」「それnullで返るやつですね」 -
型の不一致
🤨「数字の123が返されるはずなのに何故、、、」
(数値が文字列として返ってくる仕様。123ではなく"123") -
追加フィールド
🙄「ん、なんか増えてる?」※仕様変更と同じ
これらの問題を実行時に検出するので適切にハンドリングするためにランタイムバリデーションを入れておくと安全であり、TypeScriptの「実行時に型が消える」という制約を補完できます。
Schemaの定義とバリデーション
以下、代表的なzodを例としておりますが軽量なvalibotなどでも良いと思います。
基本的な使い方(というか結論)
import { z } from 'zod';
// Schema定義
const UserSchema = z.object({
id: z.number(),
name: z.string(),
email: z.string().email(),
createdAt: z.string().datetime(),
});
// Schemaから型を推論
type User = z.infer<typeof UserSchema>;
// バリデーション実行
const result = UserSchema.safeParse(responseData);
if (result.success) {
// result.data は User型として型安全に使える
console.log(result.data.email);
} else {
// バリデーションエラーの詳細を取得
console.error(result.error.issues);
}
parseとsafeParseの違い
// parse: 失敗時に例外をスロー
try {
const user = UserSchema.parse(responseData);
} catch (e) {
if (e instanceof z.ZodError) {
console.error(e.issues);
}
}
// safeParse: 失敗時もResult型で返す(例外をスローしない)
const result = UserSchema.safeParse(responseData);
if (!result.success) {
console.error(result.error.issues);
}
配列・ネストしたオブジェクトの場合の例
// 配列
const UsersResponseSchema = z.array(UserSchema);
type UsersResponse = z.infer<typeof UsersResponseSchema>;
// ネスト
const PostSchema = z.object({
id: z.number(),
title: z.string(),
body: z.string(),
author: UserSchema,
tags: z.array(z.string()),
});
OrvalによるOpenAPIからのSchema生成
上記のようにzodのSchemaをinferで型に変換し設定しておくのは悪くないですが、手動でSchemaを書くのは面倒ですしAPIの仕様変更に追従するのも大変です。
今更改まって紹介するほどのものではなく有名ですが
Orvalを使えば、OpenAPI(yaml/json)からzod Schemaを自動生成できます。
Orvalの設定
// orval.config.ts
import { defineConfig } from 'orval';
export default defineConfig({
petstore: {
input: {
target: './openapi.yaml', // OpenAPI specファイル
},
output: {
mode: 'tags-split',
client: 'zod', // zod Schemaのみを出力
target: 'src/gen/schemas',
fileExtension: '.zod.ts',
},
},
});
生成されるSchema
// src/gen/schemas/users.zod.ts(自動生成)
import { z as zod } from 'zod';
export const getUsersResponseItem = zod.object({
id: zod.number(),
name: zod.string(),
email: zod.string(),
});
export const getUsersResponse = zod.array(getUsersResponseItem);
export const postUsersBody = zod.object({
name: zod.string(),
email: zod.string(),
});
なぜOrvalの機能をフルに活かしてfetchクライアントも生成しないのか?
Orvalはclient: 'fetch'やclient: 'react-query'で型付きクライアントも生成できますがレスポンスのランタイムバリデーションは自動で行われません。
生成されたクライアントは型推論のみで、実行時にサーバーから不正なデータが返ってきても検知できず、問題の本質的な解決に一歩届かないのでzod Schemaだけを生成し、fetchクライアントは自前で用意してBoundary層でsafeParseを実行する構成が良いと思います。
階層を分ける意義について
アプリケーションの 境界(Boundary) でバリデーションを実行することで、内部のビジネスロジックは常に検証済みのデータを扱えます。
実装例:API ClientをBoundary層として設計
// api/client.ts(Boundary層)
import { type ZodSchema } from 'zod';
type ApiResult<T> =
| { success: true; data: T }
| { success: false; error: { type: 'validation' | 'network'; message: string } };
class ApiClient {
private baseURL: string;
constructor(baseURL: string) {
this.baseURL = baseURL;
}
/**
* Boundary層としてのfetch
* - 外部からのデータをOrval生成SchemaでsafeParseして検証
* - 検証済みデータのみを内部に渡す
*/
async fetch<T>(
endpoint: string,
schema: ZodSchema<T>
): Promise<ApiResult<T>> {
try {
const response = await fetch(`${this.baseURL}${endpoint}`);
if (!response.ok) {
return {
success: false,
error: { type: 'network', message: `HTTP ${response.status}` },
};
}
const json = await response.json();
// ここがBoundary: 外部データを検証(safeParseで例外をスローしない)
const result = schema.safeParse(json);
if (!result.success) {
console.error('Validation failed:', result.error.issues);
return {
success: false,
error: { type: 'validation', message: 'Invalid response format' },
};
}
return { success: true, data: result.data };
} catch (error) {
return {
success: false,
error: { type: 'network', message: error instanceof Error ? error.message : 'Unknown error' },
};
}
}
}
export const apiClient = new ApiClient('https://api.xxx.xxxx');
// services/userService.ts(内部:検証済みデータのみ扱う)
import { z } from 'zod';
import { apiClient } from '../api/client';
// Orvalで生成したSchemaをimport
import { getUsersResponse } from '../gen/schemas/users.zod';
type User = z.infer<typeof getUsersResponse>[number];
export async function getUsers(): Promise<User[] | null> {
// Orvalで生成したSchemaを渡す
const result = await apiClient.fetch('/users', getUsersResponse);
if (!result.success) {
// エラーハンドリング(ログ、通知など)
return null;
}
// result.data は検証済みのUser[]
return result.data;
}
Boundary層でsafeParseを使いResult型で返すようにすると呼び出し元で明示的にエラーハンドリングが強制され、try-catchの階層が深くならなくなりますので実装するAppの性質・制約に合わせて使い分けると良いと思います。
型・値の変換について
Client側での処理である為、BFFほどresponseの型を変更することは望ましくありませんが、フロントで扱いやすい形にすることができることも利点です。
また、Orvalが生成するSchemaはOpenAPIの定義に基づくため、日付はstringになります。Dateオブジェクトに変換したい場合はこの手法で変換を行います。
import { z } from 'zod';
import { getUsersIdResponse } from '../gen/schemas/users.zod';
// 生成されたSchemaを拡張してDateに変換
const UserWithDateSchema = getUsersIdResponse.extend({
createdAt: z.string().datetime().transform((str) => new Date(str)),
});
type UserWithDate = z.infer<typeof UserWithDateSchema>;
// UserWithDate['createdAt'] は Date型
構成例
src/
├── gen/ # Orvalで自動生成(編集しない)
│ └── schemas/
│ └── users.zod.ts
├── api/
│ └── client.ts # Boundary層(fetch + safeParse)
├── services/
│ └── userService.ts # 内部ロジック
└── components/
└── UserList.tsx # UI
まとめ
現状のany型の不満を解消でき、破壊的な変更を加えずに既存のプロジェクトに導入するものを考えてみました。
Boundary層として明確に役割をもたせることでランタイムバリデーションを導入し、APIの仕様変更に強く堅牢なフロントエンドを構築できるようになるかなと思っています。
最後に
本記事を最後まで読んで頂きありがとうございます![]()
TRAILBLAZERでは一緒に働くメンバーを募集中です!!
皆さまからのご連絡お待ちしております![]()