はじめに
こんにちは。and factory フロントエンドエンジニアの青木です。
現在関わっているプロジェクトのフロントエンドで採用している、OpenAPI を Single Source of Truth とした型安全 API 統合パターン を紹介します。
Orvalでやっていることを並べると次のとおりです。
- バックエンドの
make openapiで生成されたYAMLをフロント側にコピー - 1コマンドで 型定義 / TanStack Query Hook / Zodスキーマ / MSWモック までを一括生成
- 公開APIと認証APIで operationId 単位に mutator を切り替え、誤用を生成時点で防ぐ
- Zodの 二段防御 (coerce × transform) で
unknownをコンポーネント層に到達させない
前提: Single Source of Truth とは
Single Source of Truth (SSoT) は、ある情報の 「正しい定義を置く場所を 1 箇所に固定する」 設計原則です。日本語では「信頼できる唯一の情報源」と訳されます。
例えばAPIの「リクエスト/レスポンスの形」をプロジェクトのあちこちに書いていると、以下の問題が起きます。
- バックエンドが型を変えたのにフロントのTypeScript型が古いまま動いてしまう
- ドキュメントとコードがズレて、新規参画者がどちらを信じればよいか分からなくなる
- 同じ情報を複数箇所で手書きするため、変更時の修正漏れが必ず発生する
これを防ぐため、「APIの形」を openapi.yaml という1つのファイルに集約 しています。フロントの型・Hook・Zod・MSWモックは、すべてそこから自動生成する運用です。
flowchart TD
A["openapi.yaml<br/>(唯一の「真実」)"]:::source
A --> B[TypeScript 型]
A --> C[TanStack Query Hook]
A --> D[Zod 検証スキーマ]
A --> E[MSW モックハンドラ]
classDef source fill:#0ea5e9,color:#fff,stroke:#0369a1,stroke-width:2px
これが「OpenAPIをSingle Source of Truthにする」の意味です。openapi.yaml を更新して pnpm run generate:api を実行すれば、全派生物が機械的に再生成されます。手作業の同期は不要です。
なぜ Orval を選んだか — バックエンドと並走するため
本プロジェクトのバックエンドはフロントエンドと別チームで開発が進んでおり、プロジェクトの性質上、バックエンドの実装完了を待たずにフロントを先行して進める必要がありました。加えて、以前担当した別プロジェクトではAPIスキーマの一元管理がありませんでした。レスポンス構造を確認するために .proto 定義を直接読みに行ったり、APIを呼び出して目視確認したりする運用を経験していました。
これらを踏まえ、「型を手書きで管理しない」「OpenAPI を契約として先に固め、フロントはモックで動かしながら先行実装する」 の両方を満たす運用方針を立てました。これを支える生成ツールとしてOrvalを採用しています。決め手になったのは次の3点です。
1. 「OpenAPI 契約 → フロント着手」の成立
バックエンドチームと「このURLでこの形のレスポンスを返す」というスキーマだけ合意できれば、openapi.yaml を起点にOrvalで 型 / Hook / モック が一気に揃います。バックエンド実装が間に合わなくても、フロントは「Orvalが生成した useXxx() を呼び出す」コードを書き進められます。
2. MSW 自動生成による「未実装 API」のローカル動作確認
mock: { type: 'msw', useExamples: true, generateEachHttpStatus: true } の組み合わせがよく効きます。
- OpenAPIの
examplesをそのままMSWのレスポンスに流せる responsesに定義した4xx/5xxのモックも生成される(デフォルトは200のみ、定義したステータス分だけ生成)- エラー UIのテストもバックエンドなしで完結する
- 結果として バックエンド実装と並走でフロント完成度を上げられる
軽量な openapi-typescript + openapi-fetch ではMSWハンドラを手書きする必要があります。KubbやHey APIはMSWプラグイン等で同等のことが可能ですが、方針が異なりOrvalほど一括では生成しづらい構成です。
3. バックエンド更新時の「ズレ検出」の安全性
並走開発で最も怖いのは「OpenAPIを更新したのにフロントが古い型のまま気付かない」事故です。Orvalの clean: true で 再生成時に死んだファイル・古い型を完全に削除 するため、operationIdのリネームや削除があれば確実にビルドエラーになります。手書きクライアントでは検知できない契約のズレを、生成段階で機械的に防げます。
比較した他候補
なお、本プロジェクトのバックエンドは TypeScript ではなく Go で実装 されています。そのため、TypeScriptの型を直接共有するアプローチ(tRPC / Hono RPCなど)は最初から候補に入りませんでした。OpenAPIを中継するアプローチの中から比較した結果は以下の通りです。
| 候補 | 不採用の理由 |
|---|---|
| tRPC / Hono RPC | バックエンドが Go のため、TypeScript の型共有が成立しない |
openapi-typescript + openapi-fetch | 型生成のみで Zod / MSW を別途用意する必要があり、フロント先行のセットアップコストが上がる |
| Kubb / Hey API | 機能的には近いが、operationId 単位の mutator override で公開/認証 API を生成時に分離できる Orval の運用実績を優先した |
Orval採用の最大の理由は、TanStack Query・MSW・Zod を併用するチームに必要なものが1コマンドで一括生成できる点でした。バックエンド完成前のフロント先行開発を前提としていたため、初期セットアップで手間が省けた効果は大きかったです。
技術スタック(API 関連のみ抜粋)
| カテゴリ | 採用技術 |
|---|---|
| Framework | Next.js 16 (App Router) / React 19 / TypeScript 5 |
| データ取得 | TanStack Query 5 |
| API クライアント生成 | Orval 7 |
| バリデーション | Zod 3 |
| モック | MSW 2 |
| 認証 | Better Auth |
| ランタイム | Node.js 24.11.1 |
| パッケージマネージャ | pnpm 9.15.9 |
生成パイプラインの全体像
flowchart TD
A["backend/.../openapi.yaml<br/>(make openapi)"]:::backend
A -->|コピー| B["src/openapi/openapi_gen.yaml"]:::frontend
B -->|pnpm run generate:api| C["src/gen/"]:::generated
subgraph G [生成物]
direction TB
M["models/ — 型定義"]
R["repository/ — TanStack Query"]
R --> T1["item.ts<br/>(useXxx / prefetchXxx)"]
R --> T2["item.zod.ts<br/>(URL/Body 検証 Zod)"]
R --> T3["item.msw.ts<br/>(MSW モック)"]
end
C --> M
C --> R
G -->|afterAllFilesWrite| F["biome check --write src/gen<br/>(自動整形)"]:::tool
classDef backend fill:#f59e0b,color:#fff,stroke:#b45309,stroke-width:2px
classDef frontend fill:#0ea5e9,color:#fff,stroke:#0369a1,stroke-width:2px
classDef generated fill:#10b981,color:#fff,stroke:#047857,stroke-width:2px
classDef tool fill:#8b5cf6,color:#fff,stroke:#6d28d9,stroke-width:2px
- 1コマンドで「型 / Hook / Zod / MSWモック / 整形」まで完結する
- バックエンド開発と並走でフロント先行開発が可能(MSWモック自動生成のため)
- 公開APIと認証APIで operationId 単位に mutator を切り替える
orval.config.ts の構造
orval.config.ts には appApi(TanStack Query 用)と appApiZod(Zod 用)の2つの export があります。同じYAMLから2系統の生成物を作るパターンです。
TanStack Query Hook と MSW モック (appApi)
| |
特に効くのは次の3つです。
mode: 'tags-split'— OpenAPIのtags単位でドメインフォルダが切られる(item/,user/,auth/など)generateEachHttpStatus: true—responsesに定義した4xx/5xxもモックされる(デフォルトは200のみ。未定義のステータスは生成されない)clean: true— operationIdのリネーム漏れによる「死んだHook」が残らない
Zod スキーマ (appApiZod)
| |
ここで効くのは次の2点です。
response: false— レスポンス検証をZodにやらせない(バックエンド契約信頼 + バンドル削減)coerce設定 —?page=1という文字列クエリを自動でnumberに強制変換する。Number('abc')がNaNになっても、z.coerce.number()がNaNを拒否するためsafeParse段階で弾ける
公開 API / 認証 API の分離 — operationId 単位の mutator 切り替え
前提: operationId とは
operationId はOpenAPI仕様で 各エンドポイント (path × HTTP メソッド) に付ける一意の識別子 です。
| |
Orvalはこの operationId を 生成される関数名や Hook 名にそのまま使います。
operationId: searchItems→useSearchItems()/searchItems()/prefetchSearchItemsQuery()operationId: getItemDetail→useGetItemDetail()/getItemDetail()/ …
省略するとpath + methodから useGetV1ItemsById のような長く読みにくい名前が自動生成されます。そのため、OpenAPI 側で operationId を明示するのが事実上の前提 です。
Orval の operations override
Orvalの operations overrideを使い、operationId ごとに mutator を差し替えています。
| |
customFetch(認証 API 用)
| |
customPublicFetch(公開 API 用)
| |
抜粋では customFetch 側が Error、customPublicFetch 側が ApiError を投げており、例外型が不揃いです。実プロジェクトではカスタム例外型(ApiError など)に統一し、上位のエラーハンドラがステータスコードで分岐できるようにしてください。
なぜこの設計が効くか
| 切り口 | 効能 |
|---|---|
| operations の手動リストで mutator を割り当て | spec の security と自動同期はされないが、orval.config.ts 1ファイルに割り当てを集約できる |
誤って customFetch が当たると即 throw | 未ログイン状態で No access token が即 throw され、「公開のはずなのに認証必須扱いになっている」事故をオンボード時に検出できる |
| operations を 1 ファイルで列挙 | レビューで「これ公開で大丈夫?」を一覧確認できる |
if (isLoggedIn) ... else ... を毎Hookで書く実装より、生成段階で分離してしまう ほうが簡潔に書けます。
生成物の構造(1 ドメイン 3 ファイル)
タグ単位で3種類のファイルが生成されます。
src/gen/repository/item/
├── item.ts # useXxx / prefetchXxx (TanStack Query hooks)
├── item.zod.ts # *QueryParams / *PathParams (Zod スキーマ)
└── item.msw.ts # getXxxResponseMock (faker でランダム値生成)
item.ts — Hooks
| |
生成される1セットは以下の通りです。
getXxxUrl— URL構築関数xxx— fetch関数useXxx—useQueryラッパprefetchXxxQuery—queryClient.prefetchQueryラッパ(SSRで重要、後述)useXxxInfinite— 無限スクロール対応(Orvalのoverride.query.useInfiniteを指定したエンドポイントのみ生成)
item.zod.ts — 検証スキーマ
| |
これを page.tsx 側で使います。
| |
item.msw.ts — MSW モック
| |
Zod の二段防御 — coerce × transform
URLパラメータの安全性は 「生成 Zod (coerce)」と「ページ側 Zod (transform)」の2層 で担保しています。
1 段目: Orval 生成の coerce
| |
?page=1 という文字列クエリが自動で number にキャストされます。型レベルで string | undefined が number に正規化されます。
2 段目: page.tsx 側の transform
| |
enum外の値が入っても500にならず、デフォルトタブで描画されます。
なぜこの組み合わせが強いか
- 1 段目 (coerce) で型キャスト失敗 (
'abc' → NaN) を排除 - 2 段目 (transform) で「想定外の文字列」を黙って正規化
- 結果として
page.tsxはunknownを引数に取らない — 渡ってくる時点で「正規化済みのSearchParams」と決まる
MSW モックの「自動生成 + 手動オーバーライド」二層戦略
OrvalはMSWハンドラを自動生成しますが、自動生成されたfakerベースのモックだけでは シナリオテスト ができません。そこで二層構造を取っています。
| |
役割分担
| 種類 | 用途 |
|---|---|
| 自動生成 (faker) | リスト系の動作確認、データ量や境界値のラフチェック |
| 手書きカスタム | バグ再現テスト、特定の displayId だけ違う挙動を返す等 |
| |
有効化制御
| |
環境変数 ENABLE_API_MOCKING=true で開発時のみ起動します。本番では実行されません(バンドルから完全に除去するには process.env.NODE_ENV 等のビルド時定数で分岐し、dead code eliminationが効く形にする必要があります)。
SSR Prefetch の並列実行 — Promise.all × HydrationBoundary
Orvalが prefetchXxxQuery 関数を自動生成するため、page.tsx で 複数 API を並列 prefetch → クライアントにキャッシュごと渡す パターンが楽に書けます。
| |
- 初期HTMLにJSONが埋め込まれるため、初期描画でキャッシュを即時利用できる
- ただし
staleTime未指定だとハイドレート直後にバックグラウンド再フェッチが走るので、SSR用途では明示推奨 - 6件を直列で取ると遅延が積み上がるが、
Promise.allで 最遅1件分の時間 に圧縮できる
SSR 経路では customFetch の 401/403 処理が走らない
前述の customFetch の401/403リダイレクトは typeof window !== 'undefined' でクライアント側に限定しています。SSRのprefetchはサーバー実行のため、prefetch中に認証エラーが起きてもこの処理は通りません。代わりにページ冒頭の getSession() → redirect('/') で未ログインを早期に遮断しています。SSR認証はゲート関数側、クライアント認証は customFetch 側で責務を分担する設計です。
Promise.all は fail-fast — 失敗を許容する設計に切り替える
上のコードはあえて Promise.all を使っており、1 件でも reject すると SSR レンダリング全体が失敗 します。1ブロックの取得失敗で初期表示を落としたくない場合は Promise.allSettled か個別 .catch を使い、失敗したクエリだけクライアント側で再フェッチさせます。
| |
どちらを選ぶかは「このページは全APIが揃って初めて成立するか / 一部失敗でもUIが描けるか」で決めます。ホームのように複数ブロックの寄せ集めなら後者、認証必須の集計画面のように1件でも欠けたら不整合になるなら前者が向いています。
API 更新フローと直編集禁止ルール
API 更新時
| |
| |
ファイル直編集禁止
src/gen/ 配下は 直接編集禁止 とし、.claude/rules/api.md で明文化しています。常にYAMLから再生成します。clean: true で死んだファイルが残らないため、operationIdのリネームも安全に行えます。
よくある詰まりどころ
pnpm run generate:api の実運用で踏みやすかった落とし穴を4つ挙げます。
1. YAML パースエラー
最も多いのは openapi.yaml のインデントずれ・タグ未定義です。OrvalはYAMLパーサ経由で読み込むため、エラー時には行番号付きでログが出ます(パーサ実装はバージョン依存のため、行番号の出方には多少の差があります)。
| |
対処は以下の2ステップです。
- 該当行のインデントを上下の階層に揃える
tagsを参照しているのに定義漏れがないかpathsとtagsを突き合わせる
2. operationId の重複
Orvalは同一 operationId を2箇所で使っているとビルド時に検出して停止します。Go側で複数のpathに同じIDを割り当てると起きやすい事象です。
| |
OpenAPI側で operationId を API単位で一意 になるよう修正します。make openapi を実行するチームと事前に命名規約を合意しておくと事故が減ります。
3. clean: true による生成物の消失
clean: true を有効にしていると、再生成時に gen/ 配下が一旦全削除 されます。誤って gen/ 配下に手書きユーティリティを置いてしまうと、次の再生成で消えます。
対処は以下の通りです。
gen/配下を 絶対に手で編集しない ルールを徹底する (.claude/rules/api.mdで明文化)- 共通ユーティリティは
src/lib/など別ディレクトリに置く
4. mutator のパス指定ミス
orval.config.ts の mutator.path は 設定ファイルからの相対パス で書きます。プロジェクトのモノレポ化・ディレクトリ移動の際に壊れやすいため、生成後に src/gen/repository/item/item.ts 冒頭のimport文を一度確認してください。
| |
tsconfig.paths aliasやdynamic import経由の場合、import先の誤りを 型解決・バンドル時に検出できず、ランタイムエラーで初めて発覚する ことがあります。原因追跡で時間を要するため注意してください。
まとめ
このプロジェクトの「型安全API統合」を支える5つの工夫を整理します。
- OpenAPI を Single Source of Truth —
make openapiのYAMLをフロントにコピーし、Orvalで型・Hook・Zod・MSWを一括生成 - 二重 Mutator 戦略 —
customFetchとcustomPublicFetchをoperationId単位で配置。specのsecurityと自動同期する仕組みではないが、誤割り当ては即throw+レビューで検出する - Zod の二段防御 —
coerceで型キャスト、transformで値正規化。unknownがコンポーネント層まで到達しない - MSW の「自動生成 + 手動オーバーライド」 — fakerでランダム生成しつつ、特定シナリオは手書きハンドラで上書き
- SSR Prefetch の並列実行 —
Promise.allで6件並列 +HydrationBoundaryで初期表示のAPIラウンドトリップが事実上ゼロ
pnpm run generate:api 1コマンドで「型・Hook・Zod・MSWモック・整形」まで終わるため、バックエンドのAPI変更にもフロントが素早く追従できる構造です。
