Skip to content

パイプライン設計

🔄 パイプライン設計:データの型管理と画面接続のアーキテクチャ

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>
);
}

問題点:

  1. 型安全性がない: any型や型チェックなしで使用
  2. API変更の影響: APIのフィールド名(first_nameなど)が変更されると、UI全体が壊れる
  3. UI変更の影響: UIで使いたい形式(fullNameなど)に変更するため、APIを変更してしまう
  4. 保守性の低下: APIとUIが密結合で、変更が困難

データの型管理を3つの層に分けることで、APIとUIの責務を明確に分離できます。

層 (Layer)名称役割管理場所
MSW層Mock Service Worker開発・テスト環境でAPIをモックする。src/mocks/handlers.ts
Schema層API SchemaAPIのレスポンスそのものの形。Zodで定義。src/api/schema.ts
DTO層Data Transfer ObjectAPIの型を「UIで使いやすい形」に変換した型。src/types/user.ts
Form層Form SchemaRHFの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 “汎用的な実装パターン(ジェネリクス)”
src/api/utils/schemaUtils.ts
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型を使用した具体例)”
src/api/schema/userSchema.ts
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
);
}
src/api/schema/productSchema.ts
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
);
}

ベストプラクティス:

  1. unknownで受け取る: any型は使わず、必ずunknownで取得
  2. Zodでパース: .parse()で型チェック(失敗時はエラーを投げる)
  3. 安全なパース: エラー処理が必要な場合は.safeParse()を使用
  4. スキーマの共有: APIのスキーマ定義を一箇所にまとめる
  5. 汎用関数の活用: 同じパターンを繰り返す場合は、ジェネリクス関数を作成して再利用する

安全なパース(エラーハンドリング)

Section titled “安全なパース(エラーハンドリング)”
src/api/schema/userSchema.ts
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 “汎用的な実装パターン(ジェネリクス)”
src/utils/dtoUtils.ts
/**
* 汎用的な配列変換関数(ジェネリクス)
* @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型を使用した具体例)”
src/types/user.ts
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);
}
src/types/product.ts
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);
}

ベストプラクティス:

  1. 明確な変換: API型とDTO型の変換を明示的に行う
  2. 計算プロパティ: UIでよく使う値(fullNameformattedPriceなど)をDTO層で計算
  3. 型変換: 文字列の日付をDateオブジェクトに変換するなど、型変換も行う
  4. 一方向変換: API → DTOの変換のみを定義(逆変換は別途定義)
  5. 汎用関数の活用: 配列変換など、同じパターンを繰り返す場合は、ジェネリクス関数を作成して再利用する

役割: React Hook Form(RHF)で使用するフォームの型とバリデーションスキーマを定義する。

汎用的な実装パターン(ジェネリクス)

Section titled “汎用的な実装パターン(ジェネリクス)”
src/utils/formUtils.ts
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型を使用した具体例)”
src/components/UserForm/schema.ts
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
);
}
src/components/ProductForm/schema.ts
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へ配分”

役割: データを取得し、適切な層を経由してフォームに渡す。

Page層では、以下の流れでデータを処理します:

  1. API層: unknown → Strict Schema (Zodで洗浄)
  2. DTO層: API型 → UI型 (snake_case → camelCase, 計算プロパティ追加)
  3. Form層: DTO型 → Form型 (フォーム用の型に変換)

注意: フレームワーク特有の実装例については、以下を参照してください:

Action層: フォームデータをAPIリクエストに変換

Section titled “Action層: フォームデータをAPIリクエストに変換”

役割: フォームデータをAPIリクエスト形式に変換し、APIを呼び出す。

重要なポイント: 「逆方向」の洗浄

  • APIから受け取る時: unknownzod.parse() → 型安全なデータ
  • APIに送る時: フォームデータ → zod.parse() → 洗浄済みデータ → API送信

両方向でZodによる洗浄を行うことで、型安全性とデータの整合性を保証します。

汎用的な実装パターン(ジェネリクス)

Section titled “汎用的な実装パターン(ジェネリクス)”
src/utils/actionUtils.ts
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型を使用した具体例)”
src/actions/userActions.ts
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'
);
}
src/actions/productActions.ts
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 “🏁 まとめ:データフローの正解”
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層unknownTApiResponse (snake_case)APIレスポンスの型チェックfetchWithSchema<T>()
DTO層TApiResponseTDto (camelCase, Date, 計算プロパティ)UIで使いやすい形に変換convertArray<TApi, TDto>()
Form層TDtoTFormInputフォーム用の型とバリデーションvalidateFormToApiRequest<TForm, TApiRequest>()
Page層API層 → DTO層 → Form層-データフローの統括(フレームワーク依存)getFormInitialValues<TApi, TDto, TForm>() (Next.js), useFormData<TApi, TDto, TForm>() (React)
Action層TFormInputTApiResponseフォームデータをAPIリクエストに変換(フレームワーク依存)updateAction<TForm, TApiRequest, TApiResponse>(), createAction<TForm, TApiRequest, TApiResponse>()

注意: 表内のTは型パラメータ(ジェネリクス)を示しています。実際の使用時は、UserProductなどの具体的な型に置き換えます。

  1. MSWでのモック: 開発・テスト環境でAPIをモックし、実際のAPIサーバーに依存しない(詳細はMSW(モックサービスワーカー)を参照)
  2. 型安全性の確保: unknownで受け取り、Zodで型チェック
  3. 「逆方向」の洗浄: APIから受け取る時だけでなく、**APIに送る時もZodでパース(洗浄)**する。これにより、DTOからフォームに変換した際のゴミデータがAPIに飛ぶのを防ぐ
  4. 責務の分離: 各層の責務を明確に分ける
  5. 一方向データフロー: MSW → API → DTO → Formの流れを維持
  6. 変換関数の明示化: 型変換を明示的な関数で行う
  7. スキーマの共有: Zodスキーマを共有して、型とバリデーションを一元管理(MSW層でも使用)
  8. ジェネリクスの活用: 同じパターンを繰り返す場合は、ジェネリクス関数(fetchWithSchemaconvertArrayvalidateFormToApiRequestupdateActionなど)を作成して再利用する。これにより、コードの重複を減らし、保守性を向上させる
  1. API型をそのままUIで使用: snake_caseがUIに直接出てくる
  2. any型の使用: 型安全性が失われる
  3. 型チェックなしのAPI呼び出し: 実行時エラーが発生する可能性
  4. UI都合でAPIを変更: APIとUIが密結合になる
  5. 変換層の省略: 型変換を怠り、型不整合が発生する

このパイプライン設計を守ることで、APIとUIの責務が明確に分離され、保守性の高いアプリケーションを構築できます。


パイプライン設計の概念は共通ですが、実装はフレームワークによって異なります。各フレームワークでの実装例については、以下を参照してください: