Hono のエラーハンドリング(Month 4 Week 13)
try-catch・カスタムエラークラス・グローバルエラーハンドラ・HTTP ステータスコードの使い分けを、Hono ミドルウェアで実装するパターン
Week 13 で Hono のルートを本格実装し始めると、すぐに「どこで何をキャッチして、どう返すか」問題にぶつかります。エラーハンドリングの設計が弱いと、フロント側で「API が何を伝えようとしているのか」分からず、画面が固まります。
本ドキュメントは 3 層でエラーを扱う設計 を提示します。
3 層エラー設計
┌─────────────────────────────────────┐
│ 1. ルートハンドラ(routes/*.ts) │
│ ↓ throw │
├─────────────────────────────────────┤
│ 2. カスタムエラー(lib/errors.ts) │
│ ↓ catch │
├─────────────────────────────────────┤
│ 3. グローバルハンドラ(app.onError) │
│ → JSON レスポンスに変換 │
└─────────────────────────────────────┘
- ルートはドメインロジックに集中し、異常は
throwするだけ - カスタムエラークラスが HTTP ステータスとエラーコードを表現
- グローバルハンドラが一括で JSON に整形
この分離により、ルート側の try-catch が消えてコードが読みやすくなります。
Step 1: カスタムエラークラス
api/src/lib/errors.ts:
// Hono のエラーを拡張。status / code / details を保持する
export class AppError extends Error {
constructor(
public readonly status: number,
public readonly code: string,
message: string,
public readonly details?: unknown,
) {
super(message);
this.name = "AppError";
}
}
export class ValidationError extends AppError {
constructor(message: string, details?: unknown) {
super(400, "VALIDATION_FAILED", message, details);
}
}
export class UnauthorizedError extends AppError {
constructor(message = "認証が必要です") {
super(401, "UNAUTHORIZED", message);
}
}
export class ForbiddenError extends AppError {
constructor(message = "この操作を行う権限がありません") {
super(403, "FORBIDDEN", message);
}
}
export class NotFoundError extends AppError {
constructor(resource: string, id?: string) {
super(404, "NOT_FOUND", `${resource} が見つかりません${id ? `: ${id}` : ""}`);
}
}
export class ConflictError extends AppError {
constructor(message: string) {
super(409, "CONFLICT", message);
}
}
Why クラス化するか: エラーの種類ごとに instanceof で分岐できる。status を毎回書かずに throw new NotFoundError("Post", id) と書ける。
Step 2: ルートでは throw するだけ
api/src/routes/posts.ts:
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod";
import { db } from "../db";
import { posts } from "../db/schema";
import { eq } from "drizzle-orm";
import { NotFoundError, ForbiddenError } from "../lib/errors";
import { authMiddleware, type AuthedContext } from "../middleware/auth";
const postsRouter = new Hono<AuthedContext>();
const createPostSchema = z.object({
content: z.string().min(1).max(280),
});
postsRouter.post("/", authMiddleware, zValidator("json", createPostSchema), async (c) => {
const { content } = c.req.valid("json");
const userId = c.get("userId");
const [post] = await db
.insert(posts)
.values({ content, authorId: userId })
.returning();
return c.json(post, 201);
});
postsRouter.get("/:id", async (c) => {
const id = c.req.param("id");
const [post] = await db.select().from(posts).where(eq(posts.id, id));
if (!post) throw new NotFoundError("Post", id);
return c.json(post);
});
postsRouter.delete("/:id", authMiddleware, async (c) => {
const id = c.req.param("id");
const userId = c.get("userId");
const [post] = await db.select().from(posts).where(eq(posts.id, id));
if (!post) throw new NotFoundError("Post", id);
if (post.authorId !== userId) throw new ForbiddenError();
await db.delete(posts).where(eq(posts.id, id));
return c.body(null, 204);
});
export { postsRouter };
ポイント: try-catch が 1 つもありません。異常は全て throw。
Step 3: グローバルエラーハンドラ
api/src/index.ts:
import { Hono } from "hono";
import { HTTPException } from "hono/http-exception";
import { ZodError } from "zod";
import { AppError } from "./lib/errors";
const app = new Hono();
// ... ルーター登録 ...
// 全エラーをここで一括処理
app.onError((err, c) => {
// Zod バリデーションエラー(@hono/zod-validator が throw する)
if (err instanceof ZodError) {
return c.json(
{
error: "VALIDATION_FAILED",
message: "入力値が不正です",
details: err.flatten(),
},
400,
);
}
// 自前のカスタムエラー
if (err instanceof AppError) {
return c.json(
{
error: err.code,
message: err.message,
details: err.details,
},
err.status as 400 | 401 | 403 | 404 | 409,
);
}
// Hono の標準 HTTPException
if (err instanceof HTTPException) {
return err.getResponse();
}
// 想定外のエラー(DB 接続失敗など)
console.error("[unhandled]", err);
return c.json(
{
error: "INTERNAL_ERROR",
message: "サーバー内部でエラーが発生しました",
},
500,
);
});
export default app;
レスポンス形式の統一
全エラーで同じ形式を返します:
{
"error": "NOT_FOUND",
"message": "Post が見つかりません: abc123",
"details": null
}
フロント側は error コードで分岐できます:
// web/src/lib/api.ts
export async function fetchApi(path: string, init?: RequestInit) {
const res = await fetch(path, init);
if (!res.ok) {
const body = await res.json();
throw new ApiError(res.status, body.error, body.message, body.details);
}
return res.json();
}
認証ミドルウェアとの連携
api/src/middleware/auth.ts:
import { createMiddleware } from "hono/factory";
import { verifyToken } from "../lib/auth";
import { UnauthorizedError } from "../lib/errors";
export type AuthedContext = {
Variables: { userId: string };
};
export const authMiddleware = createMiddleware<AuthedContext>(async (c, next) => {
const auth = c.req.header("Authorization");
if (!auth?.startsWith("Bearer ")) {
throw new UnauthorizedError("Bearer トークンが必要です");
}
const token = auth.slice("Bearer ".length);
try {
const { userId } = await verifyToken(token);
c.set("userId", userId);
await next();
} catch {
throw new UnauthorizedError("トークンが無効または期限切れです");
}
});
throw new UnauthorizedError するだけで、グローバルハンドラが 401 JSON を返します。
よく使うパターン
パターン 1: リソース存在チェック + 所有者チェック
const [post] = await db.select().from(posts).where(eq(posts.id, id));
if (!post) throw new NotFoundError("Post", id);
if (post.authorId !== userId) throw new ForbiddenError();
404 と 403 のどちらを返すかは設計判断です。存在隠蔽 したい場合(他人の非公開投稿を探索されたくない等)は、存在しても 404 を返します。
パターン 2: 重複登録の検知
const [existing] = await db.select().from(users).where(eq(users.email, email));
if (existing) throw new ConflictError("このメールアドレスは既に登録されています");
パターン 3: DB の制約違反を拾う
Drizzle(postgres.js)は unique 制約違反で PostgresError を throw します:
try {
await db.insert(users).values({ email, passwordHash });
} catch (err: any) {
if (err.code === "23505") {
// PostgreSQL unique_violation
throw new ConflictError("このメールアドレスは既に登録されています");
}
throw err;
}
この変換を リポジトリ層(後の Chantier 8 で扱う DDD に昇格した場合)に閉じ込めると、ルート側はさらに綺麗になります。
ログの取り方
console.error は最低限。本番では構造化ログ(pino 等)に置き換えます。
app.onError((err, c) => {
console.error({
path: c.req.path,
method: c.req.method,
error: err.name,
message: err.message,
stack: err.stack,
});
// ...
});
AWS にデプロイしたら CloudWatch Logs に流れます(b23-cloudwatch-logs で扱います)。
やってはいけないこと
- catch して無視:
try { ... } catch {}は最悪。最低でも console.error - 生の Error メッセージをフロントに返す: SQL 文や内部パスが漏れる
- 500 エラーに分類される例外を 200 で返す: 「エラーだけどボディは
{ success: false }」形式は REST では非推奨。ステータスコードで伝える - 全部 400 で返す: 401 / 403 / 404 / 409 を使い分けると、フロントでの分岐が楽になる
Week 13 のアウトプット
- ☐
api/src/lib/errors.tsにカスタムエラークラスが定義されている - ☐
api/src/index.tsのapp.onErrorで全エラーが JSON に変換される - ☐ 各ルートから try-catch が消え、throw だけで書けている
- ☐ 5 種類(400/401/403/404/409)のエラーが curl で再現可能
参考
- Hono 公式 Error Handling: https://hono.dev/api/exception
- PostgreSQL エラーコード一覧: https://www.postgresql.org/docs/current/errcodes-appendix.html
- 前提: b07-auth-patterns(認証ミドルウェア)/ b13-api-design(API 設計)
- 次: b12b-form-validation(フロント側の入力検証)
生きているコード
本ドキュメントで扱ったパターンの完全な動作コードは、メンター側リポジトリの参照ブランチで確認できます。
- 対応 Week: W13
- 参照ブランチ:
- W13:
reference/week-13 - 対応 checklist 項目: M10
ブランチの作り方・見方は b00-curriculum-map を参照してください。
- 1. 📄Web とは何か
- 2. 📄URL を打ってから画面が表示されるまで
- 3. 📄ネットワーク基礎(TCP/IP・DNS・HTTPS)
- 4. 📄【発展】物理層から通信が成立するまで(電力・Ethernet・Wi-Fi・Bluetooth)
- 5. 📄WSL2・Docker セットアップ詳細(Windows 向け)
- 6. 📄環境構築の段階的導入(macOS / Windows)
- 7. 📄カリキュラム全体マップ(Week × 教材 × 参照ブランチ × 要求チェックリスト)
- 8. 📄このカリキュラムの使い方(SQL・Python・Dify経験者向け)
- 9. 📄シェル・ターミナル基礎
- 10. 📄Windows で完全にゼロから始める開発環境構築(Week 1)
- 11. 📄Git基礎
- 12. 📄GitHubワークフロー
- 13. 📄パッケージ管理(pnpm workspace)
- 1. 📄Reactコンポーネント設計の基礎
- 2. 📄状態管理の概念
- 3. 📄バックエンドAPI設計(Hono)
- 4. 📄ルーティング(React Router v7)
- 5. 📄Hono のエラーハンドリング(Month 4 Week 13)
- 6. 📄データベース設計・SQL→Drizzle ORM対応
- 7. 📄マイグレーションの考え方
- 1. 📄AWSインフラ基礎
- 2. 📄AWS Budget Alert の設定(Month 5 Week 17)
- 3. 📄環境変数管理
- 4. 📄Bastion EC2 と SSH ProxyJump(Month 5 Week 18)
- 5. 📄CI/CD基礎
- 6. 📄ECR への Docker イメージ push と App EC2 デプロイ(Month 5 Week 19)
- 7. 📄テスト設計の基本
- 8. 📄CloudFront + S3 + ALB で公開する(Month 5 Week 20)
- 9. 📄CLAUDE.md・プロジェクト設定
- 10. 📄PR レビュー 5 観点ルーブリック(全 Week 共通)
- 11. 📄タスク分解・仕様の書き方
- 12. 📄Playwright で E2E テスト(Month 6 Week 22)
- 13. 📄生成コードのレビュー・デバッグの勘所
- 14. 📄Trivy で脆弱性スキャン(Month 6 Week 23)
- 15. 📄CloudWatch Logs の読み方と運用(Month 6 Week 23)
- 16. 📄PDF ポートフォリオの自動生成(Month 6 Week 24)