パイプライン設計
🔄 パイプライン設計:データの型管理と画面接続のアーキテクチャ
Section titled “🔄 パイプライン設計:データの型管理と画面接続のアーキテクチャ”「API戦略」のさらに深いところ、つまり**「データの型をどのレイヤーで、誰が、どう管理し、どう画面に接続するか」**というアーキテクチャの話です。
実務で破綻する原因は、APIの型がそのままUIに流れてしまい、UIの都合でAPIが壊れる(あるいはその逆)ことです。これを防ぐための**「3つの層」と「RHFの接続戦略」**を解説します。
🚨 マサカリ:APIの型がそのままUIに流れてしまう
Section titled “🚨 マサカリ:APIの型がそのままUIに流れてしまう”❌ 悪い例: APIの型をそのままUIで使用
// ❌ 問題: APIレスポンスの型をそのままUIで使用// API呼び出しconst response = await fetch('https://api.example.com/users');const data = await response.json(); // unknown型、型チェックなし
// コンポーネントfunction UserList() { const [users, setUsers] = useState<any[]>([]); // any型で使用
useEffect(() => { fetch('/api/users') .then(res => res.json()) .then(data => setUsers(data)); // 型チェックなし }, []);
return ( <div> {users.map(user => ( <div key={user.id}> {user.first_name} {user.last_name} {/* snake_caseがそのままUIに */} </div> ))} </div> );}問題点:
- 型安全性がない:
any型や型チェックなしで使用 - API変更の影響: APIのフィールド名(
first_nameなど)が変更されると、UI全体が壊れる - UI変更の影響: UIで使いたい形式(
fullNameなど)に変更するため、APIを変更してしまう - 保守性の低下: APIとUIが密結合で、変更が困難
✅ 正解: 3つの層で責務を分離
Section titled “✅ 正解: 3つの層で責務を分離”データの型管理を3つの層に分けることで、APIとUIの責務を明確に分離できます。
型定義の3つのレイヤー
Section titled “型定義の3つのレイヤー”| 層 (Layer) | 名称 | 役割 | 管理場所 |
|---|---|---|---|
| MSW層 | Mock Service Worker | 開発・テスト環境でAPIをモックする。 | src/mocks/handlers.ts |
| Schema層 | API Schema | APIのレスポンスそのものの形。Zodで定義。 | src/api/schema.ts |
| DTO層 | Data Transfer Object | APIの型を「UIで使いやすい形」に変換した型。 | src/types/user.ts |
| Form層 | Form Schema | RHFのdefaultValuesやバリデーション用の型。 | src/components/UserForm/schema.ts |
注意: MSW層の詳細については、MSW(モックサービスワーカー)を参照してください。
1. Schema層: APIのレスポンス定義(Zodで洗浄)
Section titled “1. Schema層: APIのレスポンス定義(Zodで洗浄)”役割: APIから取得したデータを、unknown型として受け取り、Zodで型チェックして厳密な型に変換する。
重要なポイント: 必ずunknownで取得してからZodで型チェックする。
汎用的な実装パターン(ジェネリクス)
Section titled “汎用的な実装パターン(ジェネリクス)”import { z } from 'zod';import { ZodSchema } from 'zod';
/** * 汎用的なAPI呼び出し関数(ジェネリクス) * @param url - APIのエンドポイントURL * @param schema - Zodスキーマ(型チェック用) * @param options - fetchのオプション(method, headers, bodyなど) * @returns 型安全なデータ */export async function fetchWithSchema<T>( url: string, schema: ZodSchema<T>, options?: RequestInit): Promise<T> { const response = await fetch(url, options);
if (!response.ok) { throw new Error(`Failed to fetch: ${response.statusText}`); }
const data: unknown = await response.json(); // ✅ unknownで取得
// Zodで型チェック(パース失敗時はエラーを投げる) const validatedData = schema.parse(data);
return validatedData; // 型安全なデータを返す}
/** * 安全なパース(エラーハンドリング付き) * @param data - パース対象のデータ(unknown型) * @param schema - Zodスキーマ * @returns パース結果(成功時はdata、失敗時はエラー情報) */export function safeParseWithSchema<T>( data: unknown, schema: ZodSchema<T>): { success: true; data: T } | { success: false; error: z.ZodError } { const result = schema.safeParse(data);
if (!result.success) { // パースエラーの詳細をログに出力 console.error('Schema validation error:', { functionName: 'safeParseWithSchema', error: result.error, data: data, }); }
return result;}実装例(User型を使用した具体例)
Section titled “実装例(User型を使用した具体例)”import { z } from 'zod';import { fetchWithSchema } from '@/api/utils/schemaUtils';
// APIレスポンスのスキーマ定義(APIの実際の構造)export const userApiSchema = z.object({ id: z.number(), first_name: z.string(), // APIはsnake_case last_name: z.string(), email: z.string().email(), created_at: z.string(), // APIはISO文字列 updated_at: z.string(),});
// TypeScript型を推論export type UserApiResponse = z.infer<typeof userApiSchema>;
// API呼び出し関数(汎用関数を使用)export async function fetchUser(id: number): Promise<UserApiResponse> { return fetchWithSchema( `https://api.example.com/users/${id}`, userApiSchema );}
// 複数ユーザー取得export const usersApiSchema = z.array(userApiSchema);export type UsersApiResponse = z.infer<typeof usersApiSchema>;
export async function fetchUsers(): Promise<UsersApiResponse> { return fetchWithSchema( 'https://api.example.com/users', usersApiSchema );}他の型での使用例
Section titled “他の型での使用例”import { z } from 'zod';import { fetchWithSchema } from '@/api/utils/schemaUtils';
// 商品のAPIスキーマexport const productApiSchema = z.object({ id: z.number(), name: z.string(), price: z.number(), category_id: z.number(), created_at: z.string(),});
export type ProductApiResponse = z.infer<typeof productApiSchema>;
// 汎用関数を使用して商品を取得export async function fetchProduct(id: number): Promise<ProductApiResponse> { return fetchWithSchema( `https://api.example.com/products/${id}`, productApiSchema );}
// 複数商品取得export const productsApiSchema = z.array(productApiSchema);export type ProductsApiResponse = z.infer<typeof productsApiSchema>;
export async function fetchProducts(): Promise<ProductsApiResponse> { return fetchWithSchema( 'https://api.example.com/products', productsApiSchema );}ベストプラクティス:
unknownで受け取る:any型は使わず、必ずunknownで取得- Zodでパース:
.parse()で型チェック(失敗時はエラーを投げる) - 安全なパース: エラー処理が必要な場合は
.safeParse()を使用 - スキーマの共有: APIのスキーマ定義を一箇所にまとめる
- 汎用関数の活用: 同じパターンを繰り返す場合は、ジェネリクス関数を作成して再利用する
安全なパース(エラーハンドリング)
Section titled “安全なパース(エラーハンドリング)”import { safeParseWithSchema } from '@/api/utils/schemaUtils';
// エラー処理が必要な場合export async function fetchUserSafely(id: number) { const response = await fetch(`https://api.example.com/users/${id}`);
if (!response.ok) { throw new Error('Failed to fetch user'); }
const data: unknown = await response.json();
// 汎用関数を使用して安全にパース const result = safeParseWithSchema(data, userApiSchema);
if (!result.success) { throw new Error('Invalid API response format'); }
return result.data; // 型安全なデータ}2. DTO層: UIで使いやすい形に変換
Section titled “2. DTO層: UIで使いやすい形に変換”役割: APIの型(snake_caseなど)を、UIで使いやすい形(camelCase、計算プロパティなど)に変換する。
汎用的な実装パターン(ジェネリクス)
Section titled “汎用的な実装パターン(ジェネリクス)”/** * 汎用的な配列変換関数(ジェネリクス) * @param apiItems - API型の配列 * @param converter - 単一アイテムの変換関数 * @returns DTO型の配列 */export function convertArray<TApi, TDto>( apiItems: TApi[], converter: (apiItem: TApi) => TDto): TDto[] { return apiItems.map(converter);}実装例(User型を使用した具体例)
Section titled “実装例(User型を使用した具体例)”import { UserApiResponse } from '@/api/schema/userSchema';import { convertArray } from '@/utils/dtoUtils';
// DTO型定義(UIで使いやすい形)export type User = { id: number; firstName: string; // snake_case -> camelCase lastName: string; fullName: string; // 計算プロパティ(first + last) email: string; createdAt: Date; // 文字列 -> Dateオブジェクト updatedAt: Date;};
// API型からDTO型への変換関数export function userApiToDto(apiUser: UserApiResponse): User { return { id: apiUser.id, firstName: apiUser.first_name, // snake_case -> camelCase lastName: apiUser.last_name, fullName: `${apiUser.first_name} ${apiUser.last_name}`, // 計算プロパティ email: apiUser.email, createdAt: new Date(apiUser.created_at), // 文字列 -> Date updatedAt: new Date(apiUser.updated_at), };}
// 複数ユーザーの変換(汎用関数を使用)export function usersApiToDto(apiUsers: UserApiResponse[]): User[] { return convertArray(apiUsers, userApiToDto);}他の型での使用例
Section titled “他の型での使用例”import { ProductApiResponse } from '@/api/schema/productSchema';import { convertArray } from '@/utils/dtoUtils';
// 商品のDTO型定義export type Product = { id: number; name: string; price: number; formattedPrice: string; // 計算プロパティ(価格をフォーマット) categoryId: number; createdAt: Date;};
// API型からDTO型への変換関数export function productApiToDto(apiProduct: ProductApiResponse): Product { return { id: apiProduct.id, name: apiProduct.name, price: apiProduct.price, formattedPrice: `¥${apiProduct.price.toLocaleString()}`, // 計算プロパティ categoryId: apiProduct.category_id, // snake_case -> camelCase createdAt: new Date(apiProduct.created_at), // 文字列 -> Date };}
// 複数商品の変換(汎用関数を使用)export function productsApiToDto(apiProducts: ProductApiResponse[]): Product[] { return convertArray(apiProducts, productApiToDto);}ベストプラクティス:
- 明確な変換: API型とDTO型の変換を明示的に行う
- 計算プロパティ: UIでよく使う値(
fullName、formattedPriceなど)をDTO層で計算 - 型変換: 文字列の日付を
Dateオブジェクトに変換するなど、型変換も行う - 一方向変換: API → DTOの変換のみを定義(逆変換は別途定義)
- 汎用関数の活用: 配列変換など、同じパターンを繰り返す場合は、ジェネリクス関数を作成して再利用する
3. Form層: RHF用のスキーマ定義
Section titled “3. Form層: RHF用のスキーマ定義”役割: React Hook Form(RHF)で使用するフォームの型とバリデーションスキーマを定義する。
汎用的な実装パターン(ジェネリクス)
Section titled “汎用的な実装パターン(ジェネリクス)”import { z } from 'zod';import { ZodSchema } from 'zod';
/** * フォームデータをAPIリクエスト形式に変換し、Zodでバリデーション * @param formData - フォームデータ * @param converter - フォームデータをAPIリクエスト形式に変換する関数 * @param schema - APIリクエスト用のZodスキーマ * @returns バリデーション済みのAPIリクエストデータ */export function validateFormToApiRequest<TForm, TApiRequest>( formData: TForm, converter: (formData: TForm) => unknown, schema: ZodSchema<TApiRequest>): TApiRequest { const apiRequest = converter(formData); return schema.parse(apiRequest);}
/** * 安全なバリデーション(エラーハンドリング付き) * @param formData - フォームデータ * @param converter - フォームデータをAPIリクエスト形式に変換する関数 * @param schema - APIリクエスト用のZodスキーマ * @returns バリデーション結果 */export function safeValidateFormToApiRequest<TForm, TApiRequest>( formData: TForm, converter: (formData: TForm) => unknown, schema: ZodSchema<TApiRequest>): { success: true; data: TApiRequest } | { success: false; error: z.ZodError } { const apiRequest = converter(formData); return schema.safeParse(apiRequest);}実装例(User型を使用した具体例)
Section titled “実装例(User型を使用した具体例)”import { z } from 'zod';import { zodResolver } from '@hookform/resolvers/zod';import { User } from '@/types/user';import { validateFormToApiRequest } from '@/utils/formUtils';
// フォーム入力用のスキーマ(バリデーションルール)export const userFormSchema = z.object({ firstName: z.string().min(1, '名前は必須です').max(50, '名前は50文字以内で入力してください'), lastName: z.string().min(1, '姓は必須です').max(50, '姓は50文字以内で入力してください'), email: z.string().email('有効なメールアドレスを入力してください'),});
// TypeScript型を推論export type UserFormInput = z.infer<typeof userFormSchema>;
// 初期値(defaultValues用)export const defaultUserFormValues: UserFormInput = { firstName: '', lastName: '', email: '',};
// DTOからフォーム初期値への変換export function userDtoToFormInput(user: User): UserFormInput { return { firstName: user.firstName, lastName: user.lastName, email: user.email, };}
// フォーム送信用のスキーマ(APIに送る形に変換)export const createUserApiSchema = z.object({ first_name: z.string(), last_name: z.string(), email: z.string().email(),});
export type CreateUserApiRequest = z.infer<typeof createUserApiSchema>;
// フォーム入力からAPIリクエストへの変換export function userFormInputToApiRequest( formInput: UserFormInput): CreateUserApiRequest { return { first_name: formInput.firstName, // camelCase -> snake_case last_name: formInput.lastName, email: formInput.email, };}
// 汎用関数を使用したバリデーション付き変換export function validateUserFormInput( formInput: UserFormInput): CreateUserApiRequest { return validateFormToApiRequest( formInput, userFormInputToApiRequest, createUserApiSchema );}他の型での使用例
Section titled “他の型での使用例”import { z } from 'zod';import { Product } from '@/types/product';import { validateFormToApiRequest } from '@/utils/formUtils';
// 商品フォームのスキーマexport const productFormSchema = z.object({ name: z.string().min(1, '商品名は必須です').max(100, '商品名は100文字以内で入力してください'), price: z.number().min(0, '価格は0以上で入力してください'), categoryId: z.number().min(1, 'カテゴリを選択してください'),});
export type ProductFormInput = z.infer<typeof productFormSchema>;
// DTOからフォーム初期値への変換export function productDtoToFormInput(product: Product): ProductFormInput { return { name: product.name, price: product.price, categoryId: product.categoryId, };}
// APIリクエスト用のスキーマexport const createProductApiSchema = z.object({ name: z.string(), price: z.number(), category_id: z.number(),});
export type CreateProductApiRequest = z.infer<typeof createProductApiSchema>;
// フォーム入力からAPIリクエストへの変換export function productFormInputToApiRequest( formInput: ProductFormInput): CreateProductApiRequest { return { name: formInput.name, price: formInput.price, category_id: formInput.categoryId, // camelCase -> snake_case };}
// 汎用関数を使用したバリデーション付き変換export function validateProductFormInput( formInput: ProductFormInput): CreateProductApiRequest { return validateFormToApiRequest( formInput, productFormInputToApiRequest, createProductApiSchema );}4. Page層: 司令塔としてデータを取得し、Formへ配分
Section titled “4. Page層: 司令塔としてデータを取得し、Formへ配分”役割: データを取得し、適切な層を経由してフォームに渡す。
実装のポイント
Section titled “実装のポイント”Page層では、以下の流れでデータを処理します:
- API層:
unknown→ Strict Schema (Zodで洗浄) - DTO層: API型 → UI型 (snake_case → camelCase, 計算プロパティ追加)
- Form層: DTO型 → Form型 (フォーム用の型に変換)
注意: フレームワーク特有の実装例については、以下を参照してください:
Action層: フォームデータをAPIリクエストに変換
Section titled “Action層: フォームデータをAPIリクエストに変換”役割: フォームデータをAPIリクエスト形式に変換し、APIを呼び出す。
重要なポイント: 「逆方向」の洗浄
- APIから受け取る時:
unknown→zod.parse()→ 型安全なデータ - APIに送る時: フォームデータ →
zod.parse()→ 洗浄済みデータ → API送信
両方向でZodによる洗浄を行うことで、型安全性とデータの整合性を保証します。
汎用的な実装パターン(ジェネリクス)
Section titled “汎用的な実装パターン(ジェネリクス)”import { z } from 'zod';import { ZodSchema } from 'zod';import { fetchWithSchema } from '@/api/utils/schemaUtils';
/** * 汎用的な更新アクション(ジェネリクス) * @param url - APIのエンドポイントURL * @param formData - フォームデータ * @param converter - フォームデータをAPIリクエスト形式に変換する関数 * @param requestSchema - APIリクエスト用のZodスキーマ * @param responseSchema - APIレスポンス用のZodスキーマ * @param method - HTTPメソッド(デフォルト: 'PUT') * @returns バリデーション済みのAPIレスポンス */export async function updateAction<TForm, TApiRequest, TApiResponse>( url: string, formData: TForm, converter: (formData: TForm) => unknown, requestSchema: ZodSchema<TApiRequest>, responseSchema: ZodSchema<TApiResponse>, method: string = 'PUT'): Promise<TApiResponse> { // 1. フォームデータをAPIリクエスト形式に変換 const apiRequest = converter(formData);
// 2. ✅ 重要: APIに送るデータもZodでパース(洗浄)してから送信 // これにより、DTOからフォームに変換した際のゴミデータがAPIに飛ぶのを防ぐ const validatedRequest = requestSchema.parse(apiRequest);
// 3. APIを呼び出す(洗浄済みのデータを送信) const validatedResponse = await fetchWithSchema( url, responseSchema, { method, headers: { 'Content-Type': 'application/json', }, body: JSON.stringify(validatedRequest), // ✅ 洗浄済みデータ } );
return validatedResponse;}
/** * 汎用的な作成アクション(ジェネリクス) * @param url - APIのエンドポイントURL * @param formData - フォームデータ * @param converter - フォームデータをAPIリクエスト形式に変換する関数 * @param requestSchema - APIリクエスト用のZodスキーマ * @param responseSchema - APIレスポンス用のZodスキーマ * @returns バリデーション済みのAPIレスポンス */export async function createAction<TForm, TApiRequest, TApiResponse>( url: string, formData: TForm, converter: (formData: TForm) => unknown, requestSchema: ZodSchema<TApiRequest>, responseSchema: ZodSchema<TApiResponse>): Promise<TApiResponse> { return updateAction( url, formData, converter, requestSchema, responseSchema, 'POST' );}実装例(User型を使用した具体例)
Section titled “実装例(User型を使用した具体例)”import { updateAction } from '@/utils/actionUtils';import { userFormInputToApiRequest } from '@/components/UserForm/schema';import { createUserApiSchema } from '@/components/UserForm/schema';import { userApiSchema } from '@/api/schema/userSchema';import type { UserFormInput } from '@/components/UserForm/schema';import type { UserApiResponse } from '@/api/schema/userSchema';
// 汎用関数を使用した更新アクションexport async function updateUserAction( id: number, formData: UserFormInput): Promise<UserApiResponse> { return updateAction( `https://api.example.com/users/${id}`, formData, userFormInputToApiRequest, createUserApiSchema, userApiSchema, 'PUT' );}他の型での使用例
Section titled “他の型での使用例”import { createAction, updateAction } from '@/utils/actionUtils';import { productFormInputToApiRequest } from '@/components/ProductForm/schema';import { createProductApiSchema } from '@/components/ProductForm/schema';import { productApiSchema } from '@/api/schema/productSchema';import type { ProductFormInput } from '@/components/ProductForm/schema';import type { ProductApiResponse } from '@/api/schema/productSchema';
// 商品作成アクションexport async function createProductAction( formData: ProductFormInput): Promise<ProductApiResponse> { return createAction( 'https://api.example.com/products', formData, productFormInputToApiRequest, createProductApiSchema, productApiSchema );}
// 商品更新アクションexport async function updateProductAction( id: number, formData: ProductFormInput): Promise<ProductApiResponse> { return updateAction( `https://api.example.com/products/${id}`, formData, productFormInputToApiRequest, createProductApiSchema, productApiSchema, 'PUT' );}🏁 まとめ:データフローの正解
Section titled “🏁 まとめ:データフローの正解”データフローの全体像
Section titled “データフローの全体像”MSW層: 開発・テスト環境でAPIをモック(実際のAPIサーバーをバイパス) ↓API層: unknown -> Strict Schema (Zodで洗浄) ↓DTO層: API型 -> UI型 (snake_case -> camelCase, 計算プロパティ追加) ↓Page層: 司令塔としてデータを取得し、Formへ配分 ↓Form層: RHFとZodで「ユーザー入力」をバリデーションし、Actionを叩く ↓Action層: Form型 -> API型 (camelCase -> snake_case) -> API呼び出し ↓MSW層: 開発・テスト環境でモックレスポンスを返す| 層 | 入力 | 出力 | 責務 | 汎用関数の例 |
|---|---|---|---|---|
| MSW層 | HTTPリクエスト | モックレスポンス | 開発・テスト環境でAPIをモック | - |
| API層 | unknown | TApiResponse (snake_case) | APIレスポンスの型チェック | fetchWithSchema<T>() |
| DTO層 | TApiResponse | TDto (camelCase, Date, 計算プロパティ) | UIで使いやすい形に変換 | convertArray<TApi, TDto>() |
| Form層 | TDto | TFormInput | フォーム用の型とバリデーション | validateFormToApiRequest<TForm, TApiRequest>() |
| Page層 | API層 → DTO層 → Form層 | - | データフローの統括(フレームワーク依存) | getFormInitialValues<TApi, TDto, TForm>() (Next.js), useFormData<TApi, TDto, TForm>() (React) |
| Action層 | TFormInput | TApiResponse | フォームデータをAPIリクエストに変換(フレームワーク依存) | updateAction<TForm, TApiRequest, TApiResponse>(), createAction<TForm, TApiRequest, TApiResponse>() |
注意: 表内の
Tは型パラメータ(ジェネリクス)を示しています。実際の使用時は、User、Productなどの具体的な型に置き換えます。
ベストプラクティス
Section titled “ベストプラクティス”- MSWでのモック: 開発・テスト環境でAPIをモックし、実際のAPIサーバーに依存しない(詳細はMSW(モックサービスワーカー)を参照)
- 型安全性の確保:
unknownで受け取り、Zodで型チェック - 「逆方向」の洗浄: APIから受け取る時だけでなく、**APIに送る時もZodでパース(洗浄)**する。これにより、DTOからフォームに変換した際のゴミデータがAPIに飛ぶのを防ぐ
- 責務の分離: 各層の責務を明確に分ける
- 一方向データフロー: MSW → API → DTO → Formの流れを維持
- 変換関数の明示化: 型変換を明示的な関数で行う
- スキーマの共有: Zodスキーマを共有して、型とバリデーションを一元管理(MSW層でも使用)
- ジェネリクスの活用: 同じパターンを繰り返す場合は、ジェネリクス関数(
fetchWithSchema、convertArray、validateFormToApiRequest、updateActionなど)を作成して再利用する。これにより、コードの重複を減らし、保守性を向上させる
アンチパターン
Section titled “アンチパターン”- API型をそのままUIで使用: snake_caseがUIに直接出てくる
any型の使用: 型安全性が失われる- 型チェックなしのAPI呼び出し: 実行時エラーが発生する可能性
- UI都合でAPIを変更: APIとUIが密結合になる
- 変換層の省略: 型変換を怠り、型不整合が発生する
このパイプライン設計を守ることで、APIとUIの責務が明確に分離され、保守性の高いアプリケーションを構築できます。
フレームワーク別の実装例
Section titled “フレームワーク別の実装例”パイプライン設計の概念は共通ですが、実装はフレームワークによって異なります。各フレームワークでの実装例については、以下を参照してください:
- Next.jsでの実装例: Server Components、Server Actionsを使用した実装
- Reactでの実装例: useEffect、useState、React Hook Formを使用した実装