Mongooseで.lean()使用時に発生する「TypeError: doc.save is not a function」の解決方法

beginner🍃 MongoDB2026-06-08| Node.js (v14+), Mongoose (v5.x, v6.x, v7.x), MongoDB (v4.4+)

Error Message

TypeError: doc.save is not a function
#mongoose#mongodb#nodejs#javascript#backend

問題点:パフォーマンス vs 機能性これはよくあるパフォーマンスの最適化手法です。実行時間を短縮し、メモリのオーバーヘッドを削減するために、Mongooseのクエリに .lean() をチェーンします。デフォルトでは、Mongooseドキュメントは重いです。内部状態、バリデーションロジック、変更追跡フックなどを保持しているため、標準的な JSON オブジェクトに比べてメモリを最大10倍も消費することがあります。

トラブルは、その軽量なオブジェクトを完全な Mongoose ドキュメントとして扱おうとしたときに発生します。プロパティを変更して doc.save() を呼び出すと、アプリケーションがクラッシュします:

TypeError: doc.save is not a function

これは、.lean() が Mongoose に対して、完全な Mongoose Document インスタンスを作成するプロセスをスキップするように指示するためです。代わりに、Plain Old JavaScript Object (POJO) を返します。これらのオブジェクトは高速ですが、.save().populate().validate() といった Mongoose 特有の「マジック」は備わっていません。

デバッグプロセス.lean() が原因であることを確認するには、save メソッドを呼び出す前にドキュメントのコンストラクタを調べてください。標準の Mongoose ドキュメントには $__isNew といった内部プロパティが含まれていますが、lean オブジェクトにはこれらが存在しません。

const user = await User.findOne({ email: 'dev@example.com' }).lean();

console.log(user instanceof mongoose.Document); // 戻り値: false
console.log(user.constructor.name); // 戻り値: Object ('model' ではなく)

user.name = 'Updated Name';
await user.save(); // ここで TypeError が発生

出力がプレーンな Object を示している場合、プロトタイプチェーンは失われています。そのオブジェクトに対して Mongoose 固有のメソッドは一切使用できません。

解決策1:直接的な修正(.lean() を削除する)最も簡単な解決策は、ドキュメントを変更して保存する予定がある場合に .lean() を削除することです。.lean() を省略すると、Mongoose はすべてのメソッドが揃った完全なドキュメントを返します。

// 修正前
const user = await User.findById(id).lean();

// 修正後
const user = await User.findById(id);
user.status = 'active';
await user.save();

pre('save') フックなどの Mongoose ミドルウェアを実行する必要がある場合や、複雑なスキーマバリデーションに依存している場合は、この方法を選択してください。

解決策2:アトミックな更新メソッドを使用する最初の取得では .lean() のスピードを活かしたい場合や、データベース内のフラグを1つだけ切り替えればよいという場合もあります。このような場合は、findOneAndUpdate または updateOne を使用します。これらのメソッドはドキュメントインスタンスを完全にバイパスし、MongoDB と直接通信します。

const userId = '60d5ec...';

// 1. 高速な読み取り専用ロジックのために lean で取得
const user = await User.findById(userId).lean();

// 2. モデルを介して直接更新
await User.updateOne({ _id: userId }, { $set: { status: 'active' } });

これは多くの場合、最も効率的なルートです。バリデーション、フック、変更追跡といった Mongoose ドキュメントのライフサイクルによる CPU オーバーヘッドを回避できるため、高トラフィックな書き込み操作に最適です。

解決策3:オブジェクトの再水和(高度な手法)キャッシュ(Redis など)から取得したプレーンなオブジェクトを、突然 Mongoose ドキュメントに戻す必要が生じることが稀にあります。このような特殊なケースでは、model.hydrate() を使用します。

const leanDoc = await User.findOne({ name: 'Alice' }).lean();

// POJO を完全な Mongoose Document に変換
const doc = User.hydrate(leanDoc);

doc.status = 'inactive';
await doc.save(); // これで動作します!

hydrate() は、そのドキュメントがすでにデータベースに存在していると Mongoose が想定する状態で作成されることに注意してください。手動で状態を切り替えない限り、isNew ロジックはトリガーされません。

検証:更新の確認修正を適用したら、データが実際に MongoDB コレクションに反映されたか確認しましょう。以下の方法で検証できます:

  • await doc.save() から返されたオブジェクトを確認する。更新されたフィールドが含まれているはずです。- MongoDB Compass などの GUI や Mongo シェルを使用して、db.users.find({ _id: ... }) を実行する。- 呼び出しの直後に成功メッセージをログに出力し、イベントループがハングしていないことを確認する。## まとめ- Lean は読み取り用: .lean() は、パフォーマンスが優先される GET API、ダッシュボードのエクスポート、読み取り中心のテンプレート用にとっておきましょう。- プロトタイプが重要: .save() などのメソッドは Document クラスのプロトタイプに存在します。プレーンなオブジェクトはそれらを継承しません。- 適切なツールを選ぶ: 1つのフィールドを更新するだけなら、find -> modify -> save パターンよりも updateOne の方が高速で、メモリ消費も少なくなります。

Related Error Notes