2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Zodios や tRPC で型安全な開発体験を実現する

Last updated at Posted at 2024-09-25

はじめに

私はフロントエンドの実務経験が2年足らずでまだまだ中堅を名乗るには程遠いレベルのエンジニアです。しかし、最初の配属先のプロジェクトから Next.js や TypeScript のコードをバリバリ書かせてもらえたり、現在所属しているプロジェクトも開発ドキュメントが豊富かつ扱うデータのほとんどに型チェックが効いていて型安全に開発できる仕組みが整備されていたりと、ありがたいことにモダンな技術や開発フローに触れる機会には恵まれていると感じます。

今回は、Zodios や tRPC で型安全な開発体験を実現する方法について初学者向けの記事があまり多くないと感じたので、自分なりにまとめてみることにしました。

想定読者

  • Next.js や TypeScript の開発を多少は経験しており、基本的なことはわかってきたけど難しい概念や実装テクニックの話にはついていけない人
  • 型安全なアプリケーション開発と聞いてなんとなくわかるけど、他の人にわかりやすく説明できるほどは言語化する自信がない人

前提

「型安全な開発体験」の定義と本記事のゴール

当記事における型安全な開発体験とは、コードの実行前にデータの型や構造を確認できる仕組みにより、バグを未然に防ぎつつ、「XXは期待通りの型となっているか?」などを考える必要もなく安心してコードが書ける ということです。Next.js や TypeScript で開発を進めるという前提ではありますが、本記事を読むことで tRPC、Zodios を使ってどのように型安全な開発体験を実現できるのかを1人でも多くの人に理解していただけると幸いです。

技術スタック

参考程度に、現在のプロジェクトで使用している主なフレームワーク・ライブラリは以下の通りです。

  • Next.js (pages router)
  • TypeScript
  • Zod
  • Zodios
  • tRPC
  • TanStack Query
  • Jest
  • React Testing Library
  • MSW
  • Storybook
  • etc...

「型安全な開発体験」を実現するために

これらが全てというわけではありませんが、現在のプロジェクトでは主に以下2つのライブラリをうまく活用することで「型安全な開発体験」を実現できているように思います。

  • Zodios
  • tRPC

上から順に説明していきます。

Zodiosとは?

Zod というTypeScriptと親和性の高いスキーマ定義・バリデーション用のライブラリを活用することで、必要な型情報を保持したAPIクライアントを簡単に生成できるのが Zodios です。Zodは作成したスキーマからTypeScriptの型定義を生成する機能を備えており、Zodiosはその型定義を利用しながらAPIリクエストを送信できます。

補足
当記事の趣旨から脱線しそうなので、Zodについてもっと深く知りたい方は公式ドキュメントTypeScriptのゾッとする話 ~ Zodの紹介 ~をご参照いただくのがよいかと思いますmm

使い方

OpenAPI仕様に準拠したドキュメントをインプットにAPIクライアントを生成

OpenAPIドキュメントをインプットにしてAPIクライアントを自動生成するためのサードパーティライブラリとして openapi-zod-client公式ドキュメントで紹介されており、使うのは非常に簡単です。OpenAPI仕様に準拠した json ファイルまたは yamlファイルを用意してそのパスを第一引数に渡し、--output に出力先のパスを渡して以下コマンドを実行するだけです。

npx openapi-zod-client http://example.com/openapi.json --output ./src/apiClient.ts

すると以下のようなAPIクライアントが生成できて、あとは利用側で {Zodiosで生成したAPIクライアント}.get('{/api/xxxのようなpath}', { params: ... }) のようにAPIエンドポイントを定義すれば、リクエストやレスポンスの型を意識せずともAPIクライアントが型安全かチェックしてくれるようになります。

apiClient.ts
import { z } from "zod";
import { makeApi, makeErrors } from "@zodios/core";


export const userApi = makeApi([
  {
    method: "get",
    path: "/users/:id",
    alias: "getUser",
    description: "対象1件のユーザー情報を取得する",
    response: user,
    errors: errors,
  },
  {
    ...
  },
]);

export const user = z.object({
  id: z.number(),
  name: z.string(),
  age: z.number(),
});

// レスポンス「user」の型を自動生成してくれて、利用側でimportして使える
export type User = z.infer<typeof user>;

export const errors = makeErrors([
  {
    status: 404,
    description: "指定されたidのユーザーが存在しない",
    schema: z.object({
      error: z.object({
        code: z.string(),
        message: z.string(),
      }),
    }),
  },
  {
    ...
  },
]);

Zodiosの良いところ

API呼び出しの実装時にTypeScriptコンパイラの静的型チェックが効いて型安全にコードが書けるのも嬉しいですし、なによりリクエストパラメータの指定やレスポンスの取得部分で自動補完が効くようになるのでコーディング速度の上昇やタイポ防止にも繋がります。

注意
Zodiosという名称からも推測できるように、APIクライアントの中身の機能としては axios が組み込まれています。Zodiosを使用する = axios に依存するということを留意しておきましょう。

このように、Zodiosを利用することでOpenAPIドキュメントを基に生成された型情報をフロントエンド側で使いまわせるようになり、APIの定義箇所と利用箇所の型のズレを未然に防ぎつつ、快適な開発体験を実現することができます。

tRPCとは?

次に、 tRPC(t = TypeScript, RPC = Remote Procedure Call)とは TypeScriptで型安全に利用できるAPIを実装できるツールです。 tRPCを使うと、サーバー側で決めた型定義を使ってクライアント側でAPIコールできるようになるため、バックエンドとフロントエンドにおける型情報のズレに悩まされる必要がなくなります。

使い方

具体的なコード例を確認していく前に、以下の2つの概念だけサラッと頭に入れておきましょう。

  • router
    • 複数のprocedureを束ねたAPIのルートを定義するもの
  • procedure
    • 実際にAPIコールを行う処理
    • どういったリクエストを引数として受け取り、どういったレスポンスを返すか指定できる

ちなみに、tRPCの使い方を理解するためにこれから説明するファイル群は、現在所属しているプロジェクトでは以下のような構成で配置しています。ご参考になれば幸いです。

.
├── src 
│   ├── pages 
│   │   ├── api               # API routes(APIエンドポイントを格納するディレクトリ)
│   │   │   ├── [..]          # tRPC以外のAPIエンドポイント
│   │   │   └── trpc           
│   │   │       └── [trpc].api.ts # tRPCのエンドポイントとして/server/routers/_app.tsを指定
│   │   └── [..]
│   ├── server                 
│   │   ├── routers           # tRPCのrouterを格納するディレクトリ
│   │   │   ├── _app.ts       # 親router(appRouter)
│   │   │   └── users
│   │   │   │   ├── users.ts   # 子router(appRouter.usersRouter)
│   │   │   │   └── [..]           
│   │   ├── context.ts        # APIコール時のコンテキスト生成
│   │   ├── trpc.ts           # tRPCの初期化・インスタンス生成やrouter、procedureを用意
│   │   └── [..] 
│   └── lib
│       ├── shared   
│       │   ├── trpc.ts       # tRPCクライアントをNextで使うためのインスタンス生成
│       │   └── [..]
│       └── [..]
└── [..]

tRPCの初期化・インスタンス生成やrouter、procedureの用意

まずは下準備として、必要なパッケージを npm i @trpc/server @trpc/client @trpc/react-query @trpc/next zod @tanstack/react-query でインストールし、tRPCの初期化・インスタンス生成やrouter、procedureを用意します。

src/server/trpc.ts
import { initTRPC } from "@trpc/server";
// 後述するcreateContextを利用する場合は以下も必要
// import { inferAsyncReturnType } from "@trpc/server";
// import { type Context } from "../context"

// tRPCの初期化・インスタンス生成
const t = initTRPC.create();
// 後述するcreateContextを利用する場合は以下も必要
// const t = initTRPC.context<Context>().create();

// router, procedureを他ファイルで利用できるようにエクスポート
export const router = t.router;
export const publicProcedure = t.procedure;

tRPCで router( procedure 含む) を定義

例えば、以下のようなusersRouterを定義して、

src/server/routers/users/index.ts
import { router, publicProcedure } from "../../trpc"
import { z } from "zod"
import nanoid from "nanoid";

type User = {
  id: string
  name: string
  age: number
}

const USERS: User[] = [
  { id: "1", name: "Taro", age: 27 },
  { id: "2", name: "Jiro", age: 34 },
]

// usersRouterを定義
export const usersRouter = router({
  // idを受け取り、一致するidを持つuserを返すprocedure
  getUserById: publicProcedure
    // zodで引数の型を指定
    .input(z.string())
    // query()はGETメソッドで呼び出される
    .query(req => {
      return USERS.find(user => user.id === req.input)
  }),
  // name, ageを受け取り、userを登録するprocedure
  createUser: publicProcedure
    // zodで引数の型を指定
    .input(z.object({ name: z.string(), age: z.number() }))
    // mutation()はPOSTメソッドで呼び出される
    .mutation(req => {
      const { name, age } = req.input
      const user: User = { id: nanoid(), name, age }
      USERS.push(user)
      return user
    }),
})

それをappRouterのusersにセットします。

src/server/routers/_app.ts
import { router } from "../trpc"
import { z } from "zod"
import { usersRouter } from "./users"

export const appRouter = router({
  users: usersRouter,
  // ...
  // 他のrouterもここに追加されていく
})

// ここでtRPCの型定義を利用側でも扱えるようにエクスポート
export type AppRouter = typeof appRouter

他にも準備は必要ですが、ひとまずサーバー側の処理はこれだけです。

NextのAPIハンドラを作成

次に、定義したrouterをNextのAPI Routesから呼び出せるようにします。

src/pages/api/trpc/[trpc].api.ts
import { createNextApiHandler } from '@trpc/server/adapters/next'
import { createContext } from 'src/server/context'
import { appRouter } from 'src/server/routers/_app'

export default createNextApiHandler({
  // ここでappRouterを指定
  router: appRouter,
  batching: { enabled: true },
  createContext, // HTTP通信時のreq, resやセッション情報をctxオブジェクトに含めることも可能
})
createContextを使用する場合

APIによっては、リクエストごとにreqオブジェクトからヘッダー情報を参照したり、セッション情報からログインしているユーザーのIDを抽出してリクエストに使ったりしたい場合が考えられると思います。

そういった場合、以下のようにcreateContextを定義し、tRPCの初期化・インスタンス生成時にinitTRPC.context<Context>().create()のようにして読み込ませてあげましょう。

src/server/context.ts
import * as trpcNext from '@trpc/server/adapters/next';
import { z } from 'zod';
import { getSession } from 'src/lib/server/next/getSession';

const sessionSchema = z.object({
  userId: z.string().optional(),
  lastLoginAt: z.string().optional(),
  // ...
})

export const createContext = async ({ req, res }: trpcNext.CreateNextContextOptions) => {
  const session = await getSession(req, res)
  const parsedSession = sessionSchema.safeParse(session)
  if (!parsedSession.success) {
    throw new Error('セッション情報のバリデーションに失敗')
  }


  return {
    req,
    res,
    session,
  }
}

export type Context = inferAsyncReturnType<typeof createContext>

trpcのクライアントインスタンス作成やその設定

src/lib/shared/trpc.ts
import { httpBatchLink } from '@trpc/client';
import { createTRPCNext } from '@trpc/next';
import { type AppRouter } from '../../server/routers/_app';

// 動的にベースURLを取得する
function getBaseUrl() {
  // SSRなどでサーバー側からtRPCを呼び出す場合、完全なURLを返却
  // trpcをクライアント側からの呼び出しでしか使わない場合、相対パスでよいので空文字を返却
  return ''
}

// ここで生成したtrpcのクライアントインスタンスをAPIを利用する部分から呼び出す
export const trpc = createTRPCNext<AppRouter>({
  config() {
    return {
      links: [
        httpBatchLink({
          url: `${getBaseUrl()}/api/trpc`,
        }),
      ],
    }
  }
})

links にはhttpBatchLink()の他にもいろいろ指定できるのですが、ちょっと自分にはまだ難しくて理解が及ばなかったので詳細をもっと知りたい方は公式ドキュメントのLinksをご参照くださいmm

最後に、_app.tsxを生成したtrpcでラップすればようやく準備完了です!

src/pages/_app.page.tsx
import type { AppType } from "next/app";
import { trpc } from "../../lib/shared/trpc";

function App({...}: AppProps {
  // ...
  return (
    // ...
  );
};

// trpcでAppをラップすることで、Appのどこからでもtrpcを呼び出して利用できる
export default trpc.withTRPC(App);

これで、APIを利用する箇所で以下のように書くだけで、trpcを介して型安全にAPI呼び出しができるようになります(もちろん、リクエストやレスポンスの自動補完型チェックも効くようになります!)

import { trpc } from 'src/lib/shared/trpc';

// src/server/routers/users/index.tsのサーバー側で定義した型情報をそのまま使えるので、型安全に書ける
const getUserById = trpc.users.getUserById("2")
const createUser = trpc.users.createUser({
  name: "Goro",
  age: 56,
})

tRPCの良いところ

tRPCはサーバー側で用意したAPIを離れた( Remote)ところにあるクライアント側からでも同じ型情報をもった処理のまとまり(Procedure)を呼び出し実行(Call)できるのが強みだと勝手なこじつけで解釈しています。

tRPCを使うことでサーバー側とIFの整合性を保ったまま型安全にクライアント側のAPI呼び出しができるのは本当に便利ですね。

補足
冒頭にも書いた通りでTypeScriptやNext.jsを使用するプロジェクトという前提なのでtRPCにフォーカスを当てていますが、他の言語やFWをお使いの場合はgRPCという選択肢もあります

そして、私が普段常駐しているプロジェクトでは、SSRでは前述した Zodios で自動生成したAPIクライアントを getServerSideProps でコールし、CSRでは tRPC で用意したAPIをそのまま使うことで、サーバー側とクライアント側の両方で型安全なAPI通信を実現しています。

おわりに

今回紹介したZodiosやtRPCの他にも、huskyによるコミット時のType check・Lint実行やプッシュ時のテスト実行、hygenによるコード自動生成などいろいろな工夫があって現在のプロジェクトでは快適な開発体験が実現されています。

しかし、それら全てに触れるととても1つの記事に収まらないかつ読みにくくなると思ったので、型安全な開発体験を実現するために特に重要なZodiosとtRPCに絞って使い方をまとめてみました。

誤字や脱字のご指摘はもちろん、まだ技術的な説明に至らない点も多いかと思うので「ここ間違ってますよ!正しくは〇〇です!」などのコメント等あれば、是非ともよろしくお願いします。

最後まで読んでいただき、ありがとうございましたmm

2
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?