Hono RPC + Superjson で厳密に型を共有する

はじめに

今回はHonoのRPCを利用する際に、フロント・バックエンドで厳密に型を共有する方法を紹介します。 SuperJSONで型情報をjsonに追加した上で、zod.parse()を利用して値を検証して型を付与します。

利用技術

  • Hono v4.7.11: Webフレームワーク
  • SuperJSON v2.2.2: jsonに型情報を付与する
  • Zod v3.25.56: バリデーション
  • TypeScript

背景

HonoのRPC機能では、フロントエンドから下記のようにAPIを呼び出すことができます。

// バックエンド側のレスポンス作成部分
export const getSampleHandler = async (c) => {
  return c.json({
    message: "test",
    date: new Date(),
  });
};

// フロントエンド側のAPIコール部分
const response = await client.api.sample.$get(); // 'GETメソッドで /api/sample を呼び出す'
const data = await response.json();

この時dataの型情報を見ると、dateがstring型になっています。

これはレスポンスに設定したobjectがjsonに変換される際にjsonで扱える形式にキャストされるため発生します。 せっかくRPCを使うのであれば、すべての型情報を共有したいのでSuperJSONを活用することにしました。

関連情報

下記の記事で、今回やろうとしていることが解説されていましたが、現在のHonoのバージョンではそのまま利用することができませんでした。

zenn.dev

またGitHubのissueでも同じことが議論されていましたが、具体的なコードは提示されないままクローズされています。 github.com

同issueの中でzod.parse()について言及があったため、これを利用して実装してみました。 github.com

実装

バックエンド側

SuperJSONを使ってレスポンスが返せるように、下記の関数を準備します。

export const jsonS = <T>(
  c: Context,
  object: T,
  status: StatusCode = 200,
  headers?: Record<string, string>
) => {
  const body = SuperJSON.stringify(object);

  const responseHeaders = {
    "content-type": "application/json; charset=UTF-8",
    "x-SuperJSON": "true", // SuperJSON形式でシリアライズしていることを表現
    ...headers,
  };

  return c.newResponse(body, status, responseHeaders);
};

ヘッダーを追加するので、CORSの設定を追加します。

const app = new Hono()
  .use(
    "*",
    cors({
      ・・・
      exposeHeaders: ["x-SuperJSON"], // 許可する
      ・・・
    })
  )

上記の関数を利用してAPIのレスポンスを作成します。

export const getSampleHandler = async (c: Context) => {
  const data = {
    message: "test",
    date: new Date(),
  };

  return jsonS(c, data);
};

合わせてレスポンスのスキーマをZodで定義します。

export const sampleResponseSchema = z.object({
  message: z.string().describe("サンプルメッセージ"),
  date: z.date().describe("日付"),
});

バックエンド側の実装はこれで終了です。

フロントエンド

こちらもヘルパー関数を用意して、SuperJSONとZodを使ってobjectを取得します。

export async function parseTypedResponse<T extends z.ZodSchema>(
  response: Response,
  schema: T
): Promise<z.infer<T>> {
  const text = await response.text();

  // SuperJSONパース
  let parsedData: any;
  if (response.headers.get("x-SuperJSON")) {
    parsedData = SuperJSON.parse(text);
  } else {
    parsedData = JSON.parse(text);
  }

  // Zodバリデーション
  return schema.parse(parsedData);
}

APIのコール部分は下記のイメージです。

const response = await client.api.sample.$get();
const data = await parseTypedResponse(response, sampleResponseSchema);

これでdate型の型情報が付与されました。

おわりに

今回はHono RPC + SuperJSON を使ってDate型も含めた型共有を実現してみました。 Zodのスキーマ情報をフロント・バックエンドで共有する必要が出てくるので、もう少しスマートに実現できると嬉しい気がします。