Photo by Allen Taylor on Unsplash

Make TypeScript and Mongoose play nicely together

A straightforward approach to Mongoose schemas and TypeScript interfaces

Tom Allen
Published in
5 min readSep 8, 2020

--

Have you ever struggled to get the TypeScript description of your data to match the Mongoose description of your data? Have you been surprised when your code editor starts giving you typing errors for Mongoose functions that you know are there? If so, here is an approach to working with both TypeScript and Mongoose that works reasonably well.

This guide will look at typing the ‘Animal’ example prevalent in the Mongoose documentation. In order to use Mongoose with TypeScript, you should be sure to include the @types/mongoose package from DefinitelyTyped in your application.

Schemas

A schema is a description of your data that mongoose uses to give some structure to your documents. To describe a schema, Mongoose uses its own SchemaTypes. SchemaTypes can appear quite similar to the way data is typed in TypeScript, but there are differences in terms of both syntax and the actual data shape. When you define a Mongoose schema, Mongoose will append additional functionality to any documents created with that schema. For example, if you define a schema that contains an array, Mongoose will attach .pull()and .id()methods to the document. The challenge is to define your TypeScript types to accurately reflect the mongoose output. If you do not, TypeScript will complain when you try to use the .pull() method. Let’s start with a schema definition for animals (taken from the Mongoose documentation)

Data, Documents, Models

When creating TypeScript typings for Mongoose, it is helpful to consider three cases or levels: Data, Documents, and Models.

In this context, data is what you want to persist to the database in the form of a Document. In mongoose terms, a document is a particular instance of a model. A model is compiled from your schema definition and used to interact with the database. You can think of a model as the mechanism by which your data gets turned into documents and vice-versa. However, in order for our typings to work nicely, we actually define the data first, then define the documents, and finally define the models.

Data

It is good to think about the actual data you want to store in the database in terms of Plain Old JavaScript Objects (or “POJOs”) and type it accordingly. They shouldn’t contain any methods, only data.

So far, this looks almost exactly like the schema, and that’s a good thing. We don’t include any methods, meaning that we could safely re-use this interface in the front-end if we wanted to.

Virtuals

Virtual attributes are bits of data that are not persisted to the database, but are composed on the fly (rather like a getter) . In our example, we might have a virtual nationalType that combined the type and country where the animal is located. First, let's update our schema:

Second, let's define the virtual:

Whether you place these on the POJO or not depends on your schema options. If you define your schema to always include virtuals (with {virtuals:true}) they should go on the POJO since you can expect them to be present even after they have been sent to the front-end. If not, they should go on the document level. For example, if our animal schema looked like this:

Then, we would expect the virtual to be there on any document we retrieved from the database, so we should type it as an optional property. Our interface now looks like this:

Documents

A Document is an instance of a Mongoose model. It has the same data as our POJO object, but it also has methods. Some of these methods are provided by Mongoose automatically, but we can also define them ourselves in the schema.

Methods

The mongoose documentation gives the example of a findSimilarTypes() method that lives on a document. We would define the function and add it to the schema like this:

Note that in our function definition we use TypeScript’s fake this to make it aware of the typing within the function.

Because the findSimilarTypes() function only works on the back-end (where the application has access to the database connection) we should not include it in our IAnimal interface.

On the back-end, however, in order for TypeScript to know about this method and the properties built into all Mongoose documents, we would need to define a new interface, IAnimalDocument, extending both the Document type provided by mongoose and our IAnimal interface:

This helps us to keep the typing of our back-end methods separate from the typing of our data.

Models

Models are compiled from schema definitions and are the normal way that you interact with the database when using mongoose.

Statics

You can add functions to models. To distinguish them from document methods, these are called statics.

We can define a static method, findByName(), on our animal schema (again using the fake this ) like so:

To give the model the correct type, we define its interface like this:

Now we will be able to call our findByName() function on our animal model, but we will get a typing error if we mistakenly try to call it on a document.

Wrapping up: Exporting the typed model

Finally, we can export our Animal model, passing in the document, model typings, and schema like so:

One remaining potential issue comes from the populate feature. This feature allows a field to reference data in another collection, and pull it into the document. Let's say our animal had an owner field, that referenced a document in the owner's collection. In this case, the type of the owner would be either an ObjectId or an Owner. One way of dealing with this is to use the autopopulate plugin in combination with type guards. Another would be to create static methods that return populated versions of the document, this would mean creating another interface that extends IAnimalDocument. Of course, there is nothing saying that you have to use the population, but it is often cleaner to do so.

Breaking the typings down into smaller parts can seem like a lot of extra work, but it pays off in having accurate typings which will mean more reliable code in the future.

--

--