DatabaseNode JSProgramming

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!

Related Articles

Leave a Reply

Back to top button