Validate Polymorphic Data With NestJS and Mongoose | JavaScript In Plain English

By Rémi Sormain

Leverage Polymorphic Data Validation With Nest.js and Mongoose

Rémi Sormain
Image for post
Image for post
Photo by Yogi Purnama on Unsplash, modified by the author

Imagine: you have a collection of customer documents, and each of them has a favouritePaymentMethods array field. You need to save them in your database, however payment method details can vary greatly. It may be a credit or debit card, gift voucher or perhaps a direct debit. They are all payment methods with similarities, but you will probably store different details about them (expiration date for credit cards, bank account number fore SEPA debit etc.). Well, you just landed in the world of polymorphic data models, and we’ll cover in this article how you can ensure that the stored data is clean.

To keep this article focused, I’ll assume you have a basic knowledge of how to bootstrap a NestJS API and use Mongoose with it: if not then have a look at Nest’s doc and Mongoose’s quick start guide. Don’t worry, this article will still be there for you to check 😉.

Also, if you’re not sure why you should use NestJS in the first place, then check out this on-the-point article:

A Sample API, Without Validation

In this example, we’ll consider an API that stores a collection of Forest documents, and each forest has a subdocument collection with animals of the forest. As I’m sure you already know, you can create a new nest API with nest new forest-api, given you have nest’s CLI installed. Feel free to check out the sample code on GitHub. We’ll also need to install the @nestjs/mongoose package: it allows us to define a TypeScript class to generate the corresponding Mongoose schema, as follows:

Now that our forest database model is defined, we just have to create a controller. For the sake of simplifying this example, we inject directly the Mongoose layer in our controller, but don’t do this at home kids! In production, we should define our domain logic in a dedicated service.

Let’s not forget to register our Mongoose connection and schema inside the application module:

And here we are! We can run our API and post as many animal we want, with curl for example:

npm run start;
curl -X PUT "http://localhost:3000/forest/broceliande/animals" -H "accept: */*" -H "Content-Type: application/json" -d "{\"type\":\"bear\",\"numberOfLegs\":4}";

The problem is, with this method, we have no control over what goes into our database 😱.

Creating Validation Schemas

What we want here is to validate animals going into our forest, and we can distinguish 2 broad kinds of properties we expect on our database models:

  1. properties that all animals may have in common (such as the number of eyes, the weight etc.)
  2. properties that only some animals may have (colour of the beak for birds, number of teeth etc.)

From there we derive the need of a base schema which will validate the common property, and then as many schemas as we need to validate specific properties. We will then link both base and specific models together using Mongoose’s Discriminators.

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

Image for post
Image for post
In our forest document, we only accept hares, wolves and unicorns. The first validation step will be to rule out any other animal.

Discriminators work with a discriminator key: it is the property that Mongoose will look at to tell whether a model is of one type or another. In our case, we want to know what kind of animal we have in our array so that we apply the proper validation rules. First, we will enumerate the kind of animals we allow in our forest. From there we can create a base AnimalModel, with let’s say a common numberOfLegs property:

Although we are only interested in the schema here, you can export the class AnimalModel itself of course, to manipulate data when you retrieve it from the database

We have our base, check! This means that Mongoose will refuse any animal whose type does not belong to the AnimalKind enum, or has no numberOfLegs property. Take note of the discriminatorKey schema property. You can of course use a key name that suits your domain better. Now onto our specific animals model:

We defined a base model for common properties, and dedicated models with specific properties per animal kind. Without letting you question the existence of unicorns in forests, let’s move on to the magic trick that will make all of this work 🙂.

Image for post
Image for post
A visual recap of the Mongoose schema above: if we try to insert a wolf, then it must have a canineLengthInCm property that is a number. If we add a unicorn, then it must have a hornColor string property in order to pass validation.

Register a Discriminator

We need to tell Mongoose that our animals array in the forest document may contain objects that follow the AnimalSchema AND either the UnicornSchema, HareSchema OR WolfSchema. So let’s revisit the forest document:

At the time of writing, we unfortunately have to manually indicate to TypeScript that the animals path in our schema is a DocumentArray — Mongoose’s typings can’t infer that yet. Hence I recommend we keep this type assumption as close to the schema definition as possible, and extract the setup of discriminators in a function.

In the snippet above, we told Mongoose that, when the discriminator key of an element of animalArraySchema equals to AnimalKind.Hare, then use HareSchema to validate it. Same for Wolf and Unicorn and as many animals that you may need: extracting to a dedicated function allows us to grow the list of validated entries without touching our ForestModel! Did someone say open-closed principle 😃?

In terms of execution order, registerAnimalSchemaDiscriminator will be called once, when Node reads the module containing ForestModel. One last thing: as recommended by Mongoose’s documentation, if you have pre or post hooks on that specific path, you’ll need to configure them before registering the discriminator.

Let’s See It in Action!

We are all set now! In order for us to see the validation messages I added a try/catch around the database write action so that any validation exception results in a bad request. Let’s try to insert an elephant in the animals array through our PUT endpoint:

curl -X PUT "http://localhost:3000/forest/broceliande/animals" -H "accept: */*" -H "Content-Type: application/json" -d "{\"kind\":\"elephant\",\"numberOfLegs\":4}";

For which we’ll get:

Bad Request.
ForestModel validation failed: animals.0.kind: `elephant` is not a valid enum value for path `kind`.

The validation rule that kicked in was at the base AnimalModel level: Mongoose is only letting in animal kinds that we enumerated.

Not letting elephant in our forest is admirable but also … basic. You don’t need discriminators for that! Let’s go a level deeper by trying to insert a unicorn without a horn colour:

curl -X PUT “http://localhost:3000/forest/broceliande/animals" -H “accept: */*” -H “Content-Type: application/json” -d “{\”kind\”:\”unicorn\”,\”numberOfLegs\”:4}”

Which results in:

Bad Request.
ForestModel validation failed: animals.0.hornColor: Path `hornColor` is required.

Behold, the power of polymorphic validation 🦄! Mongoose refuses this unicorn because we explicitly said unicorns must have a horn colour. And of course, if we run the request with a proper horn colour string, our API returns a 200 code. Neat isn’t it?

Image for post
Image for post
If you don’t like CLI commands, here is a Swagger UI version of our Rest API