Node JSDatabaseProgramming

Integrating Mongoose Models and Schemas with Typescript – Node.js

How to use mongoose schemas and models with typescript retaining type saftey

Introduction

Having recently migrated a legacy project from MySQL to MongoDB, I’ve had to fight a fair bit getting models and schemas to work with existing controllers etc. One of the first decisions was to use Mongoose as an easier way to model objects stored in the database.

I’m only going to single out in issue here, rather than go through every little detail and a running example of how this works. I had quite a few hours or working through the documentation, stack overflow and trial and error to get the TypeScript typings to work with the Schemas and controllers.

Looking at a traditional Schema, they look a little something like this:

const mongoose = require('mongoose');
const Schema = mongoose.Schema;

const Contact = new Schema({
    name: string;
    email: string;
    phone?: string;
    message?: string;
    creation_date: Date;
});

A Typed Model

Now to convert to TypeScript, we need to change the imports and export a class.

import { Schema, model, Document, Model } from 'mongoose';

declare interface IContact extends Document{
    name: string;
    email: string;
    phone?: string;
    message?: string;
    course_enquiry?: string;
    creation_date: Date;
}

export interface ContactModel extends Model<IContact> {};

export class Contact {

    private _model: Model<IContact>;

    constructor() {
        const schema =  new Schema({
            name: { type: String, required: true },
            email: { type: String, required: true },
            phone: { type: String },
            message: { type: String },
            course_enquiry: { type: String },
            creation_date: { type: Date, default: Date.now }
        });

        this._model = model<IContact>('User', schema);
    }

    public get model(): Model<IContact> {
        return this._model
    }
}

Okay, I’ll grant you it is a lot more code. And sure, it takes more to read and understand. But the benefits are certainly worth it! Before we go through them lets just take a quick look at what I’ve written. If you’re interested in what types you can use in a TypeScript interface, check out my post about TypeScript types here.

The first step is importing the necessary components from mongoose. Next we declare our object interface. This is what the actual database object should look like.

Note, it extends the mongoose Document else it will throw this error:

Type ‘IContact’ does not satisfy the constraint ‘Document’. Type ‘IContact’ is missing the following properties from type ‘Document’: increment, model, isDeleted, remove, and 51 more.ts(2344)

Accessing The Model

Next is an export of a Model interface. We do this so in our database controller the types carry through and persist despite being a singleton pattern.

Next comes the constructor. This is called from our database controller and on it’s initialisation, we create our model instance.

Last but not least, we create a get method which allows for easier access to the model itself.

Database Controller

Okay, so that’s all good and dandy, but what about the database controller, and how does it all tie in? Well, the database controller looks a little something like:

import { connect, connection, Connection } from 'mongoose';
import { Contact, ContactModel } from './../models/contactsModel';

declare interface IModels {
    Contact: ContactModel;

}

export class DB {

    private static instance: DB;
    
    private _db: Connection; 
    private _models: IModels;

    private constructor() {
        connect(process.env.MONGO_URI, { useNewUrlParser: true });
        this._db = connection;
        this._db.on('open', this.connected);
        this._db.on('error', this.error);

        this._models = {
            Contact: new Contact().model
            // this is where we initialise all models
        }
    }

    public static get Models() {
        if (!DB.instance) {
            DB.instance = new DB();
        }
        return DB.instance._models;
    }

    private connected() {
        console.log('Mongoose has connected');
    }

    private error(error) {
        console.log('Mongoose has errored', error);
    }
}

The code block above is also a little lengthy but it’s all simple stuff. I didn’t want to create the database instance in the main.ts file, nor did I want to have to rely on any 1 part of the project from which to construct it. This type of programming pattern is called a singleton pattern. If you’re not familiar with this, tutorials point have a fantastic explanation on this: Design Pattern – Singleton Pattern

In Practice

Last on the list now is to import the database controller. Any other controller or route (eg: if using express) would look something like this:

import { DB } from './../controllers/db';

// within some class, this is called..
let contact = new DB.Models.Contact(
    {
        name: req.body.name,
        email: req.body.email,
        phone: req.body.phone,
        message: req.body.message,
        course_enquiry: req.body.course_enquiry
    }
);
contact.save((err) => {
    if(err) {
        return next(err);
    }
    res.status(200).json({ result: "success" });
});

Similarly, finding objects is almost identical:

import { DB } from './../controllers/db';

DB.Models.Contact.find({}, (err, results) => {
    if(err) {
        return next(err);
    }
    res.status(200).json(results);
});
mongoose-find-result-typed
mongoose find result typed

Clearing Up

So, a few questions I can imagine myself having if I were looking at this article?

  1. How does the DB know what models there are? The interface on line 4 of the database file describes what models there are.
  2. Where or how do I get mongoose to connect? You don’t need to. The singleton pattern design ensures than when ever you get a model, if the database connection doesn’t exist, then it will create a new one for you. No ‘new’ needed!
  3. Why do I also need to import the BookingModel interface in the database controller? This is my favourite part. Because the interface gets carried through as discussed above by the exported interface that extends the Model, the mongoose typings are persisted along with the object interface. (More Below)
  4. Can I nest interfaces to create complex Schemas? Yes you can, I have a post about nesting interfaces here.
  5. Are the models ever created more than once? No. Because the Models are only created on the database constructor (which is only ever called once by itself) there is no concern for getting errors like: Cannot overwrite `model-x` model once compiled.

As discussed above, notice how when you cycle through the typings of the results, your properties will now exist as a type. This saves a great deal of time trying to remember what parameters you used, and will throw compiler errors if you try to access a parameter that isn’t listed.

mongoose typed results from database
mongoose typed results from database

If you’re at all interested, a great book on MongoDB and Node.js by Greg Lim. It’d be a great start for someone just getting into MongoDB or Node.js – or both!

Any questions or problems, let me know in the comments below!

Tags

Nicholas Mordecai

Just your friendly neighbourhood programmer!

Related Articles

5 Comments

  1. Nicholas, great article – thank you!

    I’m just getting back into coding (previous experience with Java & C#) after a number of years. I have a specific question on a piece of syntax you have used (I can’t find examples of this usage any where else) in the following line:

    “”export interface ContactModel extends Model{};””

    So, I understand that you’re exporting a new interface, named ContactModel (which itself extends a mongoose Document). It’s the last bit I don’t understand … ““Model””. My guess would be that you are extending IContact, which you are also simultaneously casting (from a mongoose Document) to a mongoose Model. Is that correct?

    Appreciate your help!

    Steve

    1. Hey Steve! Welcome back to the land of coding, I hope it’s treating you alright 😛

      So the ContactModel is used in the IModels Interface. When exporting the ContactModel interface, we pass it a generic to the Model class. So what we’re effectively saying is:

      We’re going to instantiate a new Model which has been passed the interface IContact (so now the model knows the properties on the document) – but it’s stored as an interface called ContactModel. We then use that in the database controller to let the database know what documents it should be expecting to use, and so you can traverse back up the tree of interfaces to get from the database layer right down to the type schema of the Contact interface.

      I hope this helps, but it was a bit of a headache when I first worked out how to use explicit property type casting in with MongoDB and TypeScript haha

      All the best!

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.

Close
Close