Tipar Mongoose com TypeScript:
Mongoose sem tipagem é um convite ao caos. Veja como integrar schemas, documents, methods e virtuals com TypeScript de forma segura e produtiva.
Por que isso é importante
Tipar Mongoose com TypeScript:. Mongoose sem tipagem é um convite ao caos. Veja como integrar schemas, documents, methods e virtuals com TypeScript de forma segura e produtiva.
O problema: Mongoose sem TypeScript
Quando você usa Mongoose puro, os documentos retornados são tipados como any. Você chama user.nome (em vez de user.name) e ninguém reclama. A query retorna e você acessa campos que não existem. O bug aparece só quando o usuário reclama.
A boa notícia: desde a versão 7, o Mongoose tem suporte nativo a TypeScript. Dá pra tipar schemas, documents, models, methods e virtuals sem gambiarras. Vamos ver como fazer isso direito.
Passo 1: Interface + Schema tipado
O primeiro passo é criar uma interface que descreve o documento e passar essa interface como generic pro Schema. Isso garante que o schema e o tipo fiquem sincronizados.
import mongoose, { Schema, Document, Model } from 'mongoose';
// 1. Interface do documento
interface IUser {
name: string;
email: string;
password: string;
role: 'admin' | 'user' | 'editor';
age?: number;
createdAt: Date;
updatedAt: Date;
}
// 2. Schema tipado com a interface
const userSchema = new Schema<IUser>(
{
name: { type: String, required: true, minlength: 2 },
email: { type: String, required: true, unique: true },
password: { type: String, required: true, select: false },
role: { type: String, enum: ['admin', 'user', 'editor'], default: 'user' },
age: { type: Number, min: 0 },
},
{ timestamps: true }
);
// 3. Model tipado
const User = mongoose.model<IUser>('User', userSchema);
export default User;
Agora quando você faz User.findOne(), o retorno é tipado como IUser | null. Autocomplete funciona, typos viram erros de compilação. Simples assim.
Passo 2: Document type e HydratedDocument
O documento retornado pelo Mongoose não é só a interface pura. Ele vem com métodos do Mongoose como save(), toJSON(), populate(). O tipo correto é HydratedDocument, que combina sua interface com os métodos do Mongoose.
import { HydratedDocument } from 'mongoose';
// Tipo completo do documento Mongoose
type UserDocument = HydratedDocument<IUser>;
// Uso em funções
async function findUserByEmail(email: string): Promise<UserDocument | null> {
const user = await User.findOne({ email });
// user tem tipo HydratedDocument<IUser> | null
// Acesso a campos da interface + métodos do Mongoose
if (user) {
console.log(user.name); // string
console.log(user._id); // ObjectId
await user.save(); // método do Mongoose
user.toJSON(); // método do Mongoose
}
return user;
}
Passo 3: Methods tipados (instância)
Methods são funções que rodam numa instância do documento. Tipo user.comparePassword(). Pra tipar, você define uma interface de methods e passa como generic.
import mongoose, { Schema, Model, HydratedDocument } from 'mongoose';
import bcrypt from 'bcryptjs';
// Interface dos dados
interface IUser {
name: string;
email: string;
password: string;
}
// Interface dos methods
interface IUserMethods {
comparePassword(candidatePassword: string): Promise<boolean>;
getPublicProfile(): { name: string; email: string };
}
// Tipo do Model (combina dados + methods)
type UserModel = Model<IUser, {}, IUserMethods>;
// Schema com generics completos
const userSchema = new Schema<IUser, UserModel, IUserMethods>({
name: { type: String, required: true },
email: { type: String, required: true },
password: { type: String, required: true },
});
// Implementação dos methods
userSchema.methods.comparePassword = async function (
candidatePassword: string
): Promise<boolean> {
return bcrypt.compare(candidatePassword, this.password);
};
userSchema.methods.getPublicProfile = function () {
return { name: this.name, email: this.email };
};
const User = mongoose.model<IUser, UserModel>('User', userSchema);
// Uso: methods aparecem no autocomplete
const user = await User.findById(id);
if (user) {
const isValid = await user.comparePassword('123456'); // boolean
const profile = user.getPublicProfile(); // { name: string; email: string }
}
Passo 4: Statics tipados (model)
Statics são métodos no Model, não na instância. Tipo User.findByEmail(). A tipagem funciona de forma parecida, mas fica na interface do Model.
interface IUser {
name: string;
email: string;
role: 'admin' | 'user';
}
// Interface dos statics no Model
interface UserModel extends Model<IUser> {
findByEmail(email: string): Promise<HydratedDocument<IUser> | null>;
findAdmins(): Promise<HydratedDocument<IUser>[]>;
}
const userSchema = new Schema<IUser, UserModel>({
name: { type: String, required: true },
email: { type: String, required: true },
role: { type: String, enum: ['admin', 'user'], default: 'user' },
});
// Implementação dos statics
userSchema.statics.findByEmail = function (email: string) {
return this.findOne({ email });
};
userSchema.statics.findAdmins = function () {
return this.find({ role: 'admin' });
};
const User = mongoose.model<IUser, UserModel>('User', userSchema);
// Uso: statics no Model com autocomplete
const admin = await User.findByEmail('admin@email.com');
const admins = await User.findAdmins();
Passo 5: Virtuals tipados
Virtuals são propriedades computadas que não vão pro banco. Tipo user.fullName que combina firstName e lastName. Pra tipar, você cria uma interface de virtuals separada.
interface IUser {
firstName: string;
lastName: string;
email: string;
}
// Interface dos virtuals
interface IUserVirtuals {
fullName: string;
}
type UserModel = Model<IUser, {}, {}, IUserVirtuals>;
const userSchema = new Schema<IUser, UserModel, {}, {}, IUserVirtuals>(
{
firstName: { type: String, required: true },
lastName: { type: String, required: true },
email: { type: String, required: true },
},
{
toJSON: { virtuals: true },
toObject: { virtuals: true },
}
);
userSchema.virtual('fullName').get(function () {
return `${this.firstName} ${this.lastName}`;
});
const User = mongoose.model<IUser, UserModel>('User', userSchema);
const user = await User.findById(id);
if (user) {
console.log(user.fullName); // string - tipado!
}
Exemplo completo: Model com tudo tipado
import mongoose, { Schema, Model, HydratedDocument } from 'mongoose';
import bcrypt from 'bcryptjs';
// === Interfaces ===
interface IUser {
firstName: string;
lastName: string;
email: string;
password: string;
role: 'admin' | 'user';
isActive: boolean;
}
interface IUserMethods {
comparePassword(candidate: string): Promise<boolean>;
}
interface IUserVirtuals {
fullName: string;
}
interface IUserModel extends Model<IUser, {}, IUserMethods, IUserVirtuals> {
findByEmail(email: string): Promise<HydratedDocument<IUser, IUserMethods & IUserVirtuals> | null>;
}
// === Schema ===
const userSchema = new Schema<IUser, IUserModel, IUserMethods, {}, IUserVirtuals>(
{
firstName: { type: String, required: true },
lastName: { type: String, required: true },
email: { type: String, required: true, unique: true },
password: { type: String, required: true, select: false },
role: { type: String, enum: ['admin', 'user'], default: 'user' },
isActive: { type: Boolean, default: true },
},
{
timestamps: true,
toJSON: { virtuals: true },
}
);
// Methods
userSchema.methods.comparePassword = async function (candidate: string) {
return bcrypt.compare(candidate, this.password);
};
// Virtuals
userSchema.virtual('fullName').get(function () {
return `${this.firstName} ${this.lastName}`;
});
// Statics
userSchema.statics.findByEmail = function (email: string) {
return this.findOne({ email }).select('+password');
};
// Pre-save hook
userSchema.pre('save', async function (next) {
if (!this.isModified('password')) return next();
this.password = await bcrypt.hash(this.password, 12);
next();
});
export const User = mongoose.model<IUser, IUserModel>('User', userSchema);
Erros comuns ao tipar Mongoose
Atenção
Erro 1: Usar Document diretamente em vez de HydratedDocument. O tipo Document do Mongoose é genérico demais. Use HydratedDocument
Erro 2: Esquecer de tipar o Model separado. Se você não passa a interface do Model como segundo generic, statics e methods não aparecem no autocomplete.
Erro 3: Interfaces e Schema fora de sincronia. Se adiciona um campo no schema mas esquece na interface (ou vice-versa), os tipos ficam inconsistentes. Mantenha ambos no mesmo arquivo.
Erro 4: Não usar select: false no campo password. O Mongoose retorna a senha nas queries por padrão. Use select: false e busque explicitamente com .select('+password') quando precisar.
Setup passo a passo
- Instale: npm install mongoose @types/mongoose (Mongoose 7+ já tem tipos built-in)
- Crie a pasta src/models/ para os models
- Defina interface IEntity para cada coleção
- Crie interfaces de methods, statics e virtuals quando necessário
- Passe todos os generics pro Schema e model()
- Use HydratedDocument como tipo de retorno nas funções
- Configure timestamps: true nos schemas que precisam de createdAt/updatedAt
Checklist: Mongoose tipado
Checklist: Mongoose + TypeScript
- Interface IEntity definida para cada model
- Schema usando generic da interface
- Methods tipados com interface IEntityMethods
- Statics tipados na interface do Model
- Virtuals tipados com interface IEntityVirtuals
- HydratedDocument usado nos retornos de funções
- Campos sensíveis com select: false
- Hooks (pre/post) com this tipado corretamente
TypeScript Profissional: Projeto Completo
No CrazyStack você constrói um projeto completo com TypeScript
Node.js e React. Mongoose tipado do zero
com models profissionais e queries seguras. Acesse:
/comprar?src=blog-como-tipar-mongoose-typescript
]
}
]
}
]
}