How to handle sub-documents in Mongoose with TypeScript

Subdocuments are tricky in TypeScript, learn how to handle them for better type checking

  • ERT:

What is a sub-document?

Here’s what the docs says:

Subdocuments are documents embedded in other documents. In Mongoose, this means you can nest schemas in other schemas. Mongoose has two distinct notions of subdocuments: arrays of subdocuments and single nested subdocuments.

In the original design

Let’s look at a real live example that we have implemented in our [RestK12 API project] before we transform our code base to TypeScript.

In our original database design, we have two collections, CLUSTER and CLUSTERMONITOR respectively — and analagous to entities in a relational database environment.

[diagram of the two entities]

(figcaption: A cluster can have one cluster monitor)

By the time of implementation, we do not want to have two seprate collections, so we decided to embed CLUSTERMONITOR into CLUSTER.

[diagram of the embedded one]

Here’s the schema without TypeScript

const mongoose = require('mongoose');

const clusterMonitorSchema = new Schema(
  {
    first_name: {
      type: String,
      required: true,
      maxlength: 27
    },
    minit: {
      type: String,
      maxlength: 27
    },
    last_name: {
      type: String,
      required: true,
      maxlength: 27
    },
    email: {
      type: String,
      unique: true,
    },
    phone: {
      type: String,
      required: true,
    },
  }
);

const clusterSchema = new mongoose.Schema(
  {
    name: {
      type: String,
      required: true,
      unique: true
    },

    monitor: clusterMonitorSchema,

    createdBy: {
      type: Schema.Types.ObjectId,
      ref: 'person',
      required: true
    },

    region: {
      type: Schema.Types.ObjectId,
      ref: 'region',
      required: true
    },
  },
  { timestamps: true }
)

clusterSchema.index({ region: 1, name: 1 }, { unique: true });

clusterSchema.set('toJSON', {
  transform: (document, returnedObject) => {
    returnedObject.id = returnedObject._id.toString()
    delete returnedObject._id
    delete returnedObject.__v
  }
})

const Cluster = exports.Cluster = mongoose.model('cluster', clusterSchema);

Note that the value of the monitor field in the clusterSchema is a single nested sub-document

Transforming it to TypeScript

As stated in the docs, to get started with Mongoose in TypeScript, you need to:

  1. Create an interface representing a document in MongoDB.
  2. Create a Schema corresponding to the document interface.
  3. Create a Model.
  4. Connect to MongoDB.

We are going to focus on only 1 to 3 as far as the context of this topic is concerned.

Note that with TypeScript you can use the ES6 modules (import and export statements) instead of the so-called CommonJS modules

In the first line, we import mongoose, this is practically what we have already been doing in our code base before moving to TypeScript, but with a slightly different syntax:

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

Next, let’s define the interfaces and the model type representing the sub-document and the document in MongoDB.

sub-document and document interfaces

// Create an interface representing a sub-document in MongoDB.
interface IClusterMonitor {
  _id: Types.ObjectId
  first_name: string
  minit?: string
  last_name: string
  email?: string
  phone: string
}

// Create an interface representing a document in MongoDB.
interface ICluster {
  name: string
  region: Types.ObjectId
  createdBy: Types.ObjectId
}

type TClusterModelType = Model<ICluster>;

Note the following:

  • The naming convention we use as far as interfaces and types are concerned, interfaces start with capital I and types starts with capital T.
  • The property _id: Types.ObjectId in the IClusterMonitor interface. By default, each subdocument has its own _id attribute.
  • In the ICluster interface, the value of the monitor field is the name of the IClusterMonitor interface

Up next, we define the schemas representing the sub-document and the document in MongoDB.

clusterMonitor and Cluster Schemas

/* interfaces and type code */

// Create a Schema corresponding to the sub-document interface.
const clusterMonitorSchema = new Schema<IClusterMonitor>(
  {
    first_name: {
      type: String,
      required: true,
      maxlength: 27
    },
    minit: {
      type: String,
      maxlength: 27
    },
    last_name: {
      type: String,
      required: true,
      maxlength: 27
    },
    email: {
      type: String,
      unique: true,
    },
    phone: {
      type: String,
      required: true,
    },
  }
);

// Create a Schema corresponding to the document interface.
const clusterSchema = new Schema<ICluster, TClusterModelType>(
  {
    name: {
      type: String,
      required: true,
      unique: true
    },

    monitor: clusterMonitorSchema,

    createdBy: {
      type: Schema.Types.ObjectId,
      ref: 'person',
      required: true
    },

    region: {
      type: Schema.Types.ObjectId,
      ref: 'region',
      required: true
    },
  },
  { timestamps: true }
);

And finally we create the model and export it

// Create a Model.
const Cluster = model<ICluster, TClusterModelType>('cluster', clusterSchema);

// Export the model
export default Cluster;

The final schema in Typescript

Here is the schema with Typescript

import { Schema, model, Model, Types } from 'mongoose';
// Create an interface representing a sub-document in MongoDB.
interface IClusterMonitor {
  _id: Types.ObjectId
  first_name: string
  minit?: string
  last_name: string
  email?: string
  phone: string
}

// Create an interface representing a document in MongoDB.
interface ICluster {
  name: string
  monitor: IClusterMonitor
  region: Types.ObjectId
  createdBy: Types.ObjectId
}

type TClusterModelType = Model<ICluster>;

// Create a Schema corresponding to the sub-document interface.
const clusterMonitorSchema = new Schema<IClusterMonitor>(
  {
    first_name: {
      type: String,
      required: true,
      maxlength: 27
    },
    minit: {
      type: String,
      maxlength: 27
    },
    last_name: {
      type: String,
      required: true,
      maxlength: 27
    },
    email: {
      type: String,
      unique: true,
    },
    phone: {
      type: String,
      required: true,
    },
  }
);


// Create a Schema corresponding to the document interface.
const clusterSchema = new Schema<ICluster, TClusterModelType>(
  {
    name: {
      type: String,
      required: true,
      unique: true
    },

	monitor: clusterMonitorSchema,

	createdBy: {
  	type: Schema.Types.ObjectId,
  	ref: 'person',
  	required: true
	},

	region: {
  	type: Schema.Types.ObjectId,
  	ref: 'region',
  	required: true
	},

  },
  { timestamps: true }
);

clusterSchema.set('toJSON', {
  transform: (_document, returnedObject) => {
    // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call
    returnedObject.id = returnedObject._id.toString();
    delete returnedObject._id;
    delete returnedObject.__v;
  }
});

// Create a Model.
const Cluster = model<ICluster, TClusterModelType>('cluster', clusterSchema);

// Export the model
export default Cluster;

Related Journals

    No related journal

    No related journal

    No related journal