Without external tools
Typescript is awesome! Mongoose is popular.
Thanks to @types/mongoose, we now have typescript definitions for mongoose, however getting both to talk can still be tricky.
There are a few tools out there to help us merge mongoose with typescript such as Typegoose and ts-mongoose, but, for one reason or another you may not want to use these tools and roll out without extra dependencies.
This post is a knowledge summary for what I believe to be a good way to build APIs with mongoose and typescript. I assume that the reader is already familiar with both individually.
You can follow along on https://github.com/manueldois/mongoose-typescript.
The typical way to declare a user model looks something like this:
import { model, Schema } from 'mongoose'
interface IUser {
username: string,
email: string,
password: string,
}
const UserSchema = new Schema({
name: String,
email: {
type: String,
unique: true
},
password: String
})
const User = model('User', UserSchema)
Since typescript and mongoose use different types, we have to declare the user's fields in both the interface and the schema.
This is fine for short models but can quickly become unmanageable and lead to bugs. What if I decide to rename the field
name
to username
?interface IUser {
username: string,
// ...
}
const UserSchema = new Schema({
name: String,
// ...
})
There's no issue here as far as typescript is concerned.
One simple solution is to tell typescript that
IUser
interface and UserSchemaFields
must have the same fields:And voilà. The code above will throw a type error when being transpiled to JS, and, if you use VSCode, will alert you that these types are incompatible right on the editor (underlined red). Bear in mind that this approach does not actually check the types of the fields, only that they exist.
Continuing with the example above, right now the User document has no knowledge of the fields it keeps, as it is of the generic mongoose
Document
type:This is where @types/mongoose shines. Simply create a new interface called
IUserDoc
that is a extension between our IUser
and mongoose's Document
types:interface IUser {
name: string,
email: string,
password: string,
}
interface IUserDoc extends IUser, Document {}
// ...
const User = model<IUserDoc>('User', UserSchema)
Now typescript knows our query returns data of type
IUserDoc
which has a password
field:User.findOne().then(user => {
user.password // No error here
})
Let's complicate our model a bit. Say that the user now has friends and a boss. These are references to other users.
import { model, Schema, Document, Types } from 'mongoose'
type ID = Types.ObjectId
interface IUser {
name: string,
email: string,
password: string,
friends: ID[] | IUserDoc[],
boss: ID | IUserDoc,
}
interface IUserDoc extends IUser, Document {}
const UserSchemaFields: Record<keyof IUser, any> = {
name: String,
email: {
type: String,
unique: true
},
password: String,
friends: [{
type: Schema.Types.ObjectId,
ref: 'User',
}],
boss: {
type: Schema.Types.ObjectId,
ref: 'User',
}
}
const UserSchema = new Schema(UserSchemaFields)
const User = model<IUserDoc>('User', UserSchema)
export { User, IUser }
If do a simple query for a user you get only the IDs these references point to. To get the actual user's friends or boss, you must call the populate method on the query.
Mongoose's typescript implementation doesn't yet support populate. This means that a user's boss can be either of type ID or of type User.
const userWithFriendsAndBoss =
await User.findOne({ email: '[email protected]' })
.populate('friends')
.populate('boss')
// Friends is inferred as of type ObjectID array or IUserDoc array
userWithFriendsAndBoss.friends
A solution is to typecast manually:
type Populated<M, K extends keyof M> =
Omit<M, K> &
{
[P in K]: Exclude<M[P], ID[] | ID>
}
const userWithFriendsAndBoss =
await User.findOne({ email: '[email protected]' })
.populate('friends')
.populate('boss') as Populated<IUserDoc, 'friends' | 'boss'>
// Now friends is correctly inferred as of type IUserDoc array
userWithFriendsAndBoss.friends
As for Select, the solution is similar:
type Select<M, K extends keyof M>
= Pick<M, K> & Document
const userWithJustPassword =
await User.findOne({ email: '[email protected]' })
.select('password') as Select<IUserDoc, 'password'>
// Friends doesen't exist on this type, and throws a type error
userWithJustPassword.friends
// Password, and document methods exist
userWithJustPassword.password
userWithJustPassword.save()
The lean method is correctly typed and will return a type of
IUser
, excluding the Document
fields.
Mongoose's ability to expand with our own custom model statics and document methods is one of the best advantages of using an ODM.
Again, we have to write types for these and modify our interfaces:
import { model, Schema, Document, Types, Model } from 'mongoose'
type ID = Types.ObjectId
interface IUser {
name: string,
email: string,
password: string,
boss: ID | IUserDoc
}
interface IUserDoc extends IUser, Document {
getEmployees(): Promise<IUserDoc[]>
}
interface IUserModel extends Model<IUserDoc> {
findYoungerThan(age: number): Promise<IUserDoc[]>
}
const UserSchemaFields: Record<keyof IUser, any> = {
name: String,
email: {
type: String,
unique: true
},
password: String,
boss: {
type: Schema.Types.ObjectId,
ref: 'User',
}
}
const UserSchema = new Schema(UserSchemaFields)
UserSchema.static('findYoungerThan', function (age: number) {
const minimumBirthDate = new Date(Date.now() - (age * 365 * 24 * 3600 * 1000))
return User.find().where('birthdate').gt(minimumBirthDate)
})
UserSchema.method('getEmployees', function (cb: any) {
return User.find().where('boss').in(this.id).exec()
});
const User = model<IUserDoc, IUserModel>('User', UserSchema)
export { User, IUser }
Here, we are adding the
getEmployees
method to IUserDoc
, and creating a new interface, IUserModel
, with the method findYoungerThan
that will be a static.These are then recognized by TS.
// Instance methods
const smith = await User.findOne({ email: '[email protected]' })
const smithsEmployees = await smith.getEmployees()
// Statics
const usersYoungerThan23 = await User.findYoungerThan(23)
Right now we have only one user interface that is used on the backend:
interface IUser {
name: string,
email: string,
password: string,
}
If you're also making a frontend, using typescript, that will be using the user type, you'd typically re-declare the interface:
On the frontend:
interface IUser {
name: string,
email: string,
age: number // Exists only on the frontend
}
You're rewriting most of the user's fields. Again if you change the field name to username, no typeerror is thrown, leading to embarrassing bugs.
If you're also putting your backend and frontend on a monorepo, you can share the user interface, but, since the user interface is similar but different on the backend and frontend, we cannot simply import IUser from the backend.
The solution is to split the user into three interfaces:
Where
IUserBackend
and IUserFrontend
extend IUserShared
:On the backend:
import { model, Schema, Document } from 'mongoose'
// Fields that exist both on the frontend and the backend
interface IUserShared {
name: string,
email: string,
}
// Fields that exist only in the backend
interface IUserBackend extends IUserShared {
password: string,
birthdate: Date,
}
// Fields that exist only in the frontend.
interface IUserFrontend extends IUserShared {
age: number // Exists only on the frontend
}
interface IUserDoc extends IUserBackend, Document {}
const UserSchemaFields: Record<keyof IUserBackend, any> = {
name: String,
email: {
type: String,
unique: true
},
password: String,
birthdate: Date,
}
const UserSchema = new Schema(UserSchemaFields)
const User = model<IUserDoc>('User', UserSchema)
export { User, IUserBackend, IUserFrontend, IUserShared }
On the frontend, for example:
import { IUserBackend, IUserFrontend } from '../models/user.model'
const currentUser: IUserFrontend = {
email: '[email protected]',
name: 'John',
age: 21
}
fetch('/api/user')
.then(res => {
if (res.ok) return res.json()
})
.then((data: IUserBackend) => {
// Treat the user, maybe calculate his/her age
})
Using this approach we can truly have a single source of truth for the User model throughout our application 👌.
I hope these tricks can help you build better APIs, especially if you're hesitant on using third-party tools. Spending that extra time building a fully typesafe model can save allot of frustration down the line chasing bugs 😉.
Thanks to Tom Nagle and Hansen Wang for their inspiring posts!