🐥
Next.js + Laravel構成でのAPIレスポンスのCRUD-ableなTypeScript型設計パターン
はじめに
Next.js(フロントエンド)とLaravel(バックエンド)構成で、
- CREATE時にはidなしの型定義をしたい
- UPDATE時にはidありで、required、optional、excludedの型を管理したい
- READ時には、APIによってリレーションが異なるので柔軟に対応したい
というニーズがあり、TypeScriptの型設計を工夫しました。
前提条件
- Next.js(TypeScript)
- Laravel API
サンプルドメイン
記事では以下のシンプルなブログシステムを例に説明します:
- Post(投稿):記事のメイン情報
- Comment(コメント):投稿に対するコメント
- User(ユーザー):投稿者・コメント者
ディレクトリ構造
lib/
├── types/
│ ├── base.ts # 基盤型(Model、Create、Update)
features/ # feature-driven構造
├──posts/
│ ├── components/
│ ├── hooks/
│ ├── types/
│ └── post.ts
│ └── api/
├──comments/
│ ├── components/
│ ├── hooks/
│ ├── types/
│ └── comment.ts
│ └── api/
...
lib/base.ts 基盤となる型定義
まず、すべてのDBエンティティに共通する基盤型を定義します。
// lib/types/base.ts
export interface Model {
id: number;
createdAt: string;
updatedAt: string;
}
export type Create<T extends Model> = Omit<T, keyof Model>;
export type Update<
T extends Model,
Required extends keyof T = never,
Optional extends keyof T = never
> = Pick<T, 'id' | Required> & Partial<Pick<T, Optional>>;
この基盤型により:
- すべてのモデルが統一されたID・タイムスタンプフィールドを持つ
- Create型は自動でID・タイムスタンプを除外
- Update型は必須/オプショナルフィールドを柔軟に指定可能
features/ モデル定義
実際のドメインモデルを1ファイル1エンティティで定義します。
Post(投稿)
// post.ts
import { Model, Create, Update } from './base';
export interface Post extends Model {
title: string;
content: string;
userId: number;
published: boolean;
}
// 更新時のフィールド分類
export namespace UpdateFields {
export type Required = 'title'; // タイトルは必須
export type Optional = 'content' | 'published'; // 内容・公開状態はオプショナル
export type Excluded = 'userId'; // 投稿者は変更不可
}
// CRUD型
export type CreateInput = Create<Post>;
export type UpdateInput = Update<Post, UpdateFields.Required, UpdateFields.Optional>;
// リレーション
export type Relations = {
user: import('./user').User;
comments: import('./comment').Comment[];
};
export type With<T extends keyof Relations = never> =
Post & Pick<Relations, T>;
Comment(コメント)
// comment.ts
import { Model, Create, Update } from './base';
export interface Comment extends Model {
postId: number;
userId: number;
content: string;
}
export namespace UpdateFields {
export type Required = never; // 全フィールドオプショナル
export type Optional = 'content'; // 内容のみ更新可能
export type Excluded = 'postId' | 'userId'; // 投稿先・投稿者は変更不可
}
export type CreateInput = Create<Comment>;
export type UpdateInput = Update<Comment, UpdateFields.Required, UpdateFields.Optional>;
export type Relations = {
post: import('./post').Post;
user: import('./user').User;
};
export type With<T extends keyof Relations = never> =
Comment & Pick<Relations, T>;
User(ユーザー)
// user.ts
import { Model, Create, Update } from './base';
export interface User extends Model {
name: string;
email: string;
}
export namespace UpdateFields {
export type Required = 'name'; // 名前は必須
export type Optional = 'email'; // メールはオプショナル
export type Excluded = never; // 全フィールド更新可能
}
export type CreateInput = Create<User>;
export type UpdateInput = Update<User, UpdateFields.Required, UpdateFields.Optional>;
export type Relations = {
posts: import('./post').Post[];
comments: import('./comment').Comment[];
};
export type With<T extends keyof Relations = never> =
User & Pick<Relations, T>;
型の使用例
// 作成用
const newPost: CreateInput = {
title: "記事タイトル",
content: "記事本文",
userId: 1,
published: true
// id, createdAt, updatedAt は不要
};
// 更新用(titleは必須、contentとpublishedはオプショナル)
const updatePost: UpdateInput = {
id: 1, // 必須
title: "更新後のタイトル", // 必須
content: "更新後の内容" // オプショナル
// userId は含まれない(Excluded)
};
// コメント更新(全フィールドオプショナル)
const updateComment: UpdateInput = {
id: 1, // 必須
content: "更新後のコメント" // オプショナル
// postId, userId は含まれない(Excluded)
};
API層での活用
各エンティティの型をAPI層で活用します。
// posts/api/posts.ts
import { Post, CreateInput, UpdateInput, With, Relations } from '@/features/post/types/post';
export async function createPost(data: CreateInput): Promise<Post> {
const response = await fetch('/api/posts', {
method: 'POST',
body: JSON.stringify(data),
});
return response.json();
}
export async function updatePost(data: UpdateInput): Promise<Post> {
const { id, ...updateData } = data;
const response = await fetch(`/api/posts/${id}`, {
method: 'PUT',
body: JSON.stringify(updateData),
});
return response.json();
}
// 動的リレーションの活用
export async function getPost<T extends keyof Relations>(
id: string,
include?: T[]
): Promise<With<T[number]>> {
const params = new URLSearchParams();
if (include?.length) {
params.append('include', include.join(','));
}
const response = await fetch(`/api/posts/${id}?${params}`);
return response.json();
}
使用例
// 投稿のみ
const post = await getPost('1');
// 型: Post
// 投稿+ユーザー
const postWithUser = await getPost('1', ['user']);
// 型: With<'user'>
// postWithUser.user が型安全にアクセス可能
// 投稿+コメント+ユーザー
const postWithAll = await getPost('1', ['comments', 'user']);
// 型: With<'comments' | 'user'>
// postWithAll.comments と postWithAll.user が型安全にアクセス可能
実践例:ブログ投稿詳細ページ
// posts/hooks/usePostDetail.ts
import { useQuery } from '@tanstack/react-query';
import { getPost } from '../api/posts';
import { Relations } from '@/features/post/types/post';
export function usePostDetail<T extends keyof Relations>(
id: string,
include?: T[]
) {
return useQuery({
queryKey: ['post', id, include],
queryFn: () => getPost(id, include),
});
}
// posts/components/PostDetail.tsx
import { With } from '@/features/post/types/post';
interface Props {
post: With<'comments' | 'user'>;
}
export function PostDetail({ post }: Props) {
return (
<article>
<h1>{post.title}</h1>
<p>著者: {post.user.name}</p>
<div>{post.content}</div>
<section>
<h2>コメント ({post.comments.length}件)</h2>
{post.comments.map(comment => (
<div key={comment.id}>
<p>{comment.content}</p>
<small>{comment.createdAt.toLocaleDateString()}</small>
</div>
))}
</section>
</article>
);
}
// pages/posts/[id].tsx
import { usePostDetail } from '@/posts/hooks/usePostDetail';
export default function PostDetailPage({ params }: { params: { id: string } }) {
const { data: post, isLoading } = usePostDetail(params.id, ['comments', 'user']);
if (isLoading) return <div>Loading...</div>;
if (!post) return <div>Post not found</div>;
return <PostDetail post={post} />;
}
まとめ
以上の設計で、次のことが達成されました。
- 型安全性: コンパイル時にAPIレスポンスの型チェックが可能
- 開発効率: IDEの補完機能が充実し、型の重複定義なし
- 保守性: 1ファイル1エンティティで管理がシンプル
- 柔軟性: 必要なリレーションのみを動的に取得可能
- Laravel互換性: バックエンドのDBスキーマと整合性を保持
Laravel migration => type への変換にAIを使えばより効率的になります。
Discussion