【Express】エラーハンドリングミドルウェアで一元的にエラーを処理する(よくあるエラーの実例つき)

Expressjs

こんにちは、かつコーチです。

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.bodyundefinedになったら、まずミドルウェアの登録漏れを疑うと解決が早くなります。

エラー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/catchasyncHandlerのどちらかを適用する、というルールをチーム内で徹底することをおすすめします。

まとめ

この記事のポイント

  • エラーハンドリングミドルウェアは(err, req, res, next)という4引数の関数で定義する
  • すべてのルート定義の後、app.listen()の前に登録する
  • 非同期関数のエラーは自動でキャッチされないため、try/catchasyncHandlerで明示的にnext(err)へ渡す
  • 「Cannot set headers after they are sent」は、条件分岐でreturnを忘れることが主な原因
  • 「req.bodyがundefined」は、express.json()の登録漏れが典型的な原因
  • 非同期処理の未処理エラーはサーバーの不安定化につながるため、必ずキャッチする設計にする

次に読むべき記事

  • ミドルウェアとは?express.json()やカスタムミドルウェアの作り方
  • シンプルなREST APIを作る(CRUD実装)
  • ExpressアプリをRenderにデプロイする方法

タグ: Express, 中級者向け, エラー解決

タイトルとURLをコピーしました