はじめに

こんにちは。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 関連のみ抜粋)

カテゴリ採用技術
FrameworkNext.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)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
appApi: {
  input: {
    target: './src/openapi/openapi_gen.yaml',
    filters: {
      mode: 'exclude',
      tags: ['external', 'experimental'],  // 外部連携などフロント不要のスキーマは除外
    },
  },
  output: {
    target: './src/gen/repository',
    schemas: './src/gen/models',
    client: 'react-query',
    httpClient: 'fetch',
    mode: 'tags-split',           // タグ単位でファイル分割
    clean: true,                  // 再生成時に既存ファイルをクリア
    mock: {
      type: 'msw',
      useExamples: true,
      generateEachHttpStatus: true,  // 4xx/5xx のモックも生成
    },
    override: {
      mutator: {
        path: './src/lib/custom-fetch.ts',
        name: 'customFetch',
      },
      operations: { /* 後述 */ },
    },
  },
  hooks: {
    afterAllFilesWrite: 'biome check --write src/gen',
  },
},

特に効くのは次の3つです。

  • mode: 'tags-split' — OpenAPIの tags 単位でドメインフォルダが切られる(item/, user/, auth/ など)
  • generateEachHttpStatus: trueresponses に定義した4xx/5xxもモックされる(デフォルトは200のみ。未定義のステータスは生成されない)
  • clean: true — operationIdのリネーム漏れによる「死んだHook」が残らない

Zod スキーマ (appApiZod)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
appApiZod: {
  output: {
    fileExtension: '.zod.ts',
    client: 'zod',
    override: {
      zod: {
        generate: {
          param: true,    // PathParams 検証
          query: true,    // QueryParams 検証
          header: true,
          body: true,
          response: false, // レスポンスは型定義に任せて Zod は不要
        },
        coerce: {
          param: ['string', 'number', 'boolean', 'bigint', 'date'],
          query: ['string', 'number', 'boolean', 'bigint', 'date'],
        },
      },
    },
  },
},

ここで効くのは次の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 メソッド) に付ける一意の識別子 です。

1
2
3
4
5
6
7
8
# openapi.yaml の例
paths:
  /v1/items:
    get:
      operationId: searchItems   # ← これ
  /v1/items/{id}:
    get:
      operationId: getItemDetail

Orvalはこの operationId生成される関数名や Hook 名にそのまま使います

  • operationId: searchItemsuseSearchItems() / searchItems() / prefetchSearchItemsQuery()
  • operationId: getItemDetailuseGetItemDetail() / getItemDetail() / …

省略するとpath + methodから useGetV1ItemsById のような長く読みにくい名前が自動生成されます。そのため、OpenAPI 側で operationId を明示するのが事実上の前提 です。

Orval の operations override

Orvalの operations overrideを使い、operationId ごとに mutator を差し替えています

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
override: {
  // デフォルト
  mutator: {
    path: './src/lib/custom-fetch.ts',
    name: 'customFetch',
  },
  // 公開エンドポイントのみ別 mutator
  operations: {
    healthCheck:     { mutator: { path: './src/lib/custom-public-fetch.ts', name: 'customPublicFetch' } },
    register:        { mutator: { path: './src/lib/custom-public-fetch.ts', name: 'customPublicFetch' } },
    searchItems:   { mutator: { path: './src/lib/custom-public-fetch.ts', name: 'customPublicFetch' } },
    getItemDetail: { mutator: { path: './src/lib/custom-public-fetch.ts', name: 'customPublicFetch' } },
    // ... 計 25 個以上
  },
},

customFetch(認証 API 用)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// src/lib/custom-fetch.ts(抜粋)
export const customFetch = async <T>(url: string, options: RequestInit): Promise<T> => {
  const token = await getAccessToken();
  if (!token) throw new Error('No access token');  // 未ログイン状態のリクエストを即時失敗

  const response = await fetch(getUrl(url), {
    ...options,
    headers: { ...options.headers, Authorization: `Bearer ${token}` },
  });

  // 401: 認証切れ → signOut + /signin
  if (response.status === 401 && typeof window !== 'undefined') {
    await signOut();
    throw new Error('Unauthorized: redirecting to sign-in');
  }

  // 403: ボディの code を見てアカウント停止判定
  if (response.status === 403 && typeof window !== 'undefined') {
    const errorBody = await response.clone().json();
    if (errorBody?.code === ACCOUNT_SUSPENDED_CODE) {
      clearTokenCache();
      getGlobalQueryClient()?.clear();
      window.location.href = ACCOUNT_SUSPENDED_PATH;
    }
  }
  // ...
};

customPublicFetch(公開 API 用)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// src/lib/custom-public-fetch.ts(抜粋)
export const customPublicFetch = async <T>(url: string, options: RequestInit): Promise<T> => {
  const token = await getAccessToken();
  const response = await fetch(getUrl(url), {
    ...options,
    headers: {
      ...options.headers,
      ...(token ? { Authorization: `Bearer ${token}` } : {}),  // あれば付ける、なくてもリクエストは続行
    },
  });

  if (!response.ok) throw new ApiError(response.status, response.statusText);
  return await response.json();
};

抜粋では customFetch 側が ErrorcustomPublicFetch 側が 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
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
// 生成例(抜粋)
export const getSearchItemsUrl = (params?: SearchItemsParams): string => { /* ... */ };

export const searchItems = async (params?: SearchItemsParams, options?: RequestInit) => {
  return customPublicFetch<ItemSearchResponse>(getSearchItemsUrl(params), {
    method: 'GET',
    ...options,
  });
};

export const useSearchItems = (
  params?: SearchItemsParams,
  options?: { query?: UseQueryOptions<...> },
): UseQueryResult<ItemSearchResponse> => { /* useQuery で包む */ };

export const prefetchSearchItemsQuery = async (
  queryClient: QueryClient,
  params?: SearchItemsParams,
): Promise<QueryClient> => { /* queryClient.prefetchQuery */ };

生成される1セットは以下の通りです。

  • getXxxUrl — URL構築関数
  • xxx — fetch関数
  • useXxxuseQuery ラッパ
  • prefetchXxxQueryqueryClient.prefetchQuery ラッパ(SSRで重要、後述)
  • useXxxInfinite — 無限スクロール対応(Orvalの override.query.useInfinite を指定したエンドポイントのみ生成)

item.zod.ts — 検証スキーマ

1
2
3
4
5
6
7
export const searchItemsQueryParams = zod.object({
  keyword: zod.coerce.string().optional(),
  page: zod.coerce.number().min(1).default(1),
  per_page: zod.coerce.number().min(1).max(100).default(20),
  status: zod.array(zod.enum(['ACTIVE', 'INACTIVE'])).optional(),
  order: zod.enum(['NEWEST', 'POPULAR', 'PRICE_ASC']).optional(),
});

これを page.tsx 側で使います。

1
2
const result = searchItemsQueryParams.safeParse(rawSearchParams);
if (!result.success) handleZodValidationError(result.error);

item.msw.ts — MSW モック

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
export const getSearchItemsResponseMock = (
  overrideResponse: Partial<ItemSearchResponse> = {}
): ItemSearchResponse => ({
  items: Array.from(
    { length: faker.number.int({ min: 1, max: 10 }) },
    () => ({
      id: faker.number.int(),
      name: faker.string.alpha({ length: { min: 10, max: 20 } }),
      // ...
    })
  ),
  ...overrideResponse,
});

export const getSearchItemsMockHandler = (overrideResponse?: ...) =>
  http.get('*/v1/items', () => HttpResponse.json(getSearchItemsResponseMock(overrideResponse)));

Zod の二段防御 — coerce × transform

URLパラメータの安全性は 「生成 Zod (coerce)」と「ページ側 Zod (transform)」の2層 で担保しています。

1 段目: Orval 生成の coerce

1
2
// item.zod.ts(自動生成)
page: zod.coerce.number().min(1).default(1),

?page=1 という文字列クエリが自動で number にキャストされます。型レベルで string | undefinednumber に正規化されます。

2 段目: page.tsx 側の transform

1
2
3
4
5
6
7
// src/app/ranking/page.tsx(抜粋)
const searchParamsSchema = z.object({
  tab: z.string().optional().transform(val => {
    if (val && TAB_VALUES.includes(val as TabValue)) return val as TabValue;
    return DEFAULT_TAB;  // 無効値はデフォルトに正規化
  }),
});

enum外の値が入っても500にならず、デフォルトタブで描画されます。

なぜこの組み合わせが強いか

  • 1 段目 (coerce) で型キャスト失敗 ('abc' → NaN) を排除
  • 2 段目 (transform) で「想定外の文字列」を黙って正規化
  • 結果として page.tsxunknown を引数に取らない — 渡ってくる時点で「正規化済みのSearchParams」と決まる

MSW モックの「自動生成 + 手動オーバーライド」二層戦略

OrvalはMSWハンドラを自動生成しますが、自動生成されたfakerベースのモックだけでは シナリオテスト ができません。そこで二層構造を取っています。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// src/mocks/handlers.ts
const orvalHandlers: RequestHandler[] = [
  ...getItemMock(),       // 自動生成
  ...getUserMock(),
  ...getAnnouncementMock(),
  // ...
];

const originalHandlers: RequestHandler[] = [
  customGetCategoriesHandler,  // 手書きオーバーライド
  customGetAnnouncementsHandler,
  // ...
];

// MSW は「先にマッチしたハンドラ」を使うため、original を先頭に置く
export const handlers: RequestHandler[] = [...originalHandlers, ...orvalHandlers];

役割分担

種類用途
自動生成 (faker)リスト系の動作確認、データ量や境界値のラフチェック
手書きカスタムバグ再現テスト、特定の displayId だけ違う挙動を返す等
1
2
3
4
5
6
7
8
// 手書きハンドラの例
export const customItemDetailHandler = http.get(
  `*/v1/items/:displayId`,
  async ({ params }) => {
    const { displayId } = params;
    return HttpResponse.json(mockData[displayId] ?? defaultMock);
  }
);

有効化制御

1
2
3
4
5
// src/app/msw-provider.tsx
if (env.ENABLE_API_MOCKING) {
  const { worker } = await import('@/mocks/browser');
  await worker.start({ onUnhandledRequest: 'bypass' });
}

環境変数 ENABLE_API_MOCKING=true で開発時のみ起動します。本番では実行されません(バンドルから完全に除去するには process.env.NODE_ENV 等のビルド時定数で分岐し、dead code eliminationが効く形にする必要があります)。


SSR Prefetch の並列実行 — Promise.all × HydrationBoundary

Orvalが prefetchXxxQuery 関数を自動生成するため、page.tsx複数 API を並列 prefetch → クライアントにキャッシュごと渡す パターンが楽に書けます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// src/app/home/page.tsx
export default async function Page() {
  const session = await auth.api.getSession({ headers: await headers() });
  if (!session) redirect('/');

  const queryClient = new QueryClient();

  // 6 件を並列 prefetch
  await Promise.all([
    prefetchGetUserMeQuery(queryClient),
    prefetchGetFavoriteItemsQuery(queryClient),
    prefetchGetItemsByRankingQuery(queryClient, RankingType.TOTAL),
    prefetchGetItemsByRankingQuery(queryClient, RankingType.SATISFACTION),
    prefetchGetNewArrivalItemsQuery(queryClient),
    prefetchSearchItemsQuery(queryClient, { status: ACTIVE_TAB_STATUSES }),
  ]);

  return (
    <HydrationBoundary state={dehydrate(queryClient)}>
      <Home />
    </HydrationBoundary>
  );
}
  • 初期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 を使い、失敗したクエリだけクライアント側で再フェッチさせます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// 失敗を許容したい場合
await Promise.allSettled([
  prefetchGetUserMeQuery(queryClient),
  prefetchGetFavoriteItemsQuery(queryClient),
  prefetchGetItemsByRankingQuery(queryClient, RankingType.TOTAL),
  // ...
]);
// もしくは個別 .catch
await Promise.all([
  prefetchGetUserMeQuery(queryClient).catch(() => {}),
  // ...
]);

どちらを選ぶかは「このページは全APIが揃って初めて成立するか / 一部失敗でもUIが描けるか」で決めます。ホームのように複数ブロックの寄せ集めなら後者、認証必須の集計画面のように1件でも欠けたら不整合になるなら前者が向いています。


API 更新フローと直編集禁止ルール

API 更新時

1
2
3
4
5
6
7
# バックエンド側
cd ../backend
make openapi  # backend 側で openapi.yaml を生成

# フロント側
cp ../backend/.../openapi.yaml ./src/openapi/openapi_gen.yaml
pnpm run generate:api
1
2
3
4
// package.json
"scripts": {
  "generate:api": "orval --config ./orval.config.ts"
}

ファイル直編集禁止

src/gen/ 配下は 直接編集禁止 とし、.claude/rules/api.md で明文化しています。常にYAMLから再生成します。clean: true で死んだファイルが残らないため、operationIdのリネームも安全に行えます。


よくある詰まりどころ

pnpm run generate:api の実運用で踏みやすかった落とし穴を4つ挙げます。

1. YAML パースエラー

最も多いのは openapi.yaml のインデントずれ・タグ未定義です。OrvalはYAMLパーサ経由で読み込むため、エラー時には行番号付きでログが出ます(パーサ実装はバージョン依存のため、行番号の出方には多少の差があります)。

1
2
$ pnpm run generate:api
# 例: YAMLException: bad indentation at line 124, column 5

対処は以下の2ステップです。

  • 該当行のインデントを上下の階層に揃える
  • tags を参照しているのに定義漏れがないか pathstags を突き合わせる

2. operationId の重複

Orvalは同一 operationId を2箇所で使っているとビルド時に検出して停止します。Go側で複数のpathに同じIDを割り当てると起きやすい事象です。

1
# 例: Error: Operation ID "searchItems" is duplicated

OpenAPI側で operationIdAPI単位で一意 になるよう修正します。make openapi を実行するチームと事前に命名規約を合意しておくと事故が減ります。

3. clean: true による生成物の消失

clean: true を有効にしていると、再生成時に gen/ 配下が一旦全削除 されます。誤って gen/ 配下に手書きユーティリティを置いてしまうと、次の再生成で消えます。

対処は以下の通りです。

  • gen/ 配下を 絶対に手で編集しない ルールを徹底する (.claude/rules/api.md で明文化)
  • 共通ユーティリティは src/lib/ など別ディレクトリに置く

4. mutator のパス指定ミス

orval.config.tsmutator.path設定ファイルからの相対パス で書きます。プロジェクトのモノレポ化・ディレクトリ移動の際に壊れやすいため、生成後に src/gen/repository/item/item.ts 冒頭のimport文を一度確認してください。

1
2
// 期待通り: 相対パスで src/lib/custom-fetch を import
import { customFetch } from '../../../lib/custom-fetch';

tsconfig.paths aliasやdynamic import経由の場合、import先の誤りを 型解決・バンドル時に検出できず、ランタイムエラーで初めて発覚する ことがあります。原因追跡で時間を要するため注意してください。


まとめ

このプロジェクトの「型安全API統合」を支える5つの工夫を整理します。

  1. OpenAPI を Single Source of Truthmake openapi のYAMLをフロントにコピーし、Orvalで型・Hook・Zod・MSWを一括生成
  2. 二重 Mutator 戦略customFetchcustomPublicFetch をoperationId単位で配置。specの security と自動同期する仕組みではないが、誤割り当ては即throw+レビューで検出する
  3. Zod の二段防御coerce で型キャスト、transform で値正規化。unknown がコンポーネント層まで到達しない
  4. MSW の「自動生成 + 手動オーバーライド」 — fakerでランダム生成しつつ、特定シナリオは手書きハンドラで上書き
  5. SSR Prefetch の並列実行Promise.all で6件並列 + HydrationBoundary で初期表示のAPIラウンドトリップが事実上ゼロ

pnpm run generate:api 1コマンドで「型・Hook・Zod・MSWモック・整形」まで終わるため、バックエンドのAPI変更にもフロントが素早く追従できる構造です。

参考リンク