🐥

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