Automattic/mongoose · warning · MongooseError

Invalid arg "${arg}" to unwind(), must be string or object

Error message

Invalid arg "${arg}" to unwind(), must be string or object

What it means

When a model class is compiled (or loadClass() runs), applyMethods copies schema.methods onto the model prototype. If a method name is in schema.reserved (save, remove, populate, overwrite, deleteOne, ... — the same reserved list Document uses), overwriting it on the prototype can break Mongoose internals, so Mongoose warns. The warning can be suppressed per method with a third options argument. Self-referencing a built-in (schema.method('save', Document.prototype.save)) is silently deleted instead, to support classes extending Document.

Source

Thrown at lib/aggregate.js:467

 * @see $unwind https://www.mongodb.com/docs/manual/reference/aggregation/unwind/
 * @param {string|object|string[]|object[]} fields the field(s) to unwind, either as field names or as [objects with options](https://www.mongodb.com/docs/manual/reference/operator/aggregation/unwind/#document-operand-with-options). If passing a string, prefixing the field name with '$' is optional. If passing an object, `path` must start with '$'.
 * @return {Aggregate}
 * @api public
 */

Aggregate.prototype.unwind = function() {
  const args = [...arguments];

  const res = [];
  for (const arg of args) {
    if (arg && typeof arg === 'object') {
      res.push({ $unwind: arg });
    } else if (typeof arg === 'string') {
      res.push({
        $unwind: (arg[0] === '$') ? arg : '$' + arg
      });
    } else {
      throw new MongooseError('Invalid arg "' + arg + '" to unwind(), ' +
        'must be string or object');
    }
  }

  return this.append.apply(this, res);
};

/**
 * Appends a new $replaceRoot operator to this aggregate pipeline.
 *
 * Note that the `$replaceRoot` operator requires field strings to start with '$'.
 * If you are passing in a string Mongoose will prepend '$' if the specified field doesn't start '$'.
 * If you are passing in an object the strings in your expression will not be altered.
 *
 * #### Example:
 *
 *     aggregate.replaceRoot("user");
 *

View on GitHub (pinned to 49cdab0136)

Solutions

  1. Rename the custom method (e.g. save -> saveDraft, deleteOne -> archive) and keep built-ins intact.
  2. If you really intend to override, suppress explicitly: schema.method('save', fn, { suppressWarning: true }) — but test all internal call paths that rely on the original.
  3. For behavior to run around save/delete, prefer middleware: schema.pre('save', fn) / schema.post('findOneAndDelete', fn) instead of shadowing methods.
  4. With loadClass(), rename the class method or mark it static if it does not need an instance receiver.

Example fix

// before
schema.methods.save = function() { /* custom */ };

// after
schema.pre('save', function(next) { /* custom logic */ next(); });
// or, if overriding deliberately:
schema.method('save', function() { /* custom */ }, { suppressWarning: true });
Defensive patterns

Strategy: validation

Validate before calling

function addMethod(schema, name, fn) {
  if (schema.reserved && schema.reserved[name]) {
    throw new Error(`Method "${name}" is reserved by Mongoose; rename it or pass { suppressWarning: true }`);
  }
  schema.method(name, fn);
}

Prevention

When it happens

Trigger: schema.method('save', fn), schema.methods.deleteOne = fn, class methods named 'populate'/'remove'/'save' on a class passed to schema.loadClass(); defining a static-like helper on methods whose name matches a Document method without the suppressWarning option.

Common situations: Domain models that naturally want customSave/deleteOne semantics; porting classes with existing method names into Mongoose via loadClass; teams discovering collisions only after upgrading when the reserved list grew; wrapping save with pre-hooks instead would be the idiomatic fix.

Related errors


AI-assisted analysis of Automattic/mongoose@49cdab0136 (2026-08-21). Data as JSON: /api/errors/f1063b7422c29245. Report an issue: GitHub.