TypeScript + Zod でエッジランタイム向け型安全 API

Cloudflare Workers / Bun / Deno などのエッジランタイムで TS API を組むときは、ビルド時の型だけでは不十分です。Zod でスキーマを真実の源にすると、入力検証・サーバー出力の型・クライアント側 RPC まで一気通貫で型が通ります。本記事では Zod v4 + Hono を前提に、エッジ向けの型安全 API 設計パターンを整理します。

エッジランタイムで TypeScript の API を組むとき、厄介なのは ビルド時の型だけでは外から来る値の安全性を保証できないことです。Request / query / JSON body / KV の読み出し — どれも unknown から始まるので、ランタイムでの型付けが必須になります。

Zod はスキーマ 1 本から「実行時の検証」と「TypeScript の型」を同時に派生させられる、この用途に最もよく効くライブラリです。本記事では Zod v4 + Hono + Cloudflare Workers を前提に、エッジ向けの型安全 API 設計パターンをまとめます。

到達点

  • 1 つのスキーマ定義から、入力検証・サーバー側ハンドラ型・HTTP レスポンス型・クライアント側の型まで派生
  • z.ZodError をそのまま 400 に整形する薄いエラーハンドラ
  • Hono RPC (hc<typeof app>) で呼び出し側も型補完が効く

検証バージョン (2026-04 時点): Zod v4.3、Hono v4.12@hono/zod-validator v0.7(Zod 3.25+ / Zod 4 両対応)。

1. スキーマを真実の源にする — src/schema.ts

共通スキーマはサーバーとクライアント両方が参照できる場所に置きます。z.infer<typeof Schema> で TS 型を派生させ、型と検証を分離しないのが肝。

// src/schema.ts
import { z } from "zod";

// Branded type — 文字列を domain ID として識別させる
// Zod v4 では z.string().uuid() は deprecated、z.uuid() を使う
export const PostId = z.uuid().brand<"PostId">();
export type PostId = z.infer<typeof PostId>;

// 入力(作成)
export const PostInput = z.object({
  title: z.string().min(1).max(200),
  body: z.string().min(1).max(50_000),
});
export type PostInput = z.infer<typeof PostInput>;

// 出力(保存後)
// Zod v4 では z.string().datetime() は deprecated、z.iso.datetime() を使う
export const Post = z.object({
  id: PostId,
  title: z.string(),
  body: z.string(),
  createdAt: z.iso.datetime(), // ISO 8601 文字列でクライアントに出す
});
export type Post = z.infer<typeof Post>;

// エラーボディ — 4xx で常に返す形を固定
export const ErrorBody = z.object({
  code: z.string(),
  message: z.string(),
  details: z.unknown().optional(),
});
export type ErrorBody = z.infer<typeof ErrorBody>;

branded type は「単なる string」が userId として扱われる事故を防ぎます。PostId として受けるところに生の文字列を渡すとコンパイルエラーになります。

2. 入力検証を一元化する

Hono では @hono/zod-validatorzValidator を使うのが標準。検証を抜けた後の c.req.valid("json")PostInput 型で推論されます。

import { Hono } from "hono";
import { HTTPException } from "hono/http-exception";
import { zValidator } from "@hono/zod-validator";
import { PostInput, Post, type PostId } from "./schema";

type Bindings = { DB: D1Database };

export const app = new Hono<{ Bindings: Bindings }>()
  .post("/posts", zValidator("json", PostInput), async (c) => {
    const input = c.req.valid("json"); // PostInput で推論済
    const id = crypto.randomUUID() as PostId;
    const row: Post = {
      id,
      title: input.title,
      body: input.body,
      createdAt: new Date().toISOString(),
    };
    // …DB 保存…
    return c.json(row, 201);
  })
  .get("/posts/:id", async (c) => {
    const raw = c.req.param("id");
    // サーバー側でも branded type の境界は parse で通す
    const parsed = PostId.safeParse(raw);
    if (!parsed.success) throw new HTTPException(400, { message: "invalid id" });
    const id = parsed.data;
    // …DB 取得…
    const row: Post = {
      id,
      title: "stub",
      body: "stub",
      createdAt: new Date().toISOString(),
    };
    return c.json(row);
  });

export type AppType = typeof app; // ← クライアント型の種になる

ポイント: new Hono().post(...).get(...) とチェインで構築して typeof app を export するのが、クライアント側で型を復元するうえでもっとも素直で確実な書き方です(ルートを別 const で積んでから merge する形でも動きますが、チェインの方がルート追加の度に AppType が自然に拡張されます)。

3. Hono RPC でクライアントにも型を届ける

Zod で作った Post 型が、そのまま fetch の戻り値として補完されます。

// src/client.ts
import { hc } from "hono/client";
import type { AppType } from "./index";
import type { Post, PostInput } from "./schema";

const client = hc<AppType>("https://api.example.com");

async function createPost(input: PostInput): Promise<Post> {
  const res = await client.posts.$post({ json: input });
  if (!res.ok) throw new Error(`status ${res.status}`);
  return res.json(); // ← Post 型で補完される
}

app.post("/posts", ...) と書いた瞬間、クライアントは .posts.$post({...}) という形で呼び出せるようになり、入力も出力も補完が効きます。OpenAPI を書かずに、スキーマファイルとサーバー実装だけでエンドツーエンドの型安全が得られます。

4. ZodError を 4xx に整形する

検証失敗時の ZodError は Hono ミドルウェアで一元変換します。出力形は ErrorBody に統一。

// src/errors.ts
import type { ZodError } from "zod";
import type { ErrorBody } from "./schema";

export function formatZodError(err: ZodError): ErrorBody {
  return {
    code: "validation_error",
    message: "入力が不正です",
    details: err.issues.map((i) => ({
      path: i.path.join("."),
      code: i.code,
      message: i.message,
    })),
  };
}

app.onError から err instanceof HTTPException で 4xx、それ以外は 500 に振り分け、ZodError は上の関数で ErrorBody 化して返します。サーバーもクライアントも同じ ErrorBody 型で扱えるのでハンドリングが単純になります。

落とし穴

  • スキーマを細かく分けすぎるPostInputPostPostUpdatePostPublic と増やすと、z.infer の結果が分裂してサーバー/クライアントで微妙にズレる。**「外から来る形」「外に出す形」**の 2 種類を基本にし、派生は .omit() / .pick() / .partial() で作る
  • .transform() の副作用 — transform で副作用(ID 発行、DB 書込)を行うと、型と実行結果が乖離して追跡不能になる。transform は 純粋な変換に限定し、副作用はハンドラ本体へ
  • z.any() / z.unknown() で逃げる — 一度入れると次の boundary まで伝染する。どうしても不明なら z.record(z.string(), z.unknown()) で最小限に絞る
  • Zod 3 → Zod 4 の混在 — ひとつの tsconfig ツリー内に z3z4 が混じると、z.infer の結果が合わなくて静かに壊れます。プロジェクト単位でバージョンを固定し、依存の peer を確認する
  • c.req.valid("json") の取り忘れc.req.json() を直接呼んで検証を通していないケース。zValidator を必ず挟む

まとめ

エッジで TS API を書くとき、Zod スキーマを 境界の上で 1 回だけ書いて他は z.infer で派生させる のが最大のコストパフォーマンス。Hono RPC と組み合わせれば、OpenAPI を書かずにクライアントまで型安全が届きます。

これで spec 準拠の連載 10 本が完走。r43lab ブログの骨格はこれで一旦揃ったので、AdSense 申請準備の最終チェックに入れます。

参考

記事で登場したコードをひとつの最小リポジトリとしてまとめました。ファイルツリーから切り替えて、全体の構造をそのまま確認できます。

$cat./src/index.tsts
import { Hono } from "hono";
import { HTTPException } from "hono/http-exception";
import { zValidator } from "@hono/zod-validator";
import { PostInput, Post, PostId, type ErrorBody } from "./schema";
import { centralErrorHandler } from "./errors";

// 環境から受ける bindings (D1 等を繋ぐときはここに追加)
type Bindings = Record<string, unknown>;

// 重要: new Hono().post(...).get(...) のチェイン形で定義することで、
// `typeof app` がすべてのルートの型を保持する。
// これを export することで、クライアント側が hc<typeof app> で復元できる。
export const app = new Hono<{ Bindings: Bindings }>()
  .post("/posts", zValidator("json", PostInput), async (c) => {
    const input = c.req.valid("json"); // ← PostInput 型で推論済
    const id = crypto.randomUUID() as PostId; // brand 型へキャスト
    const row: Post = {
      id,
      title: input.title,
      body: input.body,
      createdAt: new Date().toISOString(),
    };
    // 実運用ではここで DB 保存
    return c.json(row, 201);
  })
  .get("/posts/:id", async (c) => {
    const raw = c.req.param("id");
    const parsed = PostId.safeParse(raw);
    if (!parsed.success) {
      throw new HTTPException(400, { message: "invalid id" });
    }
    const id = parsed.data;

    // 実運用ではここで DB 取得
    const row: Post = {
      id,
      title: "stub",
      body: "stub",
      createdAt: new Date().toISOString(),
    };
    return c.json(row);
  });

// エラーハンドリングの集約
app.onError(centralErrorHandler);

// ヘルスチェック
app.get("/", (c) => c.text("zod-edge-api-starter ok\n"));

// クライアントが型を復元するための export
export type AppType = typeof app;

export default app;

// ErrorBody 型を型として使う例 (linter にだけ教える)
type _ErrorShape = ErrorBody;
slugtypescript-zod-edge-type-safe-apifiles8click a file in the tree to switch