Hackernoon logoHow To Link Mongoose And Typescript for a Single Source of Truth by@manueldois

How To Link Mongoose And Typescript for a Single Source of Truth

Manuel Maldonado Hacker Noon profile picture

@manueldoisManuel Maldonado

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.

Table of contents

  1. Keeping a typescript interface and a mongoose schema in sync πŸ”—
  2. Informing mongoose about our user type
  3. Adding typesafety for Populate and Select
  4. Including statics and instance methods
  5. Splitting the user model into backend and frontend representations
  6. Conclusion

1. Keeping a typescript interface and a mongoose schema in sync

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.

2. Informing mongoose about our user type

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
})

3. Adding typesafety for Populate and Select

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: 'adam@email.com' })
        .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: 'adam@email.com' })
        .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: 'adam@email.com' })
        .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.

4. Including statics and instance methods

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: 'smith@email.com' })
const smithsEmployees = await smith.getEmployees()

// Statics
const usersYoungerThan23 = await User.findYoungerThan(23)

5. Splitting the user model into backend and frontend representations

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:

  • IUserShared
  • IUserBackend
  • IUserFrontend

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: 'some@email.com',
    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 πŸ‘Œ.

6. Conclusion

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!

Tags

Join Hacker Noon

Create your free account to unlock your custom reading experience.