📄
概念 📚 beginner-stepup

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.tsapp.onError で全エラーが JSON に変換される
  • ☐ 各ルートから try-catch が消え、throw だけで書けている
  • ☐ 5 種類(400/401/403/404/409)のエラーが curl で再現可能

参考

生きているコード

本ドキュメントで扱ったパターンの完全な動作コードは、メンター側リポジトリの参照ブランチで確認できます。

  • 対応 Week: W13
  • 参照ブランチ:
  • W13: reference/week-13
  • 対応 checklist 項目: M10

ブランチの作り方・見方は b00-curriculum-map を参照してください。

📚 beginner-stepup 全 53 章
導入編 13 章
  1. 1. 📄Web とは何か
  2. 2. 📄URL を打ってから画面が表示されるまで
  3. 3. 📄ネットワーク基礎(TCP/IP・DNS・HTTPS)
  4. 4. 📄【発展】物理層から通信が成立するまで(電力・Ethernet・Wi-Fi・Bluetooth)
  5. 5. 📄WSL2・Docker セットアップ詳細(Windows 向け)
  6. 6. 📄環境構築の段階的導入(macOS / Windows)
  7. 7. 📄カリキュラム全体マップ(Week × 教材 × 参照ブランチ × 要求チェックリスト)
  8. 8. 📄このカリキュラムの使い方(SQL・Python・Dify経験者向け)
  9. 9. 📄シェル・ターミナル基礎
  10. 10. 📄Windows で完全にゼロから始める開発環境構築(Week 1)
  11. 11. 📄Git基礎
  12. 12. 📄GitHubワークフロー
  13. 13. 📄パッケージ管理(pnpm workspace)
応用編 16 章
  1. 1. 📄AWSインフラ基礎
  2. 2. 📄AWS Budget Alert の設定(Month 5 Week 17)
  3. 3. 📄環境変数管理
  4. 4. 📄Bastion EC2 と SSH ProxyJump(Month 5 Week 18)
  5. 5. 📄CI/CD基礎
  6. 6. 📄ECR への Docker イメージ push と App EC2 デプロイ(Month 5 Week 19)
  7. 7. 📄テスト設計の基本
  8. 8. 📄CloudFront + S3 + ALB で公開する(Month 5 Week 20)
  9. 9. 📄CLAUDE.md・プロジェクト設定
  10. 10. 📄PR レビュー 5 観点ルーブリック(全 Week 共通)
  11. 11. 📄タスク分解・仕様の書き方
  12. 12. 📄Playwright で E2E テスト(Month 6 Week 22)
  13. 13. 📄生成コードのレビュー・デバッグの勘所
  14. 14. 📄Trivy で脆弱性スキャン(Month 6 Week 23)
  15. 15. 📄CloudWatch Logs の読み方と運用(Month 6 Week 23)
  16. 16. 📄PDF ポートフォリオの自動生成(Month 6 Week 24)