Leverage Polymorphic Data Validation With Nest.js and Mongoose

A hands-on guide to validate a document’s array filled with varying data models

Rémi Sormain
Jan 11 · 6 min read
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

Wrapping Up

Let’s sum up what we covered in this article:

  1. We created a base schema against which all elements of our subdocument’s array will be validated.
  2. We created specific schemas for each kind of element we accept in our array. All those schemas type names are listed in a string enum.
  3. We registered each specific schema on the array’s schema using Mongoose’s discriminator.

Do note that discriminators can also be used in a single subdocument (one property is polymorphic), for the entire document (the collection is polymorphic) or even nested discriminators. If you would like to know more about those scenarios, let me know in the comments!

I hope you learned something in that article, thank you for reading 🙏!

JavaScript In Plain English

New JavaScript + Web Development articles every day.

Medium is an open platform where 170 million readers come to find insightful and dynamic thinking. Here, expert and undiscovered voices alike dive into the heart of any topic and bring new ideas to the surface. Learn more

Follow the writers, publications, and topics that matter to you, and you’ll see them on your homepage and in your inbox. Explore

If you have a story to tell, knowledge to share, or a perspective to offer — welcome home. It’s easy and free to post your thinking on any topic. Write on Medium

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store