awesome-hacks
Docs

NestJSの例外処理

NotFoundExceptionなど標準例外を使いつつ、グローバルException FilterでAPIエラー形式を揃える

最終更新:2026/05/13

NestJSでCRUD APIを作る(Prisma + SQLite)まで終えている前提。

この記事でやること

  • Service層で NestのHTTP例外NotFoundException など)を投げる
  • Prismaの P2002(一意制約違反)などを クライアント向けメッセージ に変換する
  • @Catch()グローバルException Filter でレスポンス形式を統一する

「想定内のエラーなのに 500 Internal server error になってしまう」状態から脱して、フロントやモバイルが扱いやすいJSONに寄せるのが目的だ。

触るファイル一覧

今回は次の3ファイルを触る。

api-sample/
└─ src/
   ├─ main.ts
   ├─ common/
   │  └─ filters/
   │     └─ http-exception.filter.ts   # 新規作成
   └─ users/
      └─ users.service.ts              # 変更

users.controller.ts は基本的に変更しない。ControllerはServiceを呼ぶだけにして、例外の判断はServiceとFilterに寄せる。

1. users.service.ts を変更する

編集するファイルは api-sample/src/users/users.service.ts

NestJSでCRUD APIを作る(Prisma + SQLite)の時点では、存在しないIDでも findOnenull を返したり、メール重複時に 500 Internal server error になったりする。Prismaの詳細エラーはサーバーログには出るが、クライアントにはそのまま返らないことが多い。ここをHTTP APIとして扱いやすい例外に変換する。

この変更では findOne / create / update / removeasync を付けている。Prismaの findUnique / create / update / delete はPromiseを返すため、結果を見てから例外を投げたり、try/catch でPrismaエラーを捕まえたりするには await が必要になる。

import {
  ConflictException,
  Injectable,
  NotFoundException,
} from "@nestjs/common";
import { Prisma } from "../generated/prisma/client";
import { PrismaService } from "../prisma/prisma.service";
import { CreateUserDto } from "./dto/create-user.dto";
import { UpdateUserDto } from "./dto/update-user.dto";

@Injectable()
export class UsersService {
  constructor(private readonly prisma: PrismaService) {}

  findAll() {
    return this.prisma.user.findMany({ orderBy: { createdAt: "desc" } });
  }

  // 変更: 見つからない場合は null を返さず、404として扱う
  async findOne(id: string) {
    const user = await this.prisma.user.findUnique({ where: { id } });

    if (!user) {
      throw new NotFoundException("User not found");
    }

    return user;
  }

  // 変更: email重複(P2002)を 409 Conflict に変換する
  async create(dto: CreateUserDto) {
    try {
      return await this.prisma.user.create({ data: dto });
    } catch (e) {
      if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === "P2002") {
        throw new ConflictException("Email already in use");
      }

      throw e;
    }
  }

  // 変更: 更新前に存在確認する。存在しなければ findOne が404を投げる
  async update(id: string, dto: UpdateUserDto) {
    await this.findOne(id);
    return this.prisma.user.update({ where: { id }, data: dto });
  }

  // 変更: 削除前に存在確認する。存在しなければ findOne が404を投げる
  async remove(id: string) {
    await this.findOne(id);
    return this.prisma.user.delete({ where: { id } });
  }
}

findAll はPromiseをそのまま返してもNestJSが解決してレスポンスにしてくれるため、ここでは async にしていない。
一方で findOne は「取得結果が null かどうか」を見て分岐するので、await してから NotFoundException を投げている。createawait しないと try/catch でPrismaの P2002 を捕まえられない。

ここで押さえるPrismaのエラーコードはまず2つでよい。

  • P2002 … 一意制約違反(例: 同じ email を2回登録)
  • P2025 … 対象レコードが見つからない(update / delete で0件)

今回は update / remove の前に findOne で存在確認しているため、P2025 を直接扱わなくても404にできる。実務ではクエリ回数を減らすため、update / deleteP2025 をcatchして NotFoundException に変換する書き方もある。

2. 共通Exception Filterを新規作成する

新規作成するファイルは api-sample/src/common/filters/http-exception.filter.ts

目的は、Serviceで投げた NotFoundExceptionConflictException を、APIのレスポンスとして見やすいJSONに整えること。

import {
  ArgumentsHost,
  Catch,
  ExceptionFilter,
  HttpException,
  HttpStatus,
} from "@nestjs/common";

@Catch()
export class GlobalExceptionFilter implements ExceptionFilter {
  catch(exception: unknown, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const response = ctx.getResponse();
    const request = ctx.getRequest();

    // 追加: Nest標準のHTTP例外ならそのstatus、想定外なら500にする
    const status =
      exception instanceof HttpException
        ? exception.getStatus()
        : HttpStatus.INTERNAL_SERVER_ERROR;

    // 追加: NotFoundExceptionなどが持つレスポンス本文を取り出す
    const body =
      exception instanceof HttpException
        ? exception.getResponse()
        : { message: "Internal server error" };

    const message =
      typeof body === "string"
        ? body
        : (body as { message?: string | string[] }).message ?? "Unexpected error";

    // 追加: APIエラーの形を共通化する
    response.status(status).json({
      statusCode: status,
      message,
      path: request.url,
      timestamp: new Date().toISOString(),
    });
  }
}

@Catch() の引数を空にすると、すべての例外を拾う。
本番運用ではログ出力やエラーコード設計も考えるが、まずは「APIのエラー形式を揃える」ことを優先する。

3. main.ts でFilterを有効化する

編集するファイルは api-sample/src/main.ts

NestJS DTO / ValidationPipe 入門ValidationPipe を設定済みなら、その下あたりに useGlobalFilters を追加する。

import { ValidationPipe } from "@nestjs/common";
import { NestFactory } from "@nestjs/core";
import { AppModule } from "./app.module";
import { GlobalExceptionFilter } from "./common/filters/http-exception.filter"; // 追加

async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  app.useGlobalPipes(
    new ValidationPipe({
      whitelist: true,
      forbidNonWhitelisted: true,
      transform: true,
    }),
  );

  // 追加: Serviceなどで投げた例外を共通JSONに整形する
  app.useGlobalFilters(new GlobalExceptionFilter());

  await app.listen(process.env.PORT ?? 3000);
}
bootstrap();

useGlobalFilters はアプリ全体に効く。つまり UsersController だけでなく、今後追加する認証APIや別Controllerでも同じエラー形式にできる。

4. 動作確認

まずアプリを起動する。

pnpm run start:dev

npmで進めている場合は npm run start:dev でよい。

404を確認する

存在しないIDで取得する。

curl -s http://localhost:3000/users/not-exist

次のようなJSONになればOK。

{
  "statusCode": 404,
  "message": "User not found",
  "path": "/users/not-exist",
  "timestamp": "2026-05-13T00:00:00.000Z"
}

409を確認する

同じメールアドレスで2回作成する。

curl -s -X POST http://localhost:3000/users \
  -H "Content-Type: application/json" \
  -d '{"email":"taro@example.com","name":"Taro"}'

curl -s -X POST http://localhost:3000/users \
  -H "Content-Type: application/json" \
  -d '{"email":"taro@example.com","name":"Taro"}'

2回目が次のようなJSONになればOK。

{
  "statusCode": 409,
  "message": "Email already in use",
  "path": "/users",
  "timestamp": "2026-05-13T00:00:00.000Z"
}

実装タスク(チェックリスト)

  • findOne / update / removeNotFoundException
  • メール重複を 409 Conflict などにマッピング
  • GlobalExceptionFilter を追加し useGlobalFilters で登録
  • curl で404/409のJSON形を確認

次のステップ

NestJS Guard / JWT認証では、ここで揃えたエラー形式の上に 401 Unauthorized を載せる。

参考

NestJS Exception filters