🎄

Hono + OpenAPI + Orvalを使った型安全な開発

に公開

はじめに

バックエンドのAPIを変更したら、フロントエンドでフィールドの抜け漏れや型が異なることによるエラーが出るようにする。
そんな開発体験を実現するために、Hono + OpenAPI + Orvalの構成を紹介します。

バックエンドで定義したAPIエンドポイントの型を共有できるHono RPCもありますが、useQueryやuseMutationを追加で用意する必要があり、そこを負担してくれるOrvalを使うようにしました。

今回は、ミニマルなタスク管理アプリを例に作ってみます。

リポジトリ:
https://github.com/Takashicc/todo-app-hono

アーキテクチャ概要

以下の流れでバックエンドの型定義をフロントエンドに共有します。

プロジェクト作成

honoのプロジェクトを作成します。
Cloudflareが提供しているテンプレートを使って作成します。

https://developers.cloudflare.com/workers/framework-guides/web-apps/more-web-frameworks/hono/

パッケージマネージャーはpnpmを使います。

pnpm create cloudflare@latest todo-app-hono --template=cloudflare/templates/vite-react-template

以下のようなフォルダ構成でプロジェクトが作成され、srcフォルダ配下にあるreact-appがフロントエンド、workerがバックエンドとなります。

.
├── eslint.config.js
├── index.html
├── node_modules
├── package-lock.json
├── package.json
├── pnpm-lock.yaml
├── public
│   └── vite.svg
├── README.md
├── src
│   ├── react-app
│   └── worker
├── tsconfig.app.json
├── tsconfig.json
├── tsconfig.node.json
├── tsconfig.worker.json
├── vite.config.ts
├── worker-configuration.d.ts
└── wrangler.json

必要なライブラリを追加します。

pnpm add valibot hono-openapi @valibot/to-json-schema @hono/swagger-ui tsx
pnpm add --save-dev orval

また、これからバックエンドで/api/...エンドポイントを実装していくのですが、テンプレートのままだと/api/...にリクエストを投げた時にアセットが優先されてWorkerが実行されないため、この挙動を変更してあげる必要があります。
wrangler.jsonにassets.run_worker_first設定を追記することで、/api/...エンドポイントでリクエストが投げられたら、Workerを優先して実行させるようにします。

{
  "$schema": "node_modules/wrangler/config-schema.json",
  "name": "todo-app-hono",
  "main": "./src/worker/index.ts",
  "compatibility_date": "2025-10-08",
  "compatibility_flags": ["nodejs_compat"],
  "observability": {
    "enabled": true
  },
  "upload_source_maps": true,
  "assets": {
    "directory": "./dist/client",
    "not_found_handling": "single-page-application",
+    "run_worker_first": ["/api/*"]
  }
}

バックエンドの実装

Todoを操作するAPIを定義します。
全量載せるとコードが長くなるので、ID指定取得と作成するAPIのみを例に出します。

リクエスト・レスポンスの型定義

まずはリクエストとレスポンスの型定義をValibotを使って行います。

src/worker/index.ts
import * as v from "valibot";

const todoSchema = v.object({
  id: v.string(),
  title: v.string(),
  completed: v.boolean(),
});
type Todo = v.InferOutput<typeof todoSchema>;

const todoIdSchema = v.pipe(v.string(), v.uuid());
const todoTitleSchema = v.pipe(v.string(), v.nonEmpty());

// GET /api/todos/:id
const getTodoParamSchema = v.object({
  id: todoIdSchema,
});
const getTodoResponseSchema = v.object({
  todo: todoSchema,
});
type GetTodoResponse = v.InferOutput<typeof getTodoResponseSchema>;

// POST /api/todos
const createTodoRequestSchema = v.object({
  title: todoTitleSchema,
});
const createTodoResponseSchema = todoSchema;
type CreateTodoResponse = v.InferOutput<typeof createTodoResponseSchema>;

Todoエンドポイント

リクエスト・レスポンスの型定義を使ってエンドポイントを組み立てます。

hono-openapiというライブラリを使ってOpenAPI定義を書きます。
以下のページはValibotと一緒に使う例を載せた公式ドキュメントです。

https://honohub.dev/docs/openapi/valibot

describeRoute関数を使って、OpenAPI Specを定義します。

自分が少しハマった点として、以下のコードではhono-openapiからインポートしたvalidatorを使っていますが、似たような名前で@hono/standard-validatorからインポートできるsValidatorをバリデーションに使うことができます。
ただ、これを使った場合にOpenAPI定義からごっそりその部分がなくなってしまうので、hono-openapiにあるvalidatorを使うよう注意しましょう。

src/worker/index.ts
import { Hono } from "hono";
import {
  describeRoute,
  resolver,
  validator,
} from "hono-openapi";
import * as v from "valibot";

const todos: Todo[] = [];

const app = new Hono<{ Bindings: Env }>()
  .basePath("/api")
  .get(
    "/todos/:id",
    validator("param", getTodoParamSchema),
    describeRoute({
      description: "Get a todo by id",
      responses: {
        200: {
          description: "Success",
          content: {
            "application/json": {
              schema: resolver(getTodoResponseSchema),
            },
          },
        },
        404: {
          description: "Todo not found",
          content: {
            "application/json": {
              schema: resolver(errorResponseSchema),
            },
          },
        },
      },
    }),
    (c) => {
      const { id } = c.req.valid("param");
      const todo = todos.find((todo) => todo.id === id);
      if (!todo) {
        return c.json<ErrorResponse>({ error: "Todo not found" }, 404);
      }
      return c.json<GetTodoResponse>({ todo });
    },
  )
  .post(
    "/todos",
    validator("json", createTodoRequestSchema),
    describeRoute({
      description: "Create a todo",
      responses: {
        200: {
          description: "Success",
          content: {
            "application/json": {
              schema: resolver(createTodoResponseSchema),
            },
          },
        },
      },
    }),
    (c) => {
      const { title } = c.req.valid("json");
      const id = crypto.randomUUID();
      const todo = { id, title, completed: false };
      todos.push(todo);
      return c.json<CreateTodoResponse>(todo);
    },
  );

export default app;

OpenAPIエンドポイント

OpenAPIのJSONファイルを取得できるエンドポイントと、SwaggerUIを確認できるエンドポイントを用意します。
デフォルトのままだとOpenAPIエンドポイントもOpenAPIのJSONに含まれるため、excludeを使って除外します。

src/worker/index.ts
import { swaggerUI } from "@hono/swagger-ui";
import { GenerateSpecOptions, openAPIRouteHandler } from "hono-openapi";

export const openAPISpecOptions: Partial<GenerateSpecOptions> = {
  documentation: {
    info: {
      title: "Todo API",
      version: "1.0.0",
    },
  },
  includeEmptyPaths: true,
  exclude: ["/api/openapi.json", "/api/openapi/swagger-ui"],
};

app
  .get("/openapi.json", openAPIRouteHandler(app, openAPISpecOptions))
  .get("/openapi/swagger-ui", swaggerUI({ url: "/api/openapi.json" }));

これでpnpm run devを実行して、http://localhost:5173/api/openapi/swagger-uiに行くことで、以下のような画面が確認できるようになります。

OpenAPIファイルの生成

バックエンドに定義したOpenAPI SpecからOpenAPIファイルを生成するスクリプトを用意します。

https://honohub.dev/docs/openapi/persisting

src/worker/scripts/generate-openapi-specs.ts
import { generateSpecs } from "hono-openapi";
import fs from "node:fs";
import app, { openAPISpecOptions } from "..";

async function main() {
  const specs = await generateSpecs(app, openAPISpecOptions);
  fs.writeFileSync("./openapi.json", JSON.stringify(specs));
}

main();

このままだとCannot find module 'node:fs' or its corresponding type declarationsというエラーが表示されます。
これは、上記のスクリプトでnode:fsライブラリを使っていますが、型定義が見つからず、型チェックやモジュール解決ができないため発生しています。
以下のようにtsconfig.worker.jsonでNode.jsの型定義を読み込むように修正します。

tsconfig.worker.json
{
  "extends": "./tsconfig.node.json",
  "compilerOptions": {
    "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.worker.tsbuildinfo",
-    "types": ["vite/client", "./worker-configuration.d.ts"]
+    "types": ["vite/client", "./worker-configuration.d.ts", "node"]
  },
  "include": ["src/worker"]
}

package.jsonに用意したスクリプトを実行できるようにコマンドを設定します。

package.json
{
  "scripts": {
+    "generate": "tsx ./src/worker/scripts/generate-openapi-spec.ts",
  },
}

pnpm run generateを実行することで、プロジェクトのルートディレクトリにopenapi.jsonが生成されます。

APIクライアントの生成

生成されたOpenAPIファイルからOrvalを使ってTanStack Queryを使ったAPIクライアントを生成します。
まず、packages/apiに生成したAPIクライアントを置くため、pnpm workspaceを設定します。

https://pnpm.io/ja/workspaces

https://pnpm.io/ja/catalogs

catalogには共通して使うライブラリを書きます。tsdownはビルドに使います。

pnpm-workspace.yaml
packages:
  - packages/*

catalog:
  "@tanstack/react-query": ^5.90.9
  tsdown: ^0.15.12
  typescript: 5.8.3

packages/apiのセットアップを行います。

packages/api/package.json
{
  "name": "@packages/api",
  "private": true,
  "type": "module",
  "scripts": {
    "build": "orval --config ./orval.config.ts && tsdown --config ./tsdown.config.ts",
    "watch": "tsdown --watch --config ./tsdown.config.ts",
    "typecheck": "tsc --noEmit"
  },
  "dependencies": {
    "@tanstack/react-query": "catalog:",
    "axios": "^1.13.2"
  },
  "devDependencies": {
    "tsdown": "catalog:",
    "typescript": "catalog:"
  }
}

Orvalの設定ファイルを用意します。プロジェクトルートに生成されたopenapi.jsonファイルを元にTanStack Query、axiosを使ったAPIクライアントを作成するように設定しています。

https://orval.dev/guides/react-query

また、useSuspenseQueryが使いたい場合は、override.query設定を追加することで生成できます。

https://zenn.dev/galapagos/articles/86666ca2d3805e

packages/api/orval.config.ts
import { defineConfig } from "orval";

export default defineConfig({
  "todo-app-api": {
    input: "../../openapi.json",
    output: {
      target: "./src/index.ts",
      client: "react-query",
      biome: true,
      override: {
        query: {
          useSuspenseQuery: true,
          version: 5,
        },
      },
    },
  },
});

tsconfig.jsonを用意します。

packages/api/tsconfig.json
{
  "compilerOptions": {
    "target": "ESNext",
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "declaration": true,
    "declarationMap": true,
    "strict": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noFallthroughCasesInSwitch": true,
    "noUncheckedIndexedAccess": true,
    "noImplicitOverride": true,
    "noUncheckedSideEffectImports": true,
    "esModuleInterop": true,
    "allowSyntheticDefaultImports": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true,
    "isolatedModules": true,
    "paths": {
      "@/*": ["./src/*"]
    },
    "outDir": "./dist",
    "rootDir": "./src"
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"]
}

tsdownを使ってビルドするため、tsdown.config.ts設定ファイルを用意します。

https://tsdown.dev/reference/api/Interface.UserConfig

packages/api/tsdown.config.ts
import { defineConfig } from "tsdown";

export default defineConfig({
  entry: ["./src/index.ts"],
  dts: {
    sourcemap: true,
  },
  target: false,
  platform: "browser",
  minify: true,
  exports: true,
});

最後に空のsrc/index.tsを用意して、cd packages/api、pnpm run buildを実行すると、APIクライアントが自動生成されます。

フロントエンドの実装

まず、package.jsonに@packages/apiを追加することでAPIクライアントを使えるようにします。

package.json
  "dependencies": {
+    "@packages/api": "workspace:*"
  },

tailwindcssやTanStack Queryなどセットアップもありますが、尺が長くなるので省略します。
以下のコミットからセットアップ内容を確認できます。

https://github.com/Takashicc/todo-app-hono/commit/ecc975d892c479efdb7af559953e92981e8194e3

セットアップした後、@packages/apiからuseXXXをインポートして使うことができます。
レスポンスの型がちゃんと定義されており、補完が効くほか、バックエンドに変化があった場合はAPIクライアントを再生成することで、フィールドの抜け漏れや型違いの同期ずれを防ぐことができます。

src/react-app/App.tsx
import {
  getGetApiTodosQueryKey,
  useGetApiTodosSuspense,
  usePostApiTodos,
  usePutApiTodosId,
} from "@packages/api";
import { useQueryClient } from "@tanstack/react-query";
import { useState } from "react";

function App() {
  const queryClient = useQueryClient();
  const [title, setTitle] = useState("");

  const todosQuery = useGetApiTodosSuspense();
  const todos = todosQuery.data.data.todos;
  const completedCount = todos.filter((t) => t.completed).length;

  const createTodoMutation = usePostApiTodos({
    mutation: {
      onSuccess: () => {
        queryClient.invalidateQueries({ queryKey: getGetApiTodosQueryKey() });
      },
    },
  });

  const updateTodoMutation = usePutApiTodosId({
    mutation: {
      onSuccess: () => {
        queryClient.invalidateQueries({ queryKey: getGetApiTodosQueryKey() });
      },
    },
  });

  return ...;
}

フロントエンドの画面の実装後、pnpm run devを実行することでローカルから画面を確認できます。

おわりに

セットアップに少し時間はかかりますが、この構成を使うことでバックエンドとフロントエンドのAPI齟齬を少なくでき、ミスの少ない安全な開発ができるようになります。

特に、チーム開発においてはAPIの変更による影響範囲を事前に把握でき、リファクタリングやAPI設計の変更も安心して行えるようになります。また、OpenAPI仕様書が自動生成されるため、ドキュメントメンテナンスの負担も軽減されます。

ぜひ型安全な開発体験をお試しください。

GitHubで編集を提案
ambr Tech Blog

Discussion