Without external tools Typescript is awesome! Mongoose is popular. Thanks to , we now have typescript definitions for mongoose, however getting both to talk can still be tricky. @types/mongoose There are a few tools out there to help us merge mongoose with typescript such as and , but, for one reason or another you may not want to use these tools and roll out without extra dependencies. Typegoose ts-mongoose 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 Keeping a typescript interface and a mongoose schema in sync 🔗 Informing mongoose about our user type Adding typesafety for Populate and Select Including statics and instance methods Splitting the user model into backend and frontend representations Conclusion 1. Keeping a typescript interface and a mongoose schema in sync The typical way to declare a user model looks something like this: { model, Schema } interface IUser { : string, : string, : string, } UserSchema = Schema({ : , : { : , : }, : }) User = model( , UserSchema) import from 'mongoose' username email password const new name String email type String unique true password String const 'User' 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 to ? name username interface IUser { : string, } UserSchema = Schema({ : , }) username // ... const new name String // ... There's no issue here as far as typescript is concerned. One simple solution is to tell typescript that interface and must have the same fields: IUser UserSchemaFields And voilà. , 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. The code above will throw a type error when being transpiled to JS 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 type: Document This is where @types/mongoose shines. Simply create a new interface called that is a extension between our and mongoose's types: IUserDoc IUser Document interface IUser { : string, : string, : string, } interface IUserDoc extends IUser, Document {} User = model<IUserDoc>( , UserSchema) name email password // ... const 'User' Now typescript knows our query returns data of type which has a field: IUserDoc password User.findOne().then( { user.password }) => user // 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. { model, Schema, Document, Types } type ID = Types.ObjectId interface IUser { : string, : string, : string, : ID[] | IUserDoc[], : ID | IUserDoc, } interface IUserDoc extends IUser, Document {} UserSchemaFields: Record<keyof IUser, any> = { : , : { : , : }, : , : [{ : Schema.Types.ObjectId, : , }], : { : Schema.Types.ObjectId, : , } } UserSchema = Schema(UserSchemaFields) User = model<IUserDoc>( , UserSchema) { User, IUser } import from 'mongoose' name email password friends boss const name String email type String unique true password String friends type ref 'User' boss type ref 'User' const new const 'User' export 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. userWithFriendsAndBoss = User.findOne({ : }) .populate( ) .populate( ) userWithFriendsAndBoss.friends const await email 'adam@email.com' 'friends' 'boss' // Friends is inferred as of type ObjectID array or IUserDoc array A solution is to typecast manually: type Populated<M, K extends keyof M> = Omit<M, K> & { [P K]: Exclude<M[P], ID[] | ID> } userWithFriendsAndBoss = User.findOne({ : }) .populate( ) .populate( ) Populated<IUserDoc, | > userWithFriendsAndBoss.friends in const await email 'adam@email.com' 'friends' 'boss' as 'friends' 'boss' // Now friends is correctly inferred as of type IUserDoc array As for Select, the solution is similar: type Select<M, K extends keyof M> = Pick<M, K> & Document userWithJustPassword = User.findOne({ : }) .select( ) Select<IUserDoc, > userWithJustPassword.friends userWithJustPassword.password userWithJustPassword.save() const await email 'adam@email.com' 'password' as 'password' // Friends doesen't exist on this type, and throws a type error // Password, and document methods exist The lean method is correctly typed and will return a type of , excluding the fields. IUser Document 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: { model, Schema, Document, Types, Model } type ID = Types.ObjectId interface IUser { : string, : string, : string, : ID | IUserDoc } interface IUserDoc extends IUser, Document { getEmployees(): <IUserDoc[]> } interface IUserModel extends Model<IUserDoc> { findYoungerThan(age: number): <IUserDoc[]> } UserSchemaFields: Record<keyof IUser, any> = { : , : { : , : }, : , : { : Schema.Types.ObjectId, : , } } UserSchema = Schema(UserSchemaFields) UserSchema.static( , { minimumBirthDate = ( .now() - (age * * * * )) User.find().where( ).gt(minimumBirthDate) }) UserSchema.method( , { User.find().where( ).in( .id).exec() }); User = model<IUserDoc, IUserModel>( , UserSchema) { User, IUser } import from 'mongoose' name email password boss Promise Promise const name String email type String unique true password String boss type ref 'User' const new 'findYoungerThan' ( ) function age: number const new Date Date 365 24 3600 1000 return 'birthdate' 'getEmployees' ( ) function cb: any return 'boss' this const 'User' export Here, we are adding the method to , and creating a new interface, , with the method that will be a static. getEmployees IUserDoc IUserModel findYoungerThan These are then recognized by TS. smith = User.findOne({ : }) smithsEmployees = smith.getEmployees() usersYoungerThan23 = User.findYoungerThan( ) // Instance methods const await email 'smith@email.com' const await // Statics const await 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 { : string, : string, : string, } name email password 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 { : string, : string, : number } name email age // 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 and extend : IUserBackend IUserFrontend IUserShared On the backend: { model, Schema, Document } interface IUserShared { : string, : string, } interface IUserBackend extends IUserShared { : string, : , } interface IUserFrontend extends IUserShared { : number } interface IUserDoc extends IUserBackend, Document {} UserSchemaFields: Record<keyof IUserBackend, any> = { : , : { : , : }, : , : , } UserSchema = Schema(UserSchemaFields) User = model<IUserDoc>( , UserSchema) { User, IUserBackend, IUserFrontend, IUserShared } import from 'mongoose' // Fields that exist both on the frontend and the backend name email // Fields that exist only in the backend password birthdate Date // Fields that exist only in the frontend. age // Exists only on the frontend const name String email type String unique true password String birthdate Date const new const 'User' export On the frontend, for example: { IUserBackend, IUserFrontend } currentUser: IUserFrontend = { : , : , : } fetch( ) .then( { (res.ok) res.json() }) .then( { }) import from '../models/user.model' const email 'some@email.com' name 'John' age 21 '/api/user' => res if return ( ) => 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 and for their inspiring posts! Tom Nagle Hansen Wang