こんにちは、かつコーチです。
Expressで開発をしていると、必ずと言っていいほどエラーに遭遇します。
私自身も学習中に何度もサーバーをクラッシュさせたり、原因不明のエラーに数時間悩まされたりしてきました。
この記事では、そんな実体験も交えながら、エラーハンドリングミドルウェアを使ってエラーを一元管理する方法を解説します。
さらに、初中級者がつまずきやすい代表的なエラーを、実際のエラーメッセージ付きでBefore/Afterで紹介します。
読み終えるころには、「エラーが起きても落ち着いて対処できる」状態を目指します。
基本の書き方 / 実装手順
エラーハンドリングミドルウェアとは
Expressには、他のミドルウェアとは形が違う、特別なミドルウェアがあります。
それがエラーハンドリングミドルウェアです。
通常のミドルウェアは(req, res, next)という3つの引数を取りますが、エラーハンドリングミドルウェアは(err, req, res, next)という4つの引数を取ります。
// エラーハンドリングミドルウェア(4引数であることが重要)
function errorHandler(err, req, res, next) {
console.error(err.stack);
res.status(err.status || 500).json({
error: err.message || 'サーバー内部でエラーが発生しました',
});
}
Expressは、引数の数が4つの関数を見つけると、「これはエラー処理専用の関数だ」と自動的に判断します。
このミドルウェアは、すべてのルート定義よりも後、かつapp.listen()より前に登録する必要があります。
const express = require('express');
const app = express();
app.use(express.json());
app.get('/users/:id', (req, res, next) => {
const user = users.find((u) => u.id === Number(req.params.id));
if (!user) {
// エラーオブジェクトを作ってnextに渡す
const err = new Error('ユーザーが見つかりません');
err.status = 404;
return next(err);
}
res.json(user);
});
// ルート定義の後、listenの前に登録する
app.use(errorHandler);
app.listen(3000);
next(err)のように、nextに引数を渡して呼び出すと、Expressは通常のミドルウェアをすべて飛ばして、エラーハンドリングミドルウェアに処理を渡します。
こうすることで、「エラー処理をどこか1箇所に集約する」という設計が実現できます。
各ルートでtry/catchを書いてレスポンスを組み立てる必要がなくなり、コードの重複を大きく減らせます。
非同期関数のエラーをキャッチする方法
ここが、初中級者が最もつまずきやすいポイントです。
同期的な処理でエラーが起きた場合、Expressは自動的にエラーハンドリングミドルウェアに処理を渡してくれます。
しかし、async/awaitを使った非同期処理の中で発生したエラーは、Expressが自動的には検知してくれません。
// ❌ 非同期関数内のエラーがキャッチされない例
app.get('/users/:id', async (req, res, next) => {
const user = await db.findUser(req.params.id); // ここでエラーが起きても
if (!user) {
throw new Error('ユーザーが見つかりません'); // next()に渡らない
}
res.json(user);
});
このコードでは、throwされたエラーがnext()に渡らないため、エラーハンドリングミドルウェアが呼ばれず、リクエストが宙ぶらりんになります。
対処法は主に2つあります。
1つ目は、try/catchで明示的にnext(err)を呼ぶ方法です。
// ✅ try/catchでnext(err)を呼ぶ
app.get('/users/:id', async (req, res, next) => {
try {
const user = await db.findUser(req.params.id);
if (!user) {
const err = new Error('ユーザーが見つかりません');
err.status = 404;
throw err;
}
res.json(user);
} catch (err) {
next(err); // エラーハンドリングミドルウェアに渡す
}
});
2つ目は、非同期処理をラップするヘルパー関数を用意する方法です。
すべてのルートにtry/catchを書くのは冗長なので、実務ではこちらがよく使われます。
// 非同期ルートをラップするヘルパー関数
function asyncHandler(fn) {
return (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
}
app.get('/users/:id', asyncHandler(async (req, res) => {
const user = await db.findUser(req.params.id);
if (!user) {
const err = new Error('ユーザーが見つかりません');
err.status = 404;
throw err;
}
res.json(user);
}));
asyncHandlerで包むだけで、try/catchを毎回書かなくてもエラーが自動的にエラーハンドリングミドルウェアへ渡るようになります。
よくあるつまずきポイント・エラー対処
ここからは、実際に私がハマった経験も含めて、初中級者がよく遭遇するエラーを3つ紹介します。
エラー1:Cannot set headers after they are sent
これは、私が最初にExpressでハマったエラーの1つです。
// ❌ Before:条件分岐後もres.send()が実行され続けてしまう
app.get('/users/:id', (req, res) => {
const user = users.find((u) => u.id === Number(req.params.id));
if (!user) {
res.status(404).json({ error: 'ユーザーが見つかりません' });
// ここでreturnしていないため、処理が続いてしまう
}
res.json(user); // userがundefinedでもここが実行される
});
実際に発生したエラーメッセージは以下の通りです。
Error [ERR_HTTP_HEADERS_SENT]: Cannot set headers after they are sent to the client
原因は、ifブロックの中でres.json()を呼んだ後にreturnをせず、そのまま処理が続いてしまい、res.json()が2回実行されてしまうことでした。
レスポンスは1回のリクエストに対して1回しか送れないため、2回目の送信でこのエラーが発生します。
// ✅ After:条件分岐にreturnを入れて、処理をそこで終わらせる
app.get('/users/:id', (req, res) => {
const user = users.find((u) => u.id === Number(req.params.id));
if (!user) {
return res.status(404).json({ error: 'ユーザーが見つかりません' });
}
res.json(user);
});
res.send()やres.json()を条件分岐の中で使うときは、必ずreturnを付ける習慣をつけると、このエラーを防げます。
エラー2:req.bodyがundefinedになる
これも実際に私が経験した、原因が分かりにくいエラーです。
// ❌ Before:express.json()を登録し忘れている
const express = require('express');
const app = express();
app.post('/login', (req, res) => {
const { email, password } = req.body; // req.body自体がundefined
console.log(email, password);
res.send('OK');
});
このコードを実行し、req.bodyを使おうとすると、以下のエラーが発生します。
TypeError: Cannot destructure property 'email' of 'req.body' as it is undefined.
Postmanで正しくJSONを送っているはずなのに、なぜかreq.bodyが読めない、という状態に私も遭遇し、30分近く原因を探した経験があります。
原因は単純で、app.use(express.json())を登録し忘れていただけでした。
// ✅ After:express.json()を登録する
const express = require('express');
const app = express();
app.use(express.json()); // これを忘れずに登録する
app.post('/login', (req, res) => {
const { email, password } = req.body;
console.log(email, password);
res.send('OK');
});
req.bodyがundefinedになったら、まずミドルウェアの登録漏れを疑うと解決が早くなります。
エラー3:非同期エラーでアプリがクラッシュする
最後に紹介するのは、非同期処理のエラーをnext()に渡さなかったために、サーバー全体が落ちてしまうケースです。
// ❌ Before:非同期処理のエラーがどこにも渡らない
app.get('/data', async (req, res) => {
const data = await fetchDataFromSomewhere(); // ここで例外が発生すると
res.json(data);
});
fetchDataFromSomewhere()が失敗した場合、このthrowされたエラーはどこにもキャッチされません。
Node.jsのバージョンや環境によっては、以下のようなUnhandledPromiseRejectionの警告がターミナルに出力されます。
UnhandledPromiseRejectionWarning: Error: データの取得に失敗しました
開発中は気づきにくいものの、本番環境ではリクエストがタイムアウトするまで応答が返らず、ユーザー体験を大きく損ないます。
// ✅ After:asyncHandlerでラップしてエラーを確実に拾う
app.get('/data', asyncHandler(async (req, res) => {
const data = await fetchDataFromSomewhere();
res.json(data);
}));
// エラーハンドリングミドルウェア側で一元的に処理する
app.use((err, req, res, next) => {
console.error(err.stack);
res.status(500).json({ error: 'データの取得に失敗しました' });
});
非同期関数を使うルートには、必ずtry/catchかasyncHandlerのどちらかを適用する、というルールをチーム内で徹底することをおすすめします。
まとめ
この記事のポイント
- エラーハンドリングミドルウェアは
(err, req, res, next)という4引数の関数で定義する - すべてのルート定義の後、
app.listen()の前に登録する - 非同期関数のエラーは自動でキャッチされないため、
try/catchかasyncHandlerで明示的にnext(err)へ渡す - 「Cannot set headers after they are sent」は、条件分岐で
returnを忘れることが主な原因 - 「req.bodyがundefined」は、
express.json()の登録漏れが典型的な原因 - 非同期処理の未処理エラーはサーバーの不安定化につながるため、必ずキャッチする設計にする
次に読むべき記事
- ミドルウェアとは?express.json()やカスタムミドルウェアの作り方
- シンプルなREST APIを作る(CRUD実装)
- ExpressアプリをRenderにデプロイする方法
タグ: Express, 中級者向け, エラー解決