$ cat ./blog/typescript-zod-edge-type-safe-api.md
TypeScript + Zod でエッジランタイム向け型安全 API Cloudflare Workers / Bun / Deno などのエッジランタイムで TS API を組むときは、ビルド時の型だけでは不十分です。Zod でスキーマを真実の源にすると、入力検証・サーバー出力の型・クライアント側 RPC まで一気通貫で型が通ります。本記事では Zod v4 + Hono を前提に、エッジ向けの型安全 API 設計パターンを整理します。
author r43
date 2026-04-23 (2026年4月23日)
reading 3 min
commit 47196df エッジランタイムで 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-validator の zValidator を使うのが標準。検証を抜けた後の 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 型で扱える のでハンドリングが単純になります。
落とし穴
スキーマを細かく分けすぎる — PostInput・Post・PostUpdate・PostPublic と増やすと、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 ツリー内に z3 と z4 が混じると、z.infer の結果が合わなくて静かに壊れます。プロジェクト単位でバージョンを固定し、依存の peer を確認する
c.req.valid("json") の取り忘れ — c.req.json() を直接呼んで検証を通していないケース。zValidator を必ず挟む
まとめ
エッジで TS API を書くとき、Zod スキーマを 境界の上で 1 回だけ書いて他は z.infer で派生させる のが最大のコストパフォーマンス。Hono RPC と組み合わせれば、OpenAPI を書かずにクライアントまで型安全 が届きます。
これで spec 準拠の連載 10 本が完走。r43lab ブログの骨格はこれで一旦揃ったので、AdSense 申請準備の最終チェックに入れます。
参考
$ tree ./repo/
記事で登場したコードをひとつの最小リポジトリとしてまとめました。ファイルツリーから切り替えて、全体の構造をそのまま確認できます。
┌─ typescript-zod-edge-type-safe-api ────────── files 08 ────────────────────── ● ● ● ─┐ $ cat ./src/index.ts ts
# zod-edge-api-starter
Cloudflare Workers + Hono + Zod v4 で、 **スキーマ 1 本からサーバー・クライアントの型を貫通** させる最小テンプレート。
[ 記事本文 ]( https://r43lab.com/blog/typescript-zod-edge-type-safe-api ) と対になる実動構成です。
## Quick start
```bash
bun install
# 開発
bun run dev
# 本番デプロイ
bun run deploy
# 型付けクライアントの動作確認 (後述)
bun run src/client.ts
```
## 狙い
- ** `src/schema.ts` を真実の源** にする
- サーバーは `@hono/zod-validator` でスキーマを使って入力検証
- クライアントは `hc<typeof app>` でサーバーの型を丸ごと取り込む
- 入力 / 出力 / エラーボディの 3 種だけで、ドメインモデルを表現
## ファイル構成
- `src/schema.ts` — 共有スキーマ( `PostInput` / `Post` / `ErrorBody` )と branded `PostId`
- `src/errors.ts` — `ZodError` → `ErrorBody` 変換と Hono の `onError` 用ハンドラ
- `src/index.ts` — Hono サーバー本体。 `export type AppType = typeof app` でクライアントに型を渡す
- `src/client.ts` — `hc<AppType>` を使った型付き API クライアントの例
## なぜ branded type を使うか
`string` のまま引き回すと「ユーザー ID / ポスト ID / セッション ID」が静かに混ざります。 `z.string().uuid().brand<"PostId">()` で鋳造することで、API 境界とハンドラ内の両方で **誤用がコンパイル時に落ちる** ようになります。
## 依存バージョンについて
`package.json` は **2026-04 時点のスナップショット** です。clone 後は最新化を推奨。
```bash
bun outdated
bun update
```
## Stack
Hono v4.12 · Zod v4.3 · `@hono/zod-validator` v0.7 · Wrangler v4 {
"name" : "zod-edge-api-starter" ,
"private" : true ,
"type" : "module" ,
"scripts" : {
"dev" : "wrangler dev" ,
"deploy" : "wrangler deploy" ,
"tail" : "wrangler tail --format=pretty"
},
"dependencies" : {
"@hono/zod-validator" : "^0.7.6" ,
"hono" : "^4.12.14" ,
"zod" : "^4.3.6"
},
"devDependencies" : {
"@cloudflare/workers-types" : "^4.20260420.1" ,
"typescript" : "^6.0.3" ,
"wrangler" : "^4.84.0"
}
} /**
* hc<AppType> で型付き API クライアントを作る例。
*
* ポイント:
* - `import type { AppType }` で **型だけ** 取り込む (ランタイム依存を生まない)
* - 入力 / 出力 / エラー形が全て自動で補完される
* - サーバー側のスキーマ変更が client.ts のコンパイル時点で波及する
*/
import { hc } from "hono/client" ;
import type { AppType } from "./index" ;
import type { Post, PostInput, ErrorBody } from "./schema" ;
const client = hc < AppType >( "https://api.example.com" );
export async function createPost ( input : PostInput ) : Promise < Post > {
const res = await client.posts. $post ({ json: input });
if ( ! res.ok) {
// サーバー側 ErrorBody と同じ形で受けられる
const err = ( await res. json ()) as ErrorBody ;
throw new Error ( `${ res . status }: ${ err . message }` );
}
return res. json (); // Post 型で補完
}
export async function fetchPost ( id : string ) : Promise < Post | null > {
const res = await client.posts[ ":id" ]. $get ({ param: { id } });
if (res.status === 404 ) return null ;
if ( ! res.ok) {
const err = ( await res. json ()) as ErrorBody ;
throw new Error ( `${ res . status }: ${ err . message }` );
}
return res. json ();
}
// 簡易動作確認 (Bun で `bun run src/client.ts` した時のみ走る)
if ( import . meta .main) {
const created = await createPost ({ title: "hi" , body: "hello world" });
console. log ( "created:" , created);
const fetched = await fetchPost (created.id);
console. log ( "fetched:" , fetched);
} import { HTTPException } from "hono/http-exception" ;
import type { Context } from "hono" ;
import { z } from "zod" ;
import type { ErrorBody } from "./schema" ;
/**
* ZodError を 4xx レスポンス用の ErrorBody に整形する。
* validation の失敗を、どの手続きでも同じ形でクライアントに届けるため。
*/
export function formatZodError ( err : z . ZodError ) : ErrorBody {
return {
code: "validation_error" ,
message: "入力が不正です" ,
details: err.issues. map (( i ) => ({
path: i.path. join ( "." ),
code: i.code,
message: i.message,
})),
};
}
/**
* Hono の app.onError に挿して使う統一ハンドラ。
* HTTPException → そのまま出力、ZodError → 400、それ以外 → 500
*/
export function centralErrorHandler ( err : unknown , c : Context ) {
if (err instanceof HTTPException ) return err. getResponse ();
if (err instanceof z . ZodError ) {
return c. json < ErrorBody >( formatZodError (err), 400 );
}
console. error (err);
return c. json < ErrorBody >(
{ code: "internal" , message: "unexpected error" },
500 ,
);
} 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 ; import { z } from "zod" ;
// ─── Branded types ────────────────────────────────────────
// 同じ string でも domain 上で「別物」として扱うための型。
// 生の string を PostId として渡すとコンパイルエラーになる。
// 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 (),
// ISO 8601 文字列でクライアントに届ける (Date を避けて直列化の揺れを減らす)
createdAt: z.iso. datetime (),
});
export type Post = z . infer < typeof Post>;
// 部分更新用: PostInput の全フィールドを optional にする派生
export const PostUpdate = PostInput. partial ();
export type PostUpdate = z . infer < typeof PostUpdate>;
// ─── エラー ───────────────────────────────────────────────
// 4xx / 5xx で必ずこの形で返す
export const ErrorBody = z. object ({
code: z. string (),
message: z. string (),
details: z. unknown (). optional (),
});
export type ErrorBody = z . infer < typeof ErrorBody>; {
"compilerOptions" : {
"target" : "ES2022" ,
"module" : "ESNext" ,
"moduleResolution" : "Bundler" ,
"lib" : [ "ES2022" , "WebWorker" ],
"types" : [ "@cloudflare/workers-types" ],
"strict" : true ,
"noEmit" : true ,
"skipLibCheck" : true ,
"resolveJsonModule" : true ,
"isolatedModules" : true ,
"verbatimModuleSyntax" : true ,
"noUncheckedIndexedAccess" : true
},
"include" : [ "src/**/*" ]
} {
"$schema" : "node_modules/wrangler/config-schema.json" ,
"name" : "zod-edge-api-starter" ,
"main" : "src/index.ts" ,
"compatibility_date" : "2026-04-01" ,
"observability" : {
"enabled" : true
}
// 本番で D1 などを繋ぐときはここに bindings を足す。
// 本テンプレは型サンプルに寄せているので空構成。
} slug typescript-zod-edge-type-safe-api│ files 8 click a file in the tree to switch