型安全なAPI連携
HonoXの最大の魅力の一つは、フロントエンドとバックエンドの境界を超えた型安全性です。TypeScriptの強力な型システムを活用して、APIの呼び出しから応答まで、完全に型安全なアプリケーションを構築する方法について学んでいきましょう。
型安全APIクライアントの基礎
APIクライアントの型定義
// app/lib/api/types.ts
// API全体の型定義を一元管理
// 共通レスポンス型
export interface ApiResponse<T> {
data: T
message?: string
timestamp: string
}
export interface PaginatedResponse<T> {
data: T[]
pagination: {
page: number
limit: number
total: number
hasNext: boolean
}
}
export interface ErrorResponse {
error: {
code: string
message: string
details?: any[]
}
timestamp: string
}
// エンティティ型
export interface User {
id: string
email: string
name: string
role: 'user' | 'admin'
createdAt: string
updatedAt: string
}
export interface Post {
id: string
title: string
content: string
excerpt: string
slug: string
authorId: string
author: User
tags: string[]
status: 'draft' | 'published'
publishedAt: string
createdAt: string
updatedAt: string
}
// リクエスト型
export interface CreatePostRequest {
title: string
content: string
excerpt?: string
tags?: string[]
status?: 'draft' | 'published'
}
export interface UpdatePostRequest {
title?: string
content?: string
excerpt?: string
tags?: string[]
status?: 'draft' | 'published'
}
型安全なAPIクライアント
// app/lib/api/client.ts
import type {
ApiResponse,
PaginatedResponse,
ErrorResponse,
Post,
CreatePostRequest,
UpdatePostRequest
} from './types'
class APIError extends Error {
constructor(
public statusCode: number,
public code: string,
message: string,
public details?: any[]
) {
super(message)
this.name = 'APIError'
}
}
class APIClient {
private baseUrl: string
private token?: string
constructor(baseUrl = '/api') {
this.baseUrl = baseUrl
}
setAuthToken(token: string) {
this.token = token
}
private async request<T>(
endpoint: string,
options: RequestInit = {}
): Promise<T> {
const url = `${this.baseUrl}${endpoint}`
const headers: HeadersInit = {
'Content-Type': 'application/json',
...options.headers,
}
if (this.token) {
headers.Authorization = `Bearer ${this.token}`
}
const response = await fetch(url, {
...options,
headers,
})
if (!response.ok) {
const error: ErrorResponse = await response.json()
throw new APIError(
response.status,
error.error.code,
error.error.message,
error.error.details
)
}
return response.json()
}
// Posts API
async getPosts(params?: {
page?: number
limit?: number
tag?: string
author?: string
}): Promise<PaginatedResponse<Post>> {
const searchParams = new URLSearchParams()
if (params?.page) searchParams.set('page', params.page.toString())
if (params?.limit) searchParams.set('limit', params.limit.toString())
if (params?.tag) searchParams.set('tag', params.tag)
if (params?.author) searchParams.set('author', params.author)
const endpoint = `/posts${searchParams.toString() ? `?${searchParams}` : ''}`
return this.request<PaginatedResponse<Post>>(endpoint)
}
async getPost(id: string): Promise<ApiResponse<Post>> {
return this.request<ApiResponse<Post>>(`/posts/${id}`)
}
async createPost(data: CreatePostRequest): Promise<ApiResponse<Post>> {
return this.request<ApiResponse<Post>>('/posts', {
method: 'POST',
body: JSON.stringify(data)
})
}
async updatePost(id: string, data: UpdatePostRequest): Promise<ApiResponse<Post>> {
return this.request<ApiResponse<Post>>(`/posts/${id}`, {
method: 'PUT',
body: JSON.stringify(data)
})
}
async deletePost(id: string): Promise<ApiResponse<{ message: string }>> {
return this.request<ApiResponse<{ message: string }>>(`/posts/${id}`, {
method: 'DELETE'
})
}
}
export const apiClient = new APIClient()
「型安全なAPIクライアントを使うことで、呼び出し時点でタイプミスや型の不一致を防げます。」
HonoRPCパターンの活用
RPC風のAPI設計
// app/lib/api/rpc.ts
import type { Context } from 'hono'
import type { Post, CreatePostRequest } from './types'
// サーバー側のハンドラー定義
export const postsHandlers = {
list: async (c: Context) => {
const page = parseInt(c.req.query('page') || '1')
const limit = parseInt(c.req.query('limit') || '10')
const posts = await getPostsWithPagination({ page, limit })
return c.json({
data: posts,
pagination: { page, limit, hasNext: posts.length === limit }
})
},
get: async (c: Context) => {
const id = c.req.param('id')
const post = await getPostById(id)
if (!post) {
return c.json({ error: 'Post not found' }, 404)
}
return c.json({ data: post })
},
create: async (c: Context) => {
const postData = await c.req.json<CreatePostRequest>()
const post = await createPost(postData)
return c.json({ data: post }, 201)
}
}
// 型推論のためのヘルパー型
export type PostsAPI = {
[K in keyof typeof postsHandlers]: typeof postsHandlers[K]
}
クライアント側での型安全な呼び出し
// app/lib/api/posts-client.ts
import type { PostsAPI } from './rpc'
type ExtractResponseType<T> = T extends (c: any) => Promise<Response>
? T extends (c: any) => Promise<infer R>
? R extends Response
? any // Response型から実際の戻り値型を抽出
: never
: never
: never
class PostsClient {
async list(params: { page?: number; limit?: number } = {}) {
const searchParams = new URLSearchParams()
if (params.page) searchParams.set('page', params.page.toString())
if (params.limit) searchParams.set('limit', params.limit.toString())
const response = await fetch(`/api/posts?${searchParams}`)
return response.json()
}
async get(id: string) {
const response = await fetch(`/api/posts/${id}`)
return response.json()
}
async create(data: CreatePostRequest) {
const response = await fetch('/api/posts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
})
return response.json()
}
}
export const postsClient = new PostsClient()
React Hooks との統合
カスタムAPIフック
// app/lib/hooks/usePosts.ts
import { useState, useEffect } from 'react'
import { apiClient } from '../api/client'
import type { Post, PaginatedResponse } from '../api/types'
export function usePosts(params?: {
page?: number
limit?: number
tag?: string
}) {
const [posts, setPosts] = useState<Post[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [pagination, setPagination] = useState({
page: 1,
limit: 10,
total: 0,
hasNext: false
})
useEffect(() => {
const fetchPosts = async () => {
try {
setLoading(true)
setError(null)
const response = await apiClient.getPosts(params)
setPosts(response.data)
setPagination(response.pagination)
} catch (err) {
if (err instanceof Error) {
setError(err.message)
} else {
setError('Failed to fetch posts')
}
} finally {
setLoading(false)
}
}
fetchPosts()
}, [params?.page, params?.limit, params?.tag])
return {
posts,
loading,
error,
pagination,
refetch: () => fetchPosts()
}
}
export function usePost(id: string) {
const [post, setPost] = useState<Post | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
const fetchPost = async () => {
try {
setLoading(true)
setError(null)
const response = await apiClient.getPost(id)
setPost(response.data)
} catch (err) {
if (err instanceof Error) {
setError(err.message)
} else {
setError('Failed to fetch post')
}
} finally {
setLoading(false)
}
}
if (id) {
fetchPost()
}
}, [id])
return { post, loading, error }
}
ミューテーション用フック
// app/lib/hooks/usePostMutations.ts
import { useState } from 'react'
import { apiClient } from '../api/client'
import type { CreatePostRequest, UpdatePostRequest, Post } from '../api/types'
export function useCreatePost() {
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const createPost = async (data: CreatePostRequest): Promise<Post | null> => {
try {
setLoading(true)
setError(null)
const response = await apiClient.createPost(data)
return response.data
} catch (err) {
if (err instanceof Error) {
setError(err.message)
} else {
setError('Failed to create post')
}
return null
} finally {
setLoading(false)
}
}
return {
createPost,
loading,
error
}
}
export function useUpdatePost() {
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const updatePost = async (id: string, data: UpdatePostRequest): Promise<Post | null> => {
try {
setLoading(true)
setError(null)
const response = await apiClient.updatePost(id, data)
return response.data
} catch (err) {
if (err instanceof Error) {
setError(err.message)
} else {
setError('Failed to update post')
}
return null
} finally {
setLoading(false)
}
}
return {
updatePost,
loading,
error
}
}
フォームとの型安全な統合
型安全なフォーム実装
// app/islands/forms/PostForm.tsx
import { useState } from 'react'
import { useCreatePost } from '../../lib/hooks/usePostMutations'
import type { CreatePostRequest } from '../../lib/api/types'
interface PostFormProps {
onSuccess?: (post: Post) => void
initialData?: Partial<CreatePostRequest>
}
export function PostForm({ onSuccess, initialData }: PostFormProps) {
const [formData, setFormData] = useState<CreatePostRequest>({
title: initialData?.title || '',
content: initialData?.content || '',
excerpt: initialData?.excerpt || '',
tags: initialData?.tags || [],
status: initialData?.status || 'draft'
})
const [validationErrors, setValidationErrors] = useState<
Partial<Record<keyof CreatePostRequest, string>>
>({})
const { createPost, loading, error } = useCreatePost()
const validateForm = (): boolean => {
const errors: Partial<Record<keyof CreatePostRequest, string>> = {}
if (!formData.title.trim()) {
errors.title = 'タイトルは必須です'
} else if (formData.title.length > 200) {
errors.title = 'タイトルは200文字以内で入力してください'
}
if (!formData.content.trim()) {
errors.content = '本文は必須です'
} else if (formData.content.length < 10) {
errors.content = '本文は10文字以上で入力してください'
}
if (formData.tags && formData.tags.length > 10) {
errors.tags = 'タグは10個まで追加できます'
}
setValidationErrors(errors)
return Object.keys(errors).length === 0
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!validateForm()) return
const post = await createPost(formData)
if (post && onSuccess) {
onSuccess(post)
}
}
const updateField = <K extends keyof CreatePostRequest>(
field: K,
value: CreatePostRequest[K]
) => {
setFormData(prev => ({ ...prev, [field]: value }))
// バリデーションエラーをクリア
if (validationErrors[field]) {
setValidationErrors(prev => ({ ...prev, [field]: undefined }))
}
}
return (
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label htmlFor="title" className="block text-sm font-medium text-gray-700">
タイトル
</label>
<input
type="text"
id="title"
value={formData.title}
onChange={(e) => updateField('title', e.target.value)}
className={`mt-1 block w-full rounded-md border-gray-300 shadow-sm ${
validationErrors.title ? 'border-red-500' : ''
}`}
/>
{validationErrors.title && (
<p className="mt-1 text-sm text-red-600">{validationErrors.title}</p>
)}
</div>
<div>
<label htmlFor="content" className="block text-sm font-medium text-gray-700">
本文
</label>
<textarea
id="content"
rows={10}
value={formData.content}
onChange={(e) => updateField('content', e.target.value)}
className={`mt-1 block w-full rounded-md border-gray-300 shadow-sm ${
validationErrors.content ? 'border-red-500' : ''
}`}
/>
{validationErrors.content && (
<p className="mt-1 text-sm text-red-600">{validationErrors.content}</p>
)}
</div>
<div>
<label htmlFor="excerpt" className="block text-sm font-medium text-gray-700">
抜粋(任意)
</label>
<textarea
id="excerpt"
rows={3}
value={formData.excerpt}
onChange={(e) => updateField('excerpt', e.target.value)}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">
ステータス
</label>
<select
value={formData.status}
onChange={(e) => updateField('status', e.target.value as 'draft' | 'published')}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm"
>
<option value="draft">下書き</option>
<option value="published">公開</option>
</select>
</div>
{error && (
<div className="text-red-600 text-sm">{error}</div>
)}
<button
type="submit"
disabled={loading}
className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50"
>
{loading ? '保存中...' : '投稿を保存'}
</button>
</form>
)
}
リアルタイム通信での型安全性
WebSocketクライアントの型安全実装
// app/lib/websocket/types.ts
export interface WebSocketMessage<T = any> {
type: string
data: T
timestamp: string
}
export interface ChatMessage {
id: string
content: string
authorId: string
authorName: string
createdAt: string
}
export type WebSocketMessageMap = {
'chat_message': ChatMessage
'user_joined': { userId: string; userName: string }
'user_left': { userId: string; userName: string }
'typing': { userId: string; userName: string; isTyping: boolean }
}
// app/lib/websocket/client.ts
export class TypeSafeWebSocketClient {
private ws: WebSocket | null = null
private listeners: Map<string, Set<(data: any) => void>> = new Map()
connect(url: string) {
this.ws = new WebSocket(url)
this.ws.onmessage = (event) => {
try {
const message: WebSocketMessage = JSON.parse(event.data)
this.handleMessage(message)
} catch (error) {
console.error('Failed to parse WebSocket message:', error)
}
}
}
private handleMessage(message: WebSocketMessage) {
const listeners = this.listeners.get(message.type)
if (listeners) {
listeners.forEach(listener => listener(message.data))
}
}
on<K extends keyof WebSocketMessageMap>(
type: K,
listener: (data: WebSocketMessageMap[K]) => void
) {
if (!this.listeners.has(type)) {
this.listeners.set(type, new Set())
}
this.listeners.get(type)!.add(listener)
}
off<K extends keyof WebSocketMessageMap>(
type: K,
listener: (data: WebSocketMessageMap[K]) => void
) {
const listeners = this.listeners.get(type)
if (listeners) {
listeners.delete(listener)
}
}
send<K extends keyof WebSocketMessageMap>(
type: K,
data: WebSocketMessageMap[K]
) {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
const message: WebSocketMessage<WebSocketMessageMap[K]> = {
type,
data,
timestamp: new Date().toISOString()
}
this.ws.send(JSON.stringify(message))
}
}
}
エラーハンドリングの型安全性
型安全なエラー処理
// app/lib/errors/types.ts
export type APIErrorCode =
| 'VALIDATION_ERROR'
| 'NOT_FOUND'
| 'UNAUTHORIZED'
| 'FORBIDDEN'
| 'INTERNAL_ERROR'
| 'NETWORK_ERROR'
export interface APIErrorDetails {
field?: string
message: string
code?: string
}
export class TypedAPIError extends Error {
constructor(
public readonly statusCode: number,
public readonly code: APIErrorCode,
message: string,
public readonly details?: APIErrorDetails[]
) {
super(message)
this.name = 'TypedAPIError'
}
isValidationError(): this is TypedAPIError & { code: 'VALIDATION_ERROR' } {
return this.code === 'VALIDATION_ERROR'
}
isNotFoundError(): this is TypedAPIError & { code: 'NOT_FOUND' } {
return this.code === 'NOT_FOUND'
}
isUnauthorizedError(): this is TypedAPIError & { code: 'UNAUTHORIZED' } {
return this.code === 'UNAUTHORIZED'
}
}
// app/lib/hooks/useErrorHandler.ts
export function useErrorHandler() {
const handleError = (error: unknown) => {
if (error instanceof TypedAPIError) {
if (error.isValidationError()) {
// バリデーションエラーの場合の処理
return {
type: 'validation',
message: error.message,
details: error.details
}
} else if (error.isUnauthorizedError()) {
// 認証エラーの場合の処理
return {
type: 'auth',
message: '認証が必要です'
}
} else if (error.isNotFoundError()) {
// 404エラーの場合の処理
return {
type: 'not_found',
message: 'リソースが見つかりません'
}
}
}
// その他のエラー
return {
type: 'unknown',
message: '予期しないエラーが発生しました'
}
}
return { handleError }
}
やってみよう!
型安全なAPI連携を実践してみましょう:
-
完全型安全なCRUD操作
- 型定義からクライアント作成
- React Hooks統合
- エラーハンドリング
-
リアルタイム機能
- WebSocket通信の型安全実装
- メッセージ型の管理
-
フォーム統合
- バリデーション付きフォーム
- 型安全な入力処理
ポイント
- エンドツーエンド型安全性:APIからUIまで一貫した型定義
- 開発時エラー検出:コンパイル時の型チェックでバグを防止
- 自動補完:IDEでの強力な開発支援
- リファクタリング安全性:型に基づく安全な変更
- ドキュメント効果:型定義が仕様書の役割を果たす