DEV Community

Nienke
Nienke

Posted on

Getting started with Mongoose discriminators in Express.js

I recently started rewriting my Rails side project, what.pm, to Express. One reason is that I want to get better at JavaScript, the other is that Rails feels very magical and I don't like the fact that I don't really know what I'm doing when I use it ("it works, but I'm not sure why"). That's not necessarily a bad thing and it's something that can be solved by diving under Rails' hood, but I'm not interested in doing that, to be honest.

So for this rewrite, I wanted to dig a little deeper in storing data and stop relying on behind-the-scenes magic. This means coming up with a proper data model. I wanted a NoSQL database for flexibility (I might need to add different collection types later!). I opted for MongoDB because it meshes well with Node, and because I wanted to try MongooseJS (looking at the docs, it seemed to provide an easy to understand abstraction layer and spoiler alert: it is pretty neat).

Disclaimer

I'm writing this post as I'm learning, and my understanding of any concepts mentioned might be wrong. If you think that's the case, do let me know 😃

The problem

Imagine you're tracking which movies, books and tv shows you consume in a given year. These three things have a few things in common: they all have a title and a date of release. They also differ from eachother, however: a Book has an author, whereas a Movie has a director. A TV show has neither of these things, but it does have a season. So, how would you set up your Mongoose schemas? You could easily create three different schemas for each (Book, Movie and TVshow). However, you'd be repeating yourself - in every schema, you'd have the same title field and date of release field. And if you wanted to add another field that all three schemas have in common - such as whether it's a rewatch/reread ('redo') - you'd have to add that new field to three different files.

What if you could extend some kind of "Base" schema, and have Movies, Books and TV Shows inherit from that one schema? I didn't know how, but luckily, a colleague suggested I look into Mongoose discriminators. Unfortunately, the documentation is a little sparse, and I couldn't find any Express.js specific tutorials/blog posts, so here's my attempt at fixing that. Hopefully, this post will help those looking to integrate Mongoose discriminators in their Express app :)

The non-DRY way

Just for clarity, this is what our schemas could look like without discriminators:

> models/book.js

// Define our Book schema
const BookSchema = new mongoose.Schema(
  {
    title: { type: String, required: true },
    author: { type: String, required: true },
    release_date: { type: Date, required: true },
  }
);

// Create a model from our schema
module.exports = mongoose.model('Book', BookSchema);
Enter fullscreen mode Exit fullscreen mode
> models/movie.js

const MovieSchema = new mongoose.Schema(
  {
    title: { type: String, required: true },
    director: { type: String, required: true },
    release_date: { type: Date, required: true },
  }
);

module.exports = mongoose.model('Movie', MovieSchema);
Enter fullscreen mode Exit fullscreen mode
> models/tvshow.js

const Tvshow = new mongoose.Schema(
  {
    title: { type: String, required: true },
    season: { type: Number, required: true },
    release_date: { type: Date, required: true },
  }
);

module.exports = mongoose.model('Tvshow', TvshowSchema);
Enter fullscreen mode Exit fullscreen mode

Nothing wrong with that! However, like I mentioned before, if we wanted to add a new property, say:

// signals whether I've already seen or read the item in question
redo: { type: Boolean, required: false } 
Enter fullscreen mode Exit fullscreen mode

We'd have to add it three times in three separate files 😖. So let's try something different.

We're going to create one 'master' schema called Base, and we're going to make Book, Movie and Tvshow inherit from it. This is what we want to achieve in pseudocode:

Base:
    title: { type: String, required: true },
    date_released: { type: Date, required: true },
    redo: { type: Boolean, required: false },

Book:
    Inherit everything from Base, and add the following just for this schema:
    author: { type: String, required: true }

Movie:
    Inherit everything from Base, and add the following just for this schema:
    director: { type: String, required: true }

TV Show:
    Inherit everything from Base, and add the following just for this schema:
    season: { type: Number, required: true }
Enter fullscreen mode Exit fullscreen mode

So how are we going to give our child schemas (Book, Movie, Tvshow) the Base options? In other words, how will we extend our Base? Enter discriminators. A discriminator is a function for model that returns a model whose schema is the union of the base schema and the discriminator schema. So basically, a discriminator will allow us to specify a key, like kind or itemtype. With this key, we can store different entities (books, movies, tv shows..) in one collection, and we'll still be able to discriminate (badum tsss) between these entities.

So let's set up our Base schema. Again, that's the structure that our other schemas will extend from.

const baseOptions = {
  discriminatorKey: 'itemtype', // our discriminator key, could be anything
  collection: 'items', // the name of our collection
};

// Our Base schema: these properties will be shared with our "real" schemas
const Base = mongoose.model('Base', new mongoose.Schema({
      title: { type: String, required: true },
      date_added: { type: Date, required: true },
      redo: { type: Boolean, required: false },
    }, baseOptions,
  ),
);

module.exports = mongoose.model('Base');
Enter fullscreen mode Exit fullscreen mode

And then we could edit book.js like this:

> models/book.js

const Base = require('./base'); // we have to make sure our Book schema is aware of the Base schema

const Book = Base.discriminator('Book', new mongoose.Schema({
    author: { type: String, required: true },
  }),
);

module.exports = mongoose.model('Book');
Enter fullscreen mode Exit fullscreen mode

With Base.discriminator(), we're telling Mongoose that we want to get the properties of Base, and add another author property, solely for our Book schema. Let's do the same thing with models/movie.js:

> models/movie.js

const Base = require('./base');

const Movie = Base.discriminator('Movie', new mongoose.Schema({
    director: { type: String, required: true },
  }),
);

module.exports = mongoose.model('Movie');
Enter fullscreen mode Exit fullscreen mode

and tvshow.js:

> models/tvshow.js

const Base = require('./base');

const Tvshow = Base.discriminator('Tvshow', new mongoose.Schema({
    season: { type: Number, required: true },
  }),
);

module.exports = mongoose.model('Tvshow');
Enter fullscreen mode Exit fullscreen mode

Now if we create a new book for our collection, the new Book instance will show up in our MongoDB collection like this:

{
    "_id": {
        "$oid": "unique object ID"
    },
    "itemtype": "Book", 
    "author": "Book Author 1",
    "title": "Book Title 1",
    "date_added": {
        "$date": "2018-02-01T00:00:00.000Z"
    },
    "redo": false,
}
Enter fullscreen mode Exit fullscreen mode

Cool, right? Now let's fetch some data. The example below will return the amount of books in our collection, and all tv shows with their titles and seasons:

> controllers/someController.js

const Book = require('../models/book');
const Tvshow = require('../models/tvshow');
const async = require('async');

exports.a_bunch_of_stuff = function(req, res) {
    async.parallel({
        book_count: function (callback) {
            Book.count(callback);
        },
        tvshow_all: function(callback) {
            Tvshow.find({}, 'title season', callback)
        },
    }, function(err, results) {
        res.render('index', { error: err, data: results });
    });
};

Enter fullscreen mode Exit fullscreen mode

Wrapping up

By using a discriminator we have four small files with DRY code, instead of three larger model files with lots of the same code 😎 now anytime I want to add a new property that is shared across schemas, I'll only have to edit Base. And if I want to add new models (maybe I should start keeping track of concerts I go to!), I can easily extend existing properties when needed.

Latest comments (16)

Collapse
 
estebangs profile image
Esteban

...but, how do you actually create a new derived schema???

Collapse
 
adityagaddhyan profile image
Aditya Gaddhyan

Thanks man! I really liked your blog.

Collapse
 
uahmadsoft profile image
UAhmadSoft

GREAT Post indeed ..........
but Plz explain me what is purpose of BaseOptions ???

Collapse
 
tharindurewatha profile image
Tharindu Rewatha

Thank you so much for the post. This was the only post on the internet that described mongoose discriminator function clearly.

Collapse
 
ilhamtubagus profile image
Fian

thanks for your post. I have a question. I wonder, how i could select all of documents in my collection will all discriminators included ?

Collapse
 
mrtarikozturk profile image
mrtarikozturk

Hi, Thank you so much for your post. I have a question. I did my models like above. However, I don't have different colletions for my models in database. Is it normal? Additionally, I want to different collection for my models. How can I do it? Could you share any source or give information?

Collapse
 
hisham profile image
Hisham Mubarak

Thanks a lot for using the perfect example to demonstrate the use cases and output. Many other articles and even the original documentation used some complex event logging example which a beginner like me couldn't make any sense of.

Collapse
 
burakkarakus profile image
Burak Karakus

This is a really helpful post which quickly and greatly show how to use mongoose discriminators! Thank you!

Collapse
 
byrneg7 profile image
byrneg7

Brilliant article Nienke, great choice of use-case to illustrate an OO approach to Express+ Mongoose

Collapse
 
javabytes profile image
JavaBytes

Very clear step by step article! Found this super useful!

Collapse
 
sahilkashyap64 profile image
Sahil kashyap

Thanks for sharing this. It was easy to understand. I applied it in my "user role access" system and it worked.

Collapse
 
the_hme profile image
Brenda 🐱 • Edited

Update below:

Thanks for starting this post. I am using Mongoose 5.3 and I'm having issues with making changes to my existing schema.

I don't have each model in different files, they are all in the same file and then get exported as part of a single app schema, so how would I access Base within the same file. I tried something like this:

const Base = new mongoose.Schema({
      title: { type: String, required: true },
      date_added: { type: Date, required: true },
      redo: { type: Boolean, required: false },
    }, baseOptions,
  );

const BookSchema = new mongoose.Schema({
    author: { type: String, required: true },
  });

var Book = Base.discriminator('Book', BookSchema);
...
this.Base = mongoose.model('Base', Base);
this.Book = mongoose.model('Book', Book);

However, when I try to load my app, it complains with error:

Base.discriminator is not a function

I tried upgrading mongoose to version 5.4.2 and I am still getting this error.

UPDATE: My issue seems related to the fact that I need to work on a deeply embedded doc and this array of objects is what needs to be able to inherit from the base class. Example, person object with embedded belongings array, which contains a media array, where the movies, books, etc. are store and these media items need to share a schema

Collapse
 
rifaldikn profile image
Rifaldikn

Same with me, did u solve it?

Collapse
 
joanlamrack profile image
Joan Lamrack

Nice article!, been looking for throughout explanation about this

Collapse
 
tichel profile image
tichel

Nienke, great article that helped me a lot! What would be the routes from app.js to this Schemes.. ? Routes via models/book.js, models/tvshow.js or via the models.base.js? Many thanks, Peter Tichelaar

Collapse
 
helenasometimes profile image
Nienke

Hi Peter,

I set up my routes in app.js like this:

const routes = require('./routes/routes');
// bunch of middleware
app.use('/', routes);

And then in routes.js, I call my controllers:

const base_controller = require('../controllers/baseController');
const creation_controller = require('../controllers/creationController');

and inside baseController, I call my models like this:

const Book = require('../models/book');
const Movie = require('../models/movie');
const Tvshow = require('../models/tvshow');
const Base = require('../models/base');

If I want to, for example, get an item by its ID, I do this inside my controller:

// Get item by ID
exports.get_item_by_id = function(req, res, next) {
    async.parallel({
        function(callback) {
            Base.findById(req.params.id)
                .exec(callback);
        },
    }, function(err, results) {
        if (err) { return next(err); }
        res.render('templates/update', { data: results });
    });
};

Then in my routes, I do this:

router.get('/update/:id', ensureAuthenticated, base_controller.get_item_by_id, (req, res) => {
    res.send(req.params);
});

Hope that helps any? I'm planning on open sourcing my code at some point, it just needs a lot of cleaning up :/