🛡️

Hono RPCで型安全なAPIを作ることによる開発体験

に公開

Hono RPCとは

公式の引用

The RPC feature allows sharing of the API specifications between the server and the client.

翻訳

RPC 機能を使用すると、サーバーとクライアント間で API 仕様を共有できます。

つまり、Hono RPCはAPIのリクエストとレスポンスのプロパティとその型を共有できる機能です。
これを使用すると、フロントエンド側の実装時のミスを大幅な軽減に繋がるため、バグを大幅に減らすことが出来ます。

Hono RPCのメリット

Hono RPCを導入することによるメリットは、APIの型をバックエンドとフロントエンドで共有することによって、プロジェクト全体を型安全にすることが出来ます。
また、tRPCと比較して何が良いのかも説明します。

型定義を共有する実装の問題

Hono RPCの利点を解説する前に、従来の型定義をフロントエンドとバックエンドで共有するやり方を見てみます。
従来の方式ではリクエストとレスポンスの型を事前にファイルで定義します。

⚠️従来の書き方

type
//リクエストの型
export interface CreateUserRequest {
  name: string;
  email: string;
  age: number;
}

// レスポンスの型
export interface CreateUserResponse {
  id: string;
  name: string;
  email: string;
  age: number;
  createdAt: string;
}

そしてバックエンドとフロントエンド側で、型をインポートします。

backend
import { Hono } from 'hono';
import { CreateUserRequest, CreateUserResponse } from '../types/api';

const app = new Hono();

app.post('/api/users', async (c) => {
  try {
    // リクエストボディの型チェック
    const body = await c.req.json<CreateUserRequest>();
    
    // ユーザー作成のロジック
    const user: CreateUserResponse = {
      id: crypto.randomUUID(),
      name: body.name,
      email: body.email,
      age: body.age,
      createdAt: new Date().toISOString()
    };

    // レスポンスに型注釈を追加
    return c.json<CreateUserResponse>(user, 201);
});

export default app;
frontend

import { CreateUserRequest, CreateUserResponse, API_ENDPOINTS } from '../types/api';

  async createUser(request: CreateUserRequest): Promise<CreateUserResponse> {
    const response = await fetch('http://localhost:8787/api/users', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify(request),
    });

    //レスポンスに
    return response.json() as Promise<CreateUserResponse>;
  }
}

上記のやり方には問題があります。
・テーブルが変更した時に、型定義を変更する必要がある。
・APIの実装を変えるたびに型定義を変更する必要がある。

つまり、型定義の保守・管理が面倒になるということです。
この問題はHono RPCを使用することで解消することが出来ます。

✅Hono RPCでの実装

backend
import { Hono } from 'hono';

// Honoインスタンスを作成し、型を指定
const app = new Hono()
.post('/api/users', async (c) => {
  const { name, email, age } = await c.req.json();
  
  const user = {
    id: crypto.randomUUID(),
    name,
    email,
    age,
    createdAt: new Date().toISOString()
  };

  return c.json(user, 201);
});

export default app;
export type AppType = typeof app;
frontend
import { hc } from '@hono/client';
import type { AppType } from '../backend';

const client = hc<AppType>('http://localhost:8787');

const createUser = async() => {
    const response = await client.api.users.$post({
      json: request,
    });
    
    const data = await response.json();
    return data;
};

上記の実装方法だと、Hono側が自動的に、APIのリクエストとレスポンスの型を推論してくれます。
これによって、従来の共有する型を自分で定義する必要がなくなるため、保守性の向上に繋がります。
今回はデモなので使用していませんが、PrismaなどのTypescript向けのORMを使用することで、DB操作の返り値を格納する変数に型をつけることが出来て、より型安全なコードになります。

tRPCと比較してみる

Hono RPCはRESTfulな書き方をしながら型安全にすることが出来ますが、tRPCではRESTに準拠しない形でAPIを作成するため、それによって開発者同士のコニュニケーションの生産性の向上に繋がります。
ユーザーの情報を取得するAPIのサンプルを通じて、違いを見ていきましょう。

Hono RPC

import { Hono } from 'hono';
import { z } from 'zod';

const app = new Hono()
.get('/users/:id', (c) => {
  const id = c.req.param('id');

  return c.json({ id, name: 'Taro', email: 'taro@example.com' });
});

export type AppType = typeof app;
export default app;
tRPC

import { initTRPC } from '@trpc/server';
import { z } from 'zod';

const t = initTRPC.create();

export const appRouter = t.router({
  // ユーザー取得
  getUser: t.procedure
    .input(z.string()) // id
    .query(({ input }) => {
      // ダミーデータ
      return { id: input, name: 'Taro', email: 'taro@example.com' };
    })
});

export type AppRouter = typeof appRouter;

tRPCは、独自で関数名でAPIの仕様を表現する必要がありますが、Hono RPCは統一インターフェースの書き方によって開発者同士の認識を一致させることが容易になります。

つまずいたところ

Hono RPCを使用していて、APIの型推論が上手く機能しなかったことがありました。
原因としては、型推論の仕様を理解していないことによるものでした。
変数・定数の型推論は定義時に行われるため、書き方を少し間違えると上手く機能しない場合があります。具体的にコードで見てみましょう。

❌ 型推論されないコード

backend
const app = new Hono<{ Bindings: {}; Api: UserApi }>();

app.post('/api/users', async (c) => {
  const { name, email, age } = await c.req.json();
  
  const user = {
    id: crypto.randomUUID(),
    name,
    email,
    age,
    createdAt: new Date().toISOString()
  };

  return c.json(user, 201);
});

type AppType = typeof app;

✅ 推論されるコード

backend
const app = new Hono<{ Bindings: {}; Api: UserApi }>()
.post('/api/users', async (c) => {
  const { name, email, age } = await c.req.json();
  
  const user = {
    id: crypto.randomUUID(),
    name,
    email,
    age,
    createdAt: new Date().toISOString()
  };

  return c.json(user, 201);
});

type AppType = typeof app;

上記のコードの違いとしては、post()をHonoインスタンス生成時に追加しているか、していないかです。TypeScriptが型推論をする時には、定義時のものを参照するので、Honoインスタンス生成時にメソッドを追加しないとAppTypeにリクエストとレスポンスの型が推論されなくなってしまいます。

なので、Hono RPCを使用するときは基本的にメソッドをインスタンスにチェーンする必要があります。

example
import { Hono } from 'hono'

const app = new Hono()
  .get('/', (c) => c.json('list authors'))
  .post('/', (c) => c.json('create an author', 201))

export type AppType = typeof app;
export default app;

状態を型安全にする

Hono RPCでフロントでリクエストした時の返り値には型が付きますが、まだ問題があります。
Getリクエストで取得したデータを状態として保存する場合に、状態の定義に返り値の型と同じものを注釈しないと、型エラーが出る可能性があります。
Hono RPCでは、この問題を解決することが出来ます。

example
import type { hc, InferResponseType } from 'hono/client'
import type { AppType } from './index'

const client = hc<AppType>('http://localhost:8787');

// 
type ResType = InferResponseType<typeof client.index.$get>

InferResponseTypeを使用することで、APIのレスポンス型を取得することが出来ます。
これを以下のように状態に注釈をすることで、より型安全性が向上します。

frontend
import { hc } from 'hono/client';
import type { AppType } from './index';
import { useState } from 'react';

const client = hc<AppType>('http://localhost:8787');
type ResAllUserType = InferResponseType<typeof client.users.$get()>

const [allUser, setAllUser] = useState<ResAllUserType>([])

まとめ

  • Hono RPCを使うことで、フロントエンドとバックエンド間で型安全性を実現できます。
  • APIを定義する際は、Honoインスタンスにルート定義をメソッドチェーンで繋ぐことで、型が正しく推論されます。
  • InferResponseTypeで状態に型を付けることで、フロント側の型安全性を保てる。
Emoba Tech Blog

Discussion