NestJSの例外処理
NotFoundExceptionなど標準例外を使いつつ、グローバルException FilterでAPIエラー形式を揃える
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でも findOne が null を返したり、メール重複時に 500 Internal server error になったりする。Prismaの詳細エラーはサーバーログには出るが、クライアントにはそのまま返らないことが多い。ここをHTTP APIとして扱いやすい例外に変換する。
この変更では findOne / create / update / remove に async を付けている。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 を投げている。create も await しないと try/catch でPrismaの P2002 を捕まえられない。
ここで押さえるPrismaのエラーコードはまず2つでよい。
P2002… 一意制約違反(例: 同じemailを2回登録)P2025… 対象レコードが見つからない(update/deleteで0件)
今回は update / remove の前に findOne で存在確認しているため、P2025 を直接扱わなくても404にできる。実務ではクエリ回数を減らすため、update / delete の P2025 をcatchして NotFoundException に変換する書き方もある。
2. 共通Exception Filterを新規作成する
新規作成するファイルは api-sample/src/common/filters/http-exception.filter.ts。
目的は、Serviceで投げた NotFoundException や ConflictException を、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:devnpmで進めている場合は 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/removeでNotFoundException - メール重複を
409 Conflictなどにマッピング -
GlobalExceptionFilterを追加しuseGlobalFiltersで登録 -
curlで404/409のJSON形を確認
次のステップ
NestJS Guard / JWT認証では、ここで揃えたエラー形式の上に 401 Unauthorized を載せる。