How to handle sub-documents in Mongoose with TypeScript
Subdocuments are tricky in TypeScript, learn how to handle them for better type checking

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:
- Create an interface representing a document in MongoDB.
- Create a Schema corresponding to the document interface.
- Create a Model.
- 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 capitalT
. - The property
_id: Types.ObjectId
in theIClusterMonitor
interface. By default, each subdocument has its own_id
attribute. - In the
ICluster
interface, the value of themonitor
field is the name of theIClusterMonitor
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;