技術Tips
TypeScriptの型安全性を活かしたAPI設計
TypeScriptの型システムを活用して、実行時エラーを防ぎ、保守性の高いAPIを設計する方法を解説します。
佐藤 裕介
フロントエンドエンジニア
TypeScript API設計 型安全性 フロントエンド
はじめに
TypeScriptを使っているのに、APIレスポンスの型がanyになっていたり、実行時にエラーが発生したりしていませんか?この記事では、TypeScriptの型システムを最大限に活用し、型安全なAPI設計を実現する方法を紹介します。
問題: よくある型安全でないAPI実装
アンチパターン
// ❌ 型安全でない実装
async function fetchUser(id: string) {
const response = await fetch(`/api/users/${id}`);
const data = await response.json(); // any型
return data;
}
// 実行時にエラーになる可能性
const user = await fetchUser('123');
console.log(user.name.toUpperCase()); // userがnullだとエラー
この実装の問題点:
- レスポンスの型が
any - APIが失敗した場合の処理がない
- nullチェックがない
解決策1: 型定義の明確化
レスポンス型の定義
// ユーザー型の定義
interface User {
id: string;
name: string;
email: string;
createdAt: string;
}
// APIレスポンス型の定義
interface ApiResponse<T> {
data: T | null;
error: ApiError | null;
status: number;
}
interface ApiError {
code: string;
message: string;
}
型安全なfetch関数
async function fetchUser(id: string): Promise<ApiResponse<User>> {
try {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) {
return {
data: null,
error: {
code: 'FETCH_ERROR',
message: `HTTP Error: ${response.status}`,
},
status: response.status,
};
}
const data = await response.json();
return {
data,
error: null,
status: response.status,
};
} catch (error) {
return {
data: null,
error: {
code: 'NETWORK_ERROR',
message: error instanceof Error ? error.message : 'Unknown error',
},
status: 0,
};
}
}
// 使用例
const result = await fetchUser('123');
if (result.data) {
console.log(result.data.name.toUpperCase()); // 型安全
} else {
console.error(result.error?.message);
}
解決策2: zodによるランタイムバリデーション
TypeScriptの型チェックはコンパイル時のみ有効です。実行時にAPIレスポンスを検証するには、zodなどのバリデーションライブラリを使用します。
zodのセットアップ
import { z } from 'zod';
// zodスキーマの定義
const UserSchema = z.object({
id: z.string(),
name: z.string(),
email: z.string().email(),
createdAt: z.string().datetime(),
});
// TypeScript型を自動生成
type User = z.infer<typeof UserSchema>;
バリデーション付きfetch関数
async function fetchUserSafe(id: string): Promise<ApiResponse<User>> {
try {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) {
return {
data: null,
error: {
code: 'FETCH_ERROR',
message: `HTTP Error: ${response.status}`,
},
status: response.status,
};
}
const json = await response.json();
// zodでバリデーション
const parseResult = UserSchema.safeParse(json);
if (!parseResult.success) {
return {
data: null,
error: {
code: 'VALIDATION_ERROR',
message: parseResult.error.message,
},
status: response.status,
};
}
return {
data: parseResult.data,
error: null,
status: response.status,
};
} catch (error) {
return {
data: null,
error: {
code: 'NETWORK_ERROR',
message: error instanceof Error ? error.message : 'Unknown error',
},
status: 0,
};
}
}
解決策3: 汎用的なAPI クライアントの作成
複数のAPIエンドポイントで共通のロジックを使い回せるように、汎用的なAPIクライアントを作成します。
class ApiClient {
constructor(private baseUrl: string) {}
async fetch<T>(
path: string,
schema: z.ZodSchema<T>,
options?: RequestInit
): Promise<ApiResponse<T>> {
try {
const response = await fetch(`${this.baseUrl}${path}`, options);
if (!response.ok) {
return {
data: null,
error: {
code: 'FETCH_ERROR',
message: `HTTP Error: ${response.status}`,
},
status: response.status,
};
}
const json = await response.json();
const parseResult = schema.safeParse(json);
if (!parseResult.success) {
return {
data: null,
error: {
code: 'VALIDATION_ERROR',
message: parseResult.error.message,
},
status: response.status,
};
}
return {
data: parseResult.data,
error: null,
status: response.status,
};
} catch (error) {
return {
data: null,
error: {
code: 'NETWORK_ERROR',
message: error instanceof Error ? error.message : 'Unknown error',
},
status: 0,
};
}
}
}
// 使用例
const client = new ApiClient('https://api.example.com');
const userResult = await client.fetch('/users/123', UserSchema);
if (userResult.data) {
console.log(userResult.data.name);
}
解決策4: Result型の導入
エラーハンドリングをより明示的にするために、Result型を導入します。
type Result<T, E = Error> =
| { success: true; value: T }
| { success: false; error: E };
async function fetchUserResult(id: string): Promise<Result<User, ApiError>> {
const response = await fetchUserSafe(id);
if (response.data) {
return { success: true, value: response.data };
} else {
return { success: false, error: response.error! };
}
}
// 使用例
const result = await fetchUserResult('123');
if (result.success) {
console.log(result.value.name); // 型安全
} else {
console.error(result.error.message);
}
解決策5: tRPCによるエンドツーエンドの型安全性
フロントエンドとバックエンド間で完全な型安全性を実現したい場合は、tRPCの導入を検討しましょう。
バックエンド (Next.js API Routes)
import { initTRPC } from '@trpc/server';
import { z } from 'zod';
const t = initTRPC.create();
export const appRouter = t.router({
getUser: t.procedure
.input(z.object({ id: z.string() }))
.query(async ({ input }) => {
const user = await db.user.findUnique({
where: { id: input.id },
});
return user;
}),
});
export type AppRouter = typeof appRouter;
フロントエンド
import { createTRPCProxyClient, httpBatchLink } from '@trpc/client';
import type { AppRouter } from './server';
const client = createTRPCProxyClient<AppRouter>({
links: [
httpBatchLink({
url: 'http://localhost:3000/api/trpc',
}),
],
});
// 完全な型安全性
const user = await client.getUser.query({ id: '123' });
console.log(user.name); // 型が自動的に推論される
まとめ
TypeScriptの型安全性を活かしたAPI設計のポイント:
- 明確な型定義: APIレスポンスの型を明示的に定義する
- ランタイムバリデーション: zodなどでAPIレスポンスを検証する
- 汎用的なクライアント: 共通ロジックを抽出して再利用する
- エラーハンドリング: Result型などで明示的にエラーを扱う
- エンドツーエンドの型安全性: tRPCなどで完全な型安全性を実現する
これらの手法を組み合わせることで、実行時エラーを大幅に減らし、保守性の高いコードベースを構築できます。
参考リンク
著者について
佐藤 裕介
フロントエンドエンジニア