何が起きたのか
GET /users/invalid_id のようなルートにアクセスしたとき — 壊れたリンク、URLの誤入力、フロントエンドのバグなどが原因で — クリーンな404レスポンスの代わりに、アプリがクラッシュします:
CastError: Cast to ObjectId failed for value "invalid_id" (type string) at path "_id" for model "User"
Mongooseは文字列 "invalid_id" をMongoDB ObjectIdにキャストしようとしました。その文字列が有効な24文字の16進数値ではないため、失敗しました。try/catchやグローバルエラーハンドラーがなければ、拒否されたPromiseは未処理のままとなり、リクエストが落ちます。
なぜこうなるのか
MongoDB ObjectIdは12バイトの値で、通常 507f1f77bcf86cd799439011 のような24文字の16進数文字列として表されます。スキーマフィールドが ObjectId 型として定義されている場合、Mongooseは渡された値を自動的にキャストしようとします。無効な値の場合は、クエリがMongoDBに到達する前に即座に CastError がスローされます。
よくある原因:
- ルートパラメーターとして
"me"、"current"などの文字列エイリアスを使用している(例:/users/me) - ObjectIdが期待される場所にUUIDやスラッグを渡している
- フロントエンドのリクエストから不正な形式のIDが送られてくる
- テスト中にIDをコピー&ペーストして切り詰めてしまった(例:24文字ではなく20文字)
- レガシーシステムからの数値IDがMongooseモデルに転送されている
クイックフィックス — クエリ前にバリデーションする
IDがMongooseに到達する前にチェックします。isValid() を一度呼び出すだけで十分です:
const mongoose = require('mongoose');
async function getUserById(req, res) {
const { id } = req.params;
if (!mongoose.Types.ObjectId.isValid(id)) {
return res.status(404).json({ error: 'User not found' });
}
const user = await User.findById(id);
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
res.json(user);
}
400ではなく404を返すのは意図的です。クライアントの視点では、そのIDを持つリソースは単純に存在しません。バリデーションが失敗したことを公開しても何の価値もなく、攻撃対象を若干広げるだけです。
恒久的な対策 — ミドルウェアによるバリデーション
すべてのコントローラーに isValid() チェックをコピーするのはすぐに面倒になります。代わりにミドルウェアに切り出しましょう:
// middleware/validateObjectId.js
const mongoose = require('mongoose');
function validateObjectId(req, res, next) {
if (!mongoose.Types.ObjectId.isValid(req.params.id)) {
return res.status(404).json({ error: 'Resource not found' });
}
next();
}
module.exports = validateObjectId;
次に、ルーターに組み込みます:
const express = require('express');
const router = express.Router();
const validateObjectId = require('../middleware/validateObjectId');
router.get('/users/:id', validateObjectId, getUserById);
router.put('/users/:id', validateObjectId, updateUser);
router.delete('/users/:id', validateObjectId, deleteUser);
特殊ケースの対処:/users/me
非常によくあるシナリオとして、/me ルートと /:id を併用するケースがあります。Expressが先に /:id を評価すると、"me" がハンドラーに渡されてObjectIdのキャストが失敗します。ルートの順序を修正しましょう:
// 特定のルートをパラメーター化されたルートの前に配置する
router.get('/users/me', authMiddleware, getCurrentUser); // 先にマッチする
router.get('/users/:id', validateObjectId, getUserById); // フォールバック
安全網としてのグローバルエラーハンドラー
バリデーションはほとんどのケースをカバーしますが、漏れることもあります。グローバルエラーハンドラーは最後の砦です — トップに到達した CastError をキャッチし、スタックトレースがレスポンスに漏れないようにします:
// app.js — すべてのルートの後
app.use((err, req, res, next) => {
if (err.name === 'CastError' && err.kind === 'ObjectId') {
return res.status(404).json({ error: 'Resource not found' });
}
console.error(err);
res.status(500).json({ error: 'Internal server error' });
});
これはバリデーションの代替ではなく、最後の手段のキャッチです。両方を使いましょう。
ExpressのAsync/awaitには落とし穴がある
express-async-errors またはラッパー関数がないと、asyncハンドラー内で未処理のPromise拒否はグローバルエラーミドルウェアに届きません。手動でtry/catchを追加するか、パッケージをインストールしましょう:
npm install express-async-errors
// app.js の先頭、ルートより前
require('express-async-errors');
この1行でExpressにパッチが当たり、asyncハンドラー内でスローされたエラーが自動的にエラーミドルウェアに転送されます。ラッパーのボイラープレートは不要です。
修正の確認
curlまたはHTTPクライアントでテストします:
# 500ではなく404が返るべき
curl -i http://localhost:3000/users/invalid_id
# ユーザーが見つかれば返し、見つからなければ404
curl -i http://localhost:3000/users/507f1f77bcf86cd799439011
無効なIDに対する期待されるレスポンス:
HTTP/1.1 404 Not Found
{ "error": "Resource not found" }
レスポンスにスタックトレースなし、サーバーログにクラッシュなし。これが目標です。
ボーナス:再利用可能なisValidObjectIdユーティリティ
ObjectIdが必要なのはルートハンドラーだけではありません。サービス層、バックグラウンドジョブ、データパイプライン — すべて同じチェックが必要です。小さなユーティリティで一貫性を保ちましょう:
// utils/isValidObjectId.js
const mongoose = require('mongoose');
const isValidObjectId = (id) => mongoose.Types.ObjectId.isValid(id);
module.exports = isValidObjectId;
// 使用例
const isValidObjectId = require('./utils/isValidObjectId');
if (!isValidObjectId(someId)) {
throw new Error(`Invalid ID: ${someId}`);
}

