The model.discriminator() function

Discriminators are a schema inheritance mechanism. They enable you to have multiple models with overlapping schemas on top of the same underlying MongoDB collection.

Suppose you wanted to track different types of events in a single collection. Every event will have a timestamp, but events that represent clicked links should have a URL. You can achieve this using the model.discriminator() function. This function takes 3 parameters, a model name, a discriminator schema and an optional key (defaults to the model name). It returns a model whose schema is the union of the base schema and the discriminator schema.

const options = {discriminatorKey: 'kind'};

const eventSchema = new mongoose.Schema({time: Date}, options);
const Event = mongoose.model('Event', eventSchema);

// ClickedLinkEvent is a special type of Event that has
// a URL.
const ClickedLinkEvent = Event.discriminator('ClickedLink',
  new mongoose.Schema({url: String}, options));

// When you create a generic event, it can't have a URL field...
const genericEvent = new Event({time: Date.now(), url: 'google.com'});
assert.ok(!genericEvent.url);

// But a ClickedLinkEvent can
const clickedEvent =
  new ClickedLinkEvent({time: Date.now(), url: 'google.com'});
assert.ok(clickedEvent.url);

Discriminators save to the Event model's collection

Suppose you created another discriminator to track events where a new user registered. These SignedUpEvent instances will be stored in the same collection as generic events and ClickedLinkEvent instances.

const event1 = new Event({time: Date.now()});
const event2 = new ClickedLinkEvent({time: Date.now(), url: 'google.com'});
const event3 = new SignedUpEvent({time: Date.now(), user: 'testuser'});

const save = function (doc, callback) {
  doc.save(function (error, doc) {
    callback(error, doc);
  });
};

Promise.all([event1.save(), event2.save(), event3.save()]).
  then(() => Event.countDocuments()).
  then(count => {
    assert.equal(count, 3);
  });

Discriminator keys

The way mongoose tells the difference between the different discriminator models is by the 'discriminator key', which is __t by default. Mongoose adds a String path called __t to your schemas that it uses to track which discriminator this document is an instance of.

const event1 = new Event({time: Date.now()});
const event2 = new ClickedLinkEvent({time: Date.now(), url: 'google.com'});
const event3 = new SignedUpEvent({time: Date.now(), user: 'testuser'});

assert.ok(!event1.__t);
assert.equal(event2.__t, 'ClickedLink');
assert.equal(event3.__t, 'SignedUp');

Discriminators add the discriminator key to queries

Discriminator models are special; they attach the discriminator key to queries. In other words, find(), count(), aggregate(), etc. are smart enough to account for discriminators.

const event1 = new Event({time: Date.now()});
const event2 = new ClickedLinkEvent({time: Date.now(), url: 'google.com'});
const event3 = new SignedUpEvent({time: Date.now(), user: 'testuser'});

Promise.all([event1.save(), event2.save(), event3.save()]).
  then(() => ClickedLinkEvent.find({})).
  then(docs => {
    assert.equal(docs.length, 1);
    assert.equal(docs[0]._id.toString(), event2._id.toString());
    assert.equal(docs[0].url, 'google.com');
  });

Discriminators copy pre and post hooks

Discriminators also take their base schema's pre and post middleware. However, you can also attach middleware to the discriminator schema without affecting the base schema.

const options = {discriminatorKey: 'kind'};

const eventSchema = new mongoose.Schema({time: Date}, options);
let eventSchemaCalls = 0;
eventSchema.pre('validate', function (next) {
  ++eventSchemaCalls;
  next();
});
const Event = mongoose.model('GenericEvent', eventSchema);

const clickedLinkSchema = new mongoose.Schema({url: String}, options);
let clickedSchemaCalls = 0;
clickedLinkSchema.pre('validate', function (next) {
  ++clickedSchemaCalls;
  next();
});
const ClickedLinkEvent = Event.discriminator('ClickedLinkEvent',
  clickedLinkSchema);

const event1 = new ClickedLinkEvent();
event1.validate(function() {
  assert.equal(eventSchemaCalls, 1);
  assert.equal(clickedSchemaCalls, 1);

  const generic = new Event();
  generic.validate(function() {
    assert.equal(eventSchemaCalls, 2);
    assert.equal(clickedSchemaCalls, 1);
  });
});

Handling custom _id fields

A discriminator's fields are the union of the base schema's fields and the discriminator schema's fields, and the discriminator schema's fields take precedence. There is one exception: the _id field. If a custom _id field is set on the base schema, that will always override the discriminator's _id field, as shown below.

const options = {discriminatorKey: 'kind'};

// Base schema has a custom String `_id` and a Date `time`...
const eventSchema = new mongoose.Schema({_id: String, time: Date},
  options);
const Event = mongoose.model('BaseEvent', eventSchema);

const clickedLinkSchema = new mongoose.Schema({
  url: String,
  time: String
}, options);
// The discriminator schema has a String `time` and an
// implicitly added ObjectId `_id`.
assert.ok(clickedLinkSchema.path('_id'));
assert.equal(clickedLinkSchema.path('_id').instance, 'ObjectID');
const ClickedLinkEvent = Event.discriminator('ChildEventBad',
  clickedLinkSchema);

const event1 = new ClickedLinkEvent({_id: 'custom id', time: '4pm'});
// clickedLinkSchema overwrites the `time` path, but **not**
// the `_id` path.
assert.strictEqual(typeof event1._id, 'string');
assert.strictEqual(typeof event1.time, 'string');

Using discriminators with Model.create()

When you use Model.create(), mongoose will pull the correct type from the discriminator key for you.

const Schema = mongoose.Schema;
const shapeSchema = new Schema({
  name: String
}, { discriminatorKey: 'kind' });

const Shape = db.model('Shape', shapeSchema);

const Circle = Shape.discriminator('Circle',
  new Schema({ radius: Number }));
const Square = Shape.discriminator('Square',
  new Schema({ side: Number }));

const shapes = [
  { name: 'Test' },
  { kind: 'Circle', radius: 5 },
  { kind: 'Square', side: 10 }
];
Shape.create(shapes, function(error, shapes) {
  assert.ifError(error);
  assert.ok(shapes[0] instanceof Shape);
  assert.ok(shapes[1] instanceof Circle);
  assert.equal(shapes[1].radius, 5);
  assert.ok(shapes[2] instanceof Square);
  assert.equal(shapes[2].side, 10);
});

Embedded discriminators in arrays

You can also define discriminators on embedded document arrays. Embedded discriminators are different because the different discriminator types are stored in the same document array (within a document) rather than the same collection. In other words, embedded discriminators let you store subdocuments matching different schemas in the same array.

As a general best practice, make sure you declare any hooks on your schemas before you use them. You should not call pre() or post() after calling discriminator()

const eventSchema = new Schema({ message: String },
  { discriminatorKey: 'kind', _id: false });

const batchSchema = new Schema({ events: [eventSchema] });

// `batchSchema.path('events')` gets the mongoose `DocumentArray`
const docArray = batchSchema.path('events');

// The `events` array can contain 2 different types of events, a
// 'clicked' event that requires an element id that was clicked...
const clickedSchema = new Schema({
  element: {
    type: String,
    required: true
  }
}, { _id: false });
// Make sure to attach any hooks to `eventSchema` and `clickedSchema`
// **before** calling `discriminator()`.
const Clicked = docArray.discriminator('Clicked', clickedSchema);

// ... and a 'purchased' event that requires the product that was purchased.
const Purchased = docArray.discriminator('Purchased', new Schema({
  product: {
    type: String,
    required: true
  }
}, { _id: false }));

const Batch = db.model('EventBatch', batchSchema);

// Create a new batch of events with different kinds
const batch = {
  events: [
    { kind: 'Clicked', element: '#hero', message: 'hello' },
    { kind: 'Purchased', product: 'action-figure-1', message: 'world' }
  ]
};

Batch.create(batch).
  then(function(doc) {
    assert.equal(doc.events.length, 2);

    assert.equal(doc.events[0].element, '#hero');
    assert.equal(doc.events[0].message, 'hello');
    assert.ok(doc.events[0] instanceof Clicked);

    assert.equal(doc.events[1].product, 'action-figure-1');
    assert.equal(doc.events[1].message, 'world');
    assert.ok(doc.events[1] instanceof Purchased);

    doc.events.push({ kind: 'Purchased', product: 'action-figure-2' });
    return doc.save();
  }).
  then(function(doc) {
    assert.equal(doc.events.length, 3);

    assert.equal(doc.events[2].product, 'action-figure-2');
    assert.ok(doc.events[2] instanceof Purchased);

    done();
  }).
  catch(done);

Recursive embedded discriminators in arrays

You can also define embedded discriminators on embedded discriminators. In the below example, sub_events is an embedded discriminator, and for sub_event keys with value 'SubEvent', sub_events.events is an embedded discriminator.

const singleEventSchema = new Schema({ message: String },
  { discriminatorKey: 'kind', _id: false });

const eventListSchema = new Schema({ events: [singleEventSchema] });

const subEventSchema = new Schema({
   sub_events: [singleEventSchema]
}, { _id: false });

const SubEvent = subEventSchema.path('sub_events').
  discriminator('SubEvent', subEventSchema);
eventListSchema.path('events').discriminator('SubEvent', subEventSchema);

const Eventlist = db.model('EventList', eventListSchema);

// Create a new batch of events with different kinds
const list = {
  events: [
    { kind: 'SubEvent', sub_events: [{kind:'SubEvent', sub_events:[], message:'test1'}], message: 'hello' },
    { kind: 'SubEvent', sub_events: [{kind:'SubEvent', sub_events:[{kind:'SubEvent', sub_events:[], message:'test3'}], message:'test2'}], message: 'world' }
  ]
};

Eventlist.create(list).
  then(function(doc) {
    assert.equal(doc.events.length, 2);

    assert.equal(doc.events[0].sub_events[0].message, 'test1');
    assert.equal(doc.events[0].message, 'hello');
    assert.ok(doc.events[0].sub_events[0] instanceof SubEvent);

    assert.equal(doc.events[1].sub_events[0].sub_events[0].message, 'test3');
    assert.equal(doc.events[1].message, 'world');
    assert.ok(doc.events[1].sub_events[0].sub_events[0] instanceof SubEvent);

    doc.events.push({kind:'SubEvent', sub_events:[{kind:'SubEvent', sub_events:[], message:'test4'}], message:'pushed'});
    return doc.save();
  }).
  then(function(doc) {
    assert.equal(doc.events.length, 3);

    assert.equal(doc.events[2].message, 'pushed');
    assert.ok(doc.events[2].sub_events[0] instanceof SubEvent);

    done();
  }).
  catch(done);

Single nested discriminators

You can also define discriminators on single nested subdocuments, similar to how you can define discriminators on arrays of subdocuments.

As a general best practice, make sure you declare any hooks on your schemas before you use them. You should not call pre() or post() after calling discriminator()

const shapeSchema = Schema({ name: String }, { discriminatorKey: 'kind' });
const schema = Schema({ shape: shapeSchema });

schema.path('shape').discriminator('Circle', Schema({ radius: String }));
schema.path('shape').discriminator('Square', Schema({ side: Number }));

const MyModel = mongoose.model('ShapeTest', schema);

// If `kind` is set to 'Circle', then `shape` will have a `radius` property
let doc = new MyModel({ shape: { kind: 'Circle', radius: 5 } });
doc.shape.radius; // 5

// If `kind` is set to 'Square', then `shape` will have a `side` property
doc = new MyModel({ shape: { kind: 'Square', side: 10 } });
doc.shape.side; // 10