Pular para o conteúdo
TypeScript

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 pra ter tipagem completa.

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

  1. Instale: npm install mongoose @types/mongoose (Mongoose 7+ já tem tipos built-in)
  2. Crie a pasta src/models/ para os models
  3. Defina interface IEntity para cada coleção
  4. Crie interfaces de methods, statics e virtuals quando necessário
  5. Passe todos os generics pro Schema e model()
  6. Use HydratedDocument como tipo de retorno nas funções
  7. 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
]
}
]
}
]
}