Same database, different layer
Mongoose wraps every document in a model instance with change tracking, getters, middleware and save(). kastor takes the opposite approach on MongoDB, as it does on SQL: a schema in TypeScript, plain objects in and out, and queries compiled to aggregation pipelines. When you migrate from Mongoose, your collections and documents stay exactly as they are; you change how the application reads and writes them.
Two practical wins drive most moves. Results are typed from the schema without the HydratedDocument and InferSchemaType gymnastics, and relations load with one aggregate call per query instead of one query per populated path.
Step 1: install kastor and connect the driver
npm install kastor-orm mongodb
npx kastor-orm init --db mongodbMongoose bundles the mongodb driver; kastor uses the one you install. Connect a MongoClient to the same URL and pass its Db to the adapter. You can also reuse Mongoose's own connection during the move: mongoose.connection.getClient() returns the driver client.
import { MongoClient } from 'mongodb'
import { kastor } from 'kastor-orm'
import { mongodb } from 'kastor-orm/mongodb'
import { schema, relations } from './schema'
const client = new MongoClient(process.env.MONGODB_URL ?? 'mongodb://localhost:27017/app?replicaSet=rs0')
await client.connect()
export const db = kastor({ adapter: mongodb(client.db()), schema, relations })Step 2: Mongoose schemas become table()
npx kastor db pull reads each collection's validator and indexes, or samples documents when there is no validator, and writes a starting schema. Mongoose rarely sets validators, so expect sampled types: fields missing from some documents become nullable and nested values become t.json(). Tidy the result by hand using your Mongoose schema as the reference.
import mongoose, { Schema, model } from 'mongoose'
const userSchema = new Schema({
email: { type: String, required: true, unique: true, maxlength: 255 },
name: String,
role: { type: String, enum: ['user', 'admin'], default: 'user' },
address: { street: String, city: String },
}, { timestamps: true })
const postSchema = new Schema({
title: { type: String, required: true, maxlength: 200 },
published: { type: Boolean, default: false },
author: { type: Schema.Types.ObjectId, ref: 'User', required: true, index: true },
}, { timestamps: true })
export const User = model('User', userSchema)
export const Post = model('Post', postSchema)
export { mongoose }import { table, t, index, one, many } from 'kastor-orm'
type Address = { street?: string; city?: string }
export const users = table('users', {
id: t.id(),
email: t.string(255).unique(),
name: t.string().nullable(),
role: t.enum(['user', 'admin']).default('user'),
address: t.json<Address>().nullable(),
createdAt: t.datetime().defaultNow(),
updatedAt: t.datetime().updatedAt(),
})
export const posts = table('posts', {
id: t.id(),
title: t.string(200),
published: t.boolean().default(false),
authorId: t.int().map('author').references(() => users.id),
createdAt: t.datetime().defaultNow(),
updatedAt: t.datetime().updatedAt(),
}, { indexes: (p) => [index(p.authorId)] })
export const schema = { users, posts }
export const relations = ({ users, posts }: typeof schema) => ({
users: { posts: many(posts) },
posts: { author: one(users, { fields: ['authorId'], references: ['id'] }) },
})A few details make the two line up. Mongoose pluralizes model names into collection names, so model('User') lives in users. The ref field is called author in the documents, so the kastor column keeps that stored name with .map('author') and frees the name author for the relation. With the default mongodb(db) ids, t.id() keys and every column that references one are typed as string: the ObjectId as hex. The t.int() on the reference only matters on SQL.
| Mongoose | kastor |
|---|---|
new Schema({ ... }) and model() | table('collection', { ... }) |
_id: ObjectId | id: t.id(), read as a string |
type: ObjectId, ref: 'User' | .references(() => users.id) plus one() |
required: true | the default; .nullable() otherwise |
String, maxlength, enum | t.string(n), t.enum([...]) |
Number, Boolean, Date | t.int() or t.float(), t.boolean(), t.datetime() |
Decimal128, Buffer | t.decimal(p, s), t.bytes() |
| nested object, subdocument array | t.json<T>() |
unique: true, index: true | .unique(), index() |
timestamps: true | createdAt with defaultNow(), updatedAt with updatedAt() |
InferSchemaType | InferSelect<typeof users> |
Step 3: baseline the collections
kastor migrations on MongoDB are JSON database commands: create a collection with a $jsonSchema validator, create indexes, rename or unset fields. Your collections already exist, so record them as the starting point:
npx kastor migrate baseline --name init
npx kastor migrate statusThe baseline is marked as applied without running, so no validator is added to live collections behind your back. When you later want validators, which reject documents that break the schema, add them in a reviewed migration after checking that existing documents pass. Mongoose builds indexes at startup with autoIndex; turn that off before the switch so index changes go through migrations only.
Step 4: queries side by side
import { Post, User } from '../models'
export const feed = (authorIds: string[]) =>
Post.find({ published: true, author: { $in: authorIds } })
.populate('author', 'name')
.sort({ createdAt: -1 })
.limit(20)
.lean()
export const search = (q: string) =>
User.find({ email: { $regex: q, $options: 'i' } }).lean()
export async function publish(id: string) {
const post = await Post.findById(id)
if (!post) throw new Error('not found')
post.published = true
return post.save()
}
export const perAuthor = () =>
Post.aggregate([{ $group: { _id: '$author', n: { $sum: 1 } } }])import { db } from '../db'
export const feed = (authorIds: string[]) =>
db.posts.findMany({
where: { published: true, authorId: { in: authorIds } },
include: { author: { select: { name: true } } },
orderBy: { createdAt: 'desc' },
take: 20,
})
export const search = (q: string) =>
db.users.findMany({ where: { email: { contains: q, mode: 'insensitive' } } })
export const publish = (id: string) =>
db.posts.update({ where: { id }, data: { published: true } })
export const perAuthor = () =>
db.posts.groupBy({ by: 'authorId', _count: { _all: true } })| Mongoose | kastor |
|---|---|
find, findOne, findById | findMany, findFirst, findUnique |
populate('author') | include: { author: true } |
select('name email') | select: { name: true, email: true } |
sort, limit, skip | orderBy, take, skip |
$in, $nin, $gt, $lte | in, notIn, gt, lte |
$regex with i | contains with mode: 'insensitive' (input escaped) |
$or, $and, $not | OR, AND, NOT |
new Model(doc).save(), create | create |
updateOne, updateMany, $inc | update, updateMany, { increment: 1 } |
deleteOne, deleteMany | delete, deleteMany |
countDocuments, aggregate($group) | count, groupBy, aggregate |
session.withTransaction | db.$transaction(async (tx) => ...) |
For pipeline stages kastor does not model, such as $geoNear or Atlas Search, keep calling the driver directly on the same Db. That is the MongoDB counterpart of raw SQL, since $sql does not exist on MongoDB.
Step 5: middleware, virtuals and methods
Mongoose middleware (pre('save'), post('findOneAndUpdate')) runs hidden code around operations. kastor has no hooks: put the logic in the function that writes, where it is visible and typed. Virtuals become plain functions over the row type, and instance methods become functions in a module:
import type { InferSelect } from 'kastor-orm'
import { users } from '../db/schema'
import { db } from '../db'
type User = InferSelect<typeof users>
// Was: userSchema.virtual('displayName')
export const displayName = (u: User) => u.name ?? u.email.split('@')[0]
// Was: userSchema.pre('save', ...) lowercasing the email
export const createUser = (email: string, name?: string) =>
db.users.create({ data: { email: email.trim().toLowerCase(), name } })When nothing imports a model any more, uninstall mongoose. Keep the mongodb driver, which kastor uses.
Pitfalls to check
- Missing fields versus null. Mongoose leaves unset fields out of the document. kastor writes
nullfor nullable columns you leave out, and itsnullfilters match missing fields too, so mixed collections work, but code that checks'name' in docwill see the difference. - The __v field. Mongoose adds a version key. Leave it out of the kastor schema and it is simply ignored on reads; drop it later with a migration if you like.
- Casting. Mongoose casts
'42'to a number and strings to ObjectIds silently. kastor's types reject the wrong type at compile time, and the adapter converts id strings to ObjectIds for you. - Cascades. MongoDB has no foreign keys. kastor emulates
onDeletefor its own writes; data written by other programs is not checked. - Discriminators. A collection holding several shapes with a
kindfield maps to one table with an enum column and nullable fields per shape, or to several tables if you can split the data.
The MongoDB guide covers ids, $lookup includes and transactions in depth.
Questions people ask
Q1Can kastor read documents written by Mongoose?
Yes. Both use the official driver and BSON. A t.id() key maps to _id and is read as the ObjectId hex string, and fields declared with .references() to it are stored as ObjectIds, which is how Mongoose stores refs.
Q2What replaces populate?
include. Post.find().populate('author') becomes db.posts.findMany({ include: { author: true } }). kastor compiles includes to $lookup stages inside one aggregate call, instead of one extra query per populated path.
Q3How do I store embedded subdocuments?
Declare them as t.json<Shape>(). The value is stored as an embedded document or array and typed as Shape in your code. Use separate tables with relations for data you query on its own.
Q4Do I need a replica set?
Only for transactions, which MongoDB allows on replica sets and sharded clusters. Atlas is already a replica set. For local development, start a single node replica set.
Q5Is there an equivalent of lean()?
Every kastor result is already a plain object: no documents, no getters, no save(). That is what lean() gave you, without the loss of types.