T-CREATOR

tRPC と GraphQL 徹底比較:設計自由度・型安全・オーバーフェッチの実態

tRPC と GraphQL 徹底比較:設計自由度・型安全・オーバーフェッチの実態

API 設計を検討する際、tRPC と GraphQL のどちらを選ぶべきか迷われる方は多いでしょう。特に「設計の自由度」「型安全性の実現方法」「オーバーフェッチ問題への対応」という 3 つの観点は、開発体験やアプリケーションのパフォーマンスに直結します。

本記事では、実際のコードや設計例を用いて tRPC と GraphQL を多角的に比較し、それぞれの強みと弱みを明らかにしていきます。また、どのような場面でどちらを選ぶべきかの判断基準もお伝えしますので、プロジェクトの技術選定にお役立てください。

背景

API 設計における共通の課題

現代の Web アプリケーション開発では、フロントエンドとバックエンドを明確に分離し、API を通じて連携させるアーキテクチャが主流です。しかし、従来の REST API には以下のような課題がありました。

REST API の 3 つの制約

#課題具体例
1エンドポイント管理の複雑化リソースごとに個別のエンドポイントが必要(​/​users​/​posts​/​comments など)
2型安全性の欠如フロントエンドとバックエンドで型定義が分離し、実行時エラーが発生しやすい
3オーバーフェッチ・アンダーフェッチ必要以上のデータを取得したり、複数のリクエストが必要になったりする

これらの課題を解決するために、GraphQL と tRPC という 2 つのアプローチが登場しました。

GraphQL の登場と特徴

GraphQL は Facebook(現 Meta)が 2015 年に公開したクエリ言語兼ランタイムです。単一のエンドポイントでクライアントが必要なデータを宣言的に取得できる点が最大の特徴といえるでしょう。

typescript// GraphQL クエリの例
// クライアント側で必要なフィールドを明示的に指定
const GET_USER = gql`
  query GetUser($id: ID!) {
    user(id: $id) {
      id
      name
      email
      posts {
        title
        createdAt
      }
    }
  }
`;

このクエリ構文により、クライアントは必要なデータだけを要求できます。

tRPC の登場と特徴

一方、tRPC は TypeScript エコシステムに特化した RPC(Remote Procedure Call)フレームワークとして 2020 年に登場しました。TypeScript の型システムをそのまま活用し、コード生成なしで完全な型安全性を実現する点が特徴です。

typescript// tRPC プロシージャの例
// TypeScript の型がそのままクライアントに伝播する
const userRouter = router({
  getById: publicProcedure
    .input(z.object({ id: z.string() }))
    .query(async ({ input }) => {
      return await db.user.findUnique({
        where: { id: input.id },
      });
    }),
});

以下の図は、REST API、GraphQL、tRPC のアーキテクチャの違いを示しています。

mermaidflowchart TB
  subgraph REST["REST API"]
    rest_client["クライアント"] -->|"GET /users/:id"| rest_api["API"]
    rest_client -->|"GET /posts?userId=:id"| rest_api
    rest_api -->|"レスポンス1"| rest_client
    rest_api -->|"レスポンス2"| rest_client
  end

  subgraph GraphQL["GraphQL"]
    gql_client["クライアント"] -->|"単一クエリで<br/>必要データを指定"| gql_api["API(単一エンドポイント)"]
    gql_api -->|"リクエスト通りの<br/>データのみ"| gql_client
  end

  subgraph tRPC["tRPC"]
    trpc_client["クライアント<br/>(型情報あり)"] -->|"型安全なプロシージャ<br/>呼び出し"| trpc_api["API<br/>(型情報共有)"]
    trpc_api -->|"型付きレスポンス"| trpc_client
  end

図で理解できる要点:

  • REST は複数エンドポイントで複数リクエストが必要
  • GraphQL は単一エンドポイントで必要なデータを宣言的に取得
  • tRPC は TypeScript の型システムで完全な型安全性を実現

課題

開発者が直面する 3 つの選択基準

tRPC と GraphQL のどちらを選ぶべきか判断する際、開発者は以下の 3 つの観点で悩むことが多いでしょう。

1. 設計自由度の違い

GraphQL はスキーマファーストの設計思想を持ち、独自の SDL(Schema Definition Language)でデータ構造を定義します。これにより柔軟なクエリが可能になる一方、学習コストや設計コストが高くなります。

一方、tRPC は TypeScript の既存の型システムをそのまま活用するため、追加の学習は最小限です。ただし、TypeScript プロジェクトに限定されるという制約があります。

2. 型安全性の実現方法

GraphQL の型安全性は、スキーマから TypeScript の型定義を生成するコード生成ツール(GraphQL Code Generator など)に依存します。このプロセスは自動化できますが、ビルドステップが増え、生成された型とスキーマの同期を保つ必要があります。

tRPC は TypeScript のコンパイラがそのまま型チェックを行うため、コード生成が不要です。サーバー側の型定義が自動的にクライアント側に伝播し、完全な End-to-End の型安全性が実現されます。

3. オーバーフェッチ問題への対応

オーバーフェッチとは、必要以上のデータを取得してしまう問題です。REST API では頻繁に発生しますが、GraphQL と tRPC ではどのように対処されているのでしょうか。

以下の図は、それぞれのアプローチにおけるデータ取得の流れを示しています。

mermaidsequenceDiagram
  participant Client as クライアント
  participant API as API
  participant DB as データベース

  Note over Client,DB: GraphQL のケース
  Client->>API: クエリで必要フィールド指定<br/>{user{id,name}}
  API->>DB: クエリに応じて<br/>必要データのみ取得
  DB-->>API: id, name のみ
  API-->>Client: 指定されたデータのみ

  Note over Client,DB: tRPC のケース
  Client->>API: プロシージャ呼び出し<br/>getUser({id})
  API->>DB: プロシージャ実装に応じて<br/>データ取得
  DB-->>API: 実装次第で全データor部分データ
  API-->>Client: プロシージャの戻り値

図で理解できる要点:

  • GraphQL はクライアントがクエリで必要なフィールドを指定できる
  • tRPC はプロシージャの実装次第でオーバーフェッチが発生する可能性がある

比較検証の必要性

これらの観点を理論だけでなく、実際のコード例を用いて検証することで、それぞれの技術の適用場面を明確にできます。次のセクションでは、各観点における具体的な解決策を見ていきましょう。

解決策

1. 設計自由度の比較

GraphQL の設計アプローチ

GraphQL ではスキーマファーストの設計が基本となります。まず SDL でスキーマを定義し、それに基づいてリゾルバーを実装する流れです。

スキーマ定義(SDL)

graphql# GraphQL スキーマの定義
type User {
  id: ID!
  name: String!
  email: String!
  posts: [Post!]!
}

type Post {
  id: ID!
  title: String!
  content: String!
  author: User!
  createdAt: DateTime!
}

この定義により、クライアントは柔軟にクエリを構築できます。

リゾルバーの実装

typescript// GraphQL リゾルバーの実装
const resolvers = {
  Query: {
    user: async (_parent, { id }, context) => {
      return await context.db.user.findUnique({
        where: { id },
      });
    },
  },
  User: {
    // フィールドリゾルバー:必要に応じて posts を取得
    posts: async (parent, _args, context) => {
      return await context.db.post.findMany({
        where: { authorId: parent.id },
      });
    },
  },
};

GraphQL では、各フィールドに対してリゾルバーを定義できるため、データの取得ロジックを柔軟に制御できます。

tRPC の設計アプローチ

tRPC では TypeScript の型定義をそのまま活用し、プロシージャとして API を定義します。

プロシージャ定義

typescript// tRPC ルーターの定義
import { z } from 'zod';
import { router, publicProcedure } from './trpc';

export const userRouter = router({
  // ユーザー取得プロシージャ
  getById: publicProcedure
    .input(z.object({ id: z.string() }))
    .query(async ({ input, ctx }) => {
      const user = await ctx.db.user.findUnique({
        where: { id: input.id },
      });
      return user;
    }),
});

型の自動推論

typescript// クライアント側の型は自動的に推論される
// コード生成は不要
import { trpc } from './trpc-client';

const user = await trpc.user.getById.query({ id: '123' });
// user の型は自動的に推論される
// TypeScript の補完も完全に機能する
console.log(user.name); // 型安全

設計自由度の比較表

観点GraphQLtRPC
スキーマ定義SDL(独自言語)TypeScript
学習コスト高い(SDL、リゾルバー、クエリ言語)低い(TypeScript の知識のみ)
クライアントの柔軟性非常に高い(任意のフィールド選択)中程度(プロシージャの戻り値に依存)
エコシステム多様(Apollo、Relay、Urql など)限定的(TypeScript 専用)
適用範囲言語非依存TypeScript プロジェクトのみ

結論: GraphQL は言語やクライアントを問わない柔軟性が魅力ですが、学習コストが高めです。tRPC は TypeScript に特化することで、シンプルさと開発効率を重視しています。

2. 型安全性の実現方法

GraphQL の型安全性

GraphQL で型安全性を実現するには、スキーマから TypeScript の型を生成するツールが必要です。

GraphQL Code Generator の設定

yaml# codegen.yml
# GraphQL スキーマから TypeScript 型を自動生成
schema: './src/schema.graphql'
generates:
  ./src/generated/graphql.ts:
    plugins:
      - typescript
      - typescript-operations
      - typescript-react-apollo

生成された型の利用

typescript// 生成された型をインポート
import {
  GetUserQuery,
  GetUserQueryVariables,
} from './generated/graphql';

// 型安全なクエリの実行
const { data } = await client.query<
  GetUserQuery,
  GetUserQueryVariables
>({
  query: GET_USER,
  variables: { id: '123' },
});

// data.user は型付けされている
console.log(data.user.name);

GraphQL では、スキーマ変更時に型を再生成する必要があるため、ビルドプロセスにステップが追加されます。

tRPC の型安全性

tRPC では TypeScript の型推論を直接活用するため、コード生成は不要です。

サーバー側の型定義

typescript// サーバー側でプロシージャを定義
export const appRouter = router({
  user: userRouter,
  post: postRouter,
});

// AppRouter 型をエクスポート
export type AppRouter = typeof appRouter;

クライアント側での型利用

typescript// クライアント側で AppRouter 型をインポート
import type { AppRouter } from './server/routers/_app';
import {
  createTRPCProxyClient,
  httpBatchLink,
} from '@trpc/client';

// 型安全なクライアントを作成
const client = createTRPCProxyClient<AppRouter>({
  links: [
    httpBatchLink({
      url: 'http://localhost:3000/api/trpc',
    }),
  ],
});

// 完全な型安全性と補完
const user = await client.user.getById.query({ id: '123' });
console.log(user.name); // 型エラーが即座に検出される

型安全性の比較表

観点GraphQLtRPC
型の生成必要(Code Generator)不要(型推論)
ビルドステップ増加する変化なし
型の同期スキーマと型の同期が必要自動的に同期される
リアルタイム型チェック生成後のみ常時(TypeScript コンパイラ)
型の精度高い非常に高い(End-to-End)

結論: GraphQL は型生成プロセスが必要ですが、tRPC は TypeScript の型システムをそのまま活用するため、追加のビルドステップなしで完全な型安全性が実現されます。

3. オーバーフェッチ問題への対応

GraphQL のアプローチ

GraphQL の最大の強みは、クライアントが必要なフィールドのみを指定できる点です。

必要なデータのみを取得

typescript// 名前とメールアドレスのみを取得するクエリ
const GET_USER_BASIC = gql`
  query GetUserBasic($id: ID!) {
    user(id: $id) {
      name
      email
    }
  }
`;
typescript// ユーザー情報と投稿一覧を同時に取得するクエリ
const GET_USER_WITH_POSTS = gql`
  query GetUserWithPosts($id: ID!) {
    user(id: $id) {
      name
      email
      posts {
        title
        createdAt
      }
    }
  }
`;

クライアントの要求に応じて、API は必要なデータのみを返します。

tRPC のアプローチ

tRPC では、プロシージャの実装次第でオーバーフェッチが発生する可能性があります。これを防ぐには、複数のプロシージャを用意するか、入力パラメータでオプションを制御します。

複数プロシージャで対応

typescript// 基本情報のみを取得するプロシージャ
export const userRouter = router({
  getBasic: publicProcedure
    .input(z.object({ id: z.string() }))
    .query(async ({ input, ctx }) => {
      return await ctx.db.user.findUnique({
        where: { id: input.id },
        select: { id: true, name: true, email: true },
      });
    }),
});
typescript// 投稿も含めて取得するプロシージャ
export const userRouter = router({
  getWithPosts: publicProcedure
    .input(z.object({ id: z.string() }))
    .query(async ({ input, ctx }) => {
      return await ctx.db.user.findUnique({
        where: { id: input.id },
        include: { posts: true },
      });
    }),
});

オプションパラメータで制御

typescript// オプションで取得データを制御するプロシージャ
export const userRouter = router({
  getById: publicProcedure
    .input(
      z.object({
        id: z.string(),
        includePosts: z.boolean().optional(),
      })
    )
    .query(async ({ input, ctx }) => {
      return await ctx.db.user.findUnique({
        where: { id: input.id },
        include: {
          posts: input.includePosts ?? false,
        },
      });
    }),
});

オーバーフェッチ対策の比較表

観点GraphQLtRPC
柔軟性非常に高い(任意のフィールド選択)中程度(プロシージャ設計次第)
実装コスト低い(クエリのみで制御)中程度(複数プロシージャまたはオプション)
パフォーマンス最適化容易(N+1 問題に注意)プロシージャ設計次第
学習コストクエリ言語の学習が必要TypeScript の知識で対応可能

結論: GraphQL はクライアント側で柔軟にデータ取得を制御できるため、オーバーフェッチ問題への対応が容易です。tRPC では、プロシージャの設計やオプションパラメータで対応する必要があります。

具体例

実践シナリオ:ブログアプリケーションの API 設計

ユーザー情報と投稿記事を管理するブログアプリケーションを想定し、GraphQL と tRPC の実装例を比較してみましょう。

要件定義

以下の機能を実装します:

  1. ユーザー情報の取得(基本情報のみ)
  2. ユーザー情報と投稿一覧の取得
  3. 投稿記事の作成
  4. 投稿記事の一覧取得(ページネーション付き)

以下の図は、両者のデータフロー比較を示しています。

mermaidflowchart LR
  subgraph GraphQL_Flow["GraphQL のデータフロー"]
    gql_client["クライアント"]
    gql_query["クエリ構築"]
    gql_api["GraphQL API"]
    gql_resolver["リゾルバー"]
    gql_db[("データベース")]

    gql_client -->|"1. クエリ定義"| gql_query
    gql_query -->|"2. 送信"| gql_api
    gql_api -->|"3. 解析"| gql_resolver
    gql_resolver -->|"4. データ取得"| gql_db
    gql_db -->|"5. 必要データのみ"| gql_resolver
    gql_resolver -->|"6. レスポンス"| gql_client
  end

  subgraph tRPC_Flow["tRPC のデータフロー"]
    trpc_client["クライアント<br/>(型付き)"]
    trpc_proc["プロシージャ呼び出し"]
    trpc_api["tRPC API"]
    trpc_handler["ハンドラー実装"]
    trpc_db[("データベース")]

    trpc_client -->|"1. プロシージャ指定"| trpc_proc
    trpc_proc -->|"2. 送信(型安全)"| trpc_api
    trpc_api -->|"3. 実行"| trpc_handler
    trpc_handler -->|"4. データ取得"| trpc_db
    trpc_db -->|"5. 実装次第のデータ"| trpc_handler
    trpc_handler -->|"6. 型付きレスポンス"| trpc_client
  end

図で理解できる要点:

  • GraphQL はクエリの解析とリゾルバーによる柔軟なデータ取得
  • tRPC は型安全なプロシージャ呼び出しとハンドラー実装による処理

GraphQL の実装例

スキーマ定義

graphql# GraphQL スキーマ
type User {
  id: ID!
  name: String!
  email: String!
  posts: [Post!]!
  createdAt: DateTime!
}

type Post {
  id: ID!
  title: String!
  content: String!
  author: User!
  createdAt: DateTime!
}
graphql# クエリとミューテーションの定義
type Query {
  user(id: ID!): User
  posts(limit: Int!, offset: Int!): PostConnection!
}

type Mutation {
  createPost(input: CreatePostInput!): Post!
}

input CreatePostInput {
  title: String!
  content: String!
  authorId: ID!
}

リゾルバー実装

typescript// ユーザー取得リゾルバー
const resolvers = {
  Query: {
    user: async (_parent, { id }, { db }) => {
      return await db.user.findUnique({
        where: { id },
      });
    },
  },
};
typescript// フィールドリゾルバー(投稿一覧)
const resolvers = {
  User: {
    posts: async (parent, _args, { db }) => {
      return await db.post.findMany({
        where: { authorId: parent.id },
      });
    },
  },
};
typescript// 投稿作成ミューテーション
const resolvers = {
  Mutation: {
    createPost: async (_parent, { input }, { db }) => {
      return await db.post.create({
        data: {
          title: input.title,
          content: input.content,
          authorId: input.authorId,
        },
      });
    },
  },
};

クライアント側の実装

typescript// ユーザー情報のみを取得
const { data } = await client.query({
  query: gql`
    query GetUser($id: ID!) {
      user(id: $id) {
        name
        email
      }
    }
  `,
  variables: { id: '123' },
});

console.log(data.user.name);
typescript// ユーザー情報と投稿一覧を同時に取得
const { data } = await client.query({
  query: gql`
    query GetUserWithPosts($id: ID!) {
      user(id: $id) {
        name
        email
        posts {
          title
          createdAt
        }
      }
    }
  `,
  variables: { id: '123' },
});

tRPC の実装例

ルーター定義

typescript// ユーザールーター
import { z } from 'zod';
import { router, publicProcedure } from './trpc';

export const userRouter = router({
  // 基本情報のみ取得
  getBasic: publicProcedure
    .input(z.object({ id: z.string() }))
    .query(async ({ input, ctx }) => {
      return await ctx.db.user.findUnique({
        where: { id: input.id },
        select: { id: true, name: true, email: true },
      });
    }),
});
typescript// 投稿も含めて取得
export const userRouter = router({
  getWithPosts: publicProcedure
    .input(z.object({ id: z.string() }))
    .query(async ({ input, ctx }) => {
      return await ctx.db.user.findUnique({
        where: { id: input.id },
        include: { posts: true },
      });
    }),
});
typescript// 投稿ルーター
export const postRouter = router({
  // 投稿一覧取得(ページネーション)
  list: publicProcedure
    .input(
      z.object({
        limit: z.number().min(1).max(100),
        offset: z.number().min(0),
      })
    )
    .query(async ({ input, ctx }) => {
      const posts = await ctx.db.post.findMany({
        take: input.limit,
        skip: input.offset,
      });
      return posts;
    }),
});
typescript// 投稿作成
export const postRouter = router({
  create: publicProcedure
    .input(
      z.object({
        title: z.string().min(1),
        content: z.string(),
        authorId: z.string(),
      })
    )
    .mutation(async ({ input, ctx }) => {
      return await ctx.db.post.create({
        data: input,
      });
    }),
});

クライアント側の実装

typescript// tRPC クライアントの設定
import type { AppRouter } from './server/routers/_app';
import {
  createTRPCProxyClient,
  httpBatchLink,
} from '@trpc/client';

const trpc = createTRPCProxyClient<AppRouter>({
  links: [
    httpBatchLink({
      url: 'http://localhost:3000/api/trpc',
    }),
  ],
});
typescript// ユーザー基本情報の取得(型安全)
const user = await trpc.user.getBasic.query({ id: '123' });
console.log(user.name); // 型推論が効く
typescript// ユーザー情報と投稿一覧の取得
const userWithPosts = await trpc.user.getWithPosts.query({
  id: '123',
});
console.log(userWithPosts.posts[0].title); // 完全な型安全性
typescript// 投稿の作成
const newPost = await trpc.post.create.mutate({
  title: 'tRPC 入門',
  content: 'tRPC の基本的な使い方を解説します',
  authorId: '123',
});
// newPost の型も自動的に推論される

実装比較のまとめ表

観点GraphQLtRPC
コード量多い(スキーマ + リゾルバー)少ない(プロシージャのみ)
型生成必要(codegen)不要(型推論)
データ取得の柔軟性高い(クエリで制御)中程度(プロシージャ設計次第)
学習コスト高い(SDL、クエリ言語)低い(TypeScript のみ)
開発効率中程度高い(型推論の恩恵)
パフォーマンス最適化が容易プロシージャ実装次第

まとめ

tRPC と GraphQL の選択基準

本記事では、tRPC と GraphQL を「設計自由度」「型安全性」「オーバーフェッチ対策」の 3 つの観点から徹底的に比較してまいりました。それぞれの技術には明確な強みと弱みがあり、プロジェクトの要件に応じて適切に選択することが重要です。

GraphQL を選ぶべき場面

以下の条件に当てはまる場合は GraphQL が適しています:

#条件理由
1複数のクライアント(Web、モバイルアプリ、サードパーティ)がある言語非依存で柔軟なクエリが可能
2クライアントごとに必要なデータが大きく異なる各クライアントが必要なフィールドのみを取得できる
3パブリック API を提供する予定があるスキーマベースで API 仕様が明確
4複雑なデータ関係を扱うリレーションシップの表現が得意
5エコシステムの豊富さを重視するApollo、Relay などの成熟したツールが利用可能

tRPC を選ぶべき場面

以下の条件に当てはまる場合は tRPC が適しています:

#条件理由
1フロントエンドとバックエンドが同一リポジトリにある型の共有が容易
2TypeScript プロジェクトである型推論の恩恵を最大限に受けられる
3開発スピードを最重視する学習コストが低く、コード生成不要
4チーム規模が小〜中規模であるシンプルな設計で保守しやすい
5社内向け API である柔軟性よりも型安全性と開発効率を優先できる

最終的な判断ポイント

両技術の選択は、以下の 3 つの質問で判断できるでしょう:

質問 1:クライアントの多様性

複数の異なるクライアント(Web、iOS、Android、サードパーティ)が存在する、または将来的に増える可能性がある場合は GraphQL が有利です。単一または少数のクライアントであれば tRPC で十分でしょう。

質問 2:型安全性へのこだわり

End-to-End の完全な型安全性をコード生成なしで実現したい場合は tRPC が最適です。型生成プロセスを許容できるなら GraphQL でも問題ありません。

質問 3:学習コストと開発効率

チームが TypeScript に精通しており、すぐに開発を始めたい場合は tRPC が適しています。一方、長期的な柔軟性や拡張性を重視し、学習コストを投資と考えられるなら GraphQL が向いています。

これからの展望

GraphQL は既に成熟した技術として広く採用されており、エコシステムも充実しています。一方、tRPC は比較的新しい技術ですが、TypeScript エコシステム内での採用が急速に進んでいます。

どちらの技術も REST API の課題を解決するための強力なツールです。プロジェクトの特性、チームのスキルセット、将来の拡張性を総合的に考慮し、最適な選択を行ってください。

関連リンク

tRPC 公式ドキュメント

GraphQL 公式ドキュメント

TypeScript リソース

比較記事・参考資料