@mauroandre/zodmongo
v0.0.5
Published
Lightweight MongoDB ODM with Zod validation. TypeScript-first, no Mongoose.
Downloads
626
Maintainers
Readme
ZodMongo
Lightweight MongoDB ODM powered by Zod schemas. TypeScript-first, built on the native MongoDB driver — no Mongoose.
Why ZodMongo?
- No Mongoose — uses the native MongoDB driver, zero overhead
- Zod-native — define schemas with Zod, not a proprietary format
- TypeScript-first — types inferred directly from your Zod schemas
- Tiny — ~300 lines of code, only
mongodbandzodas dependencies - Transparent id/ObjectId — work with
id(string) in your app,_id(ObjectId) in MongoDB - Automatic timestamps —
createdAtandupdatedAtmanaged by the ODM - Built-in pagination — via aggregation pipeline with
$facet - Full aggregation pipeline —
findManyaccepts a complete pipeline, not just match filters - Relations & Snapshots — declare references to other collections with automatic
$lookupgeneration
Install
npm install @mauroandre/zodmongoQuick Start
import { connect, close, save, findMany, deleteMany } from "@mauroandre/zodmongo";
import { dbSchema } from "@mauroandre/zodmongo";
import { z } from "zod/v4";
// Connect
await connect("mongodb://localhost:27017", "mydb");
// Define a schema
const userSchema = dbSchema({
name: z.string(),
email: z.string().email(),
});
type User = z.infer<typeof userSchema>;
// Insert
const user = userSchema.parse({ name: "Mauro", email: "[email protected]" });
await save("users", user);
console.log(user.id); // ObjectId string, auto-assigned
// Update
user.name = "Mauro André";
await save("users", user);
// Find
const users = await findMany<User>("users", { name: "Mauro André" });
const all = await findMany<User>("users");
// Delete
await deleteMany("users", { email: "[email protected]" });
// Close
await close();Schemas
dbSchema(shape)
Creates a schema that extends the base model with id, createdAt, and updatedAt. This is the primary way to define your models.
import { dbSchema } from "@mauroandre/zodmongo";
import { z } from "zod/v4";
const postSchema = dbSchema({
title: z.string(),
body: z.string(),
published: z.boolean().default(false),
});
type Post = z.infer<typeof postSchema>;
// { id: string | null, createdAt: Date | null, updatedAt: Date | null, title: string, body: string, published: boolean }embeddedSchema(shape)
Creates a schema without the base model fields (id, createdAt, updatedAt). Use for nested objects that don't need their own identity.
import { embeddedSchema } from "@mauroandre/zodmongo";
import { z } from "zod/v4";
const addressSchema = embeddedSchema({
street: z.string(),
city: z.string(),
zip: z.string(),
});
const userSchema = dbSchema({
name: z.string(),
address: addressSchema,
});dbModelSchema and idSchema
Low-level schemas if you need to extend manually:
import { dbModelSchema, idSchema } from "@mauroandre/zodmongo/schema";
const customSchema = dbModelSchema.extend({
name: z.string(),
});
// idSchema validates a string as a valid ObjectId
idSchema.parse("507f1f77bcf86cd799439011"); // ok
idSchema.parse("invalid"); // throwsAPI
connect(uri, dbName)
Connects to MongoDB. Returns the Db instance.
const db = await connect("mongodb://localhost:27017", "mydb");close()
Waits for pending promises and closes the connection.
await close();getDb()
Returns the current Db instance. Throws if not connected.
const db = getDb();
const collection = db.collection("users");save(collection, doc, filter?, options?)
Smart upsert — inserts if no id, updates if id exists. Automatically manages createdAt and updatedAt.
// Insert (no id)
const user = userSchema.parse({ name: "Mauro", email: "[email protected]" });
await save("users", user);
// user.id is now set to the generated ObjectId string
// Update (has id)
user.name = "Updated";
await save("users", user);
// Upsert with custom filter
await save("users", user, { email: "[email protected]" });
// Disable upsert (update only, no insert)
await save("users", user, { email: "[email protected]" }, { upsert: false });findMany<T>(collection, matchOrPipeline?, options?)
Finds documents using a simple match object or a full aggregation pipeline. Automatically converts _id to id and ObjectIds to strings in the results.
// All documents
const all = await findMany<User>("users");
// Simple match
const admins = await findMany<User>("users", { role: "admin" });
// Find by id
const found = await findMany<User>("users", { id: "507f1f77bcf86cd799439011" });
// Full aggregation pipeline
const topAdmins = await findMany<User>("users", [
{ $match: { role: "admin" } },
{ $sort: { createdAt: -1 } },
{ $limit: 10 },
]);Pagination
Pass { paginate: true } to get paginated results via $facet:
const page = await findMany<User>("users", {}, {
paginate: true,
currentPage: 1,
docsPerPage: 20,
});
page.docs; // User[]
page.currentPage; // 1
page.pageQuantity; // total pages
page.docsQuantity; // total documentsdeleteMany(collection, filter)
Deletes documents matching the filter. Automatically converts id to _id.
await deleteMany("users", { email: "[email protected]" });
await deleteMany("users", { id: "507f1f77bcf86cd799439011" });Relations
Declare references between collections. The ODM generates $lookup pipelines automatically.
relation(schema, config)
Marks a field as a reference to another collection.
const companySchema = dbSchema({ name: z.string() });
const userSchema = dbSchema({
name: z.string(),
company: relation(companySchema, { collection: "companies" }),
});
// When fetching, use getPipeline() to auto-generate $lookup stages
const pipeline = getPipeline(userSchema);
const users = await findMany<User>("users", pipeline);
// users[0].company is now the full company document, not just an ObjectIdWhen saving, use toSave() to convert relations back to ObjectIds:
const dataToSave = toSave(userSchema, userData);
// dataToSave.company is now an ObjectId
await save("users", dataToSave);Array relations
const tagSchema = dbSchema({ label: z.string() });
const postSchema = dbSchema({
title: z.string(),
tags: z.array(relation(tagSchema, { collection: "tags" })),
});Custom foreign field
const categorySchema = dbSchema({ slug: z.string(), name: z.string() });
const postSchema = dbSchema({
title: z.string(),
category: relation(categorySchema, { collection: "categories", foreignField: "slug" }),
});Reverse relations
When a field is a populated destination (not a stored reference), use localField to match on another field of the same document:
const appSchema = dbSchema({
quadletName: z.string(),
policy: relation(backupPolicySchema, {
collection: "backupPolicies",
localField: "quadletName", // match on quadletName
foreignField: "app", // against backupPolicies.app
}),
});toSave() automatically omits reverse relation fields from the saved document.
Circular references
If two schemas reference each other (e.g. User ↔ Company), ZodMongo detects the cycle and truncates the nested pipeline — the outer lookups expand normally, but once a collection appears in the ancestor chain, the inner $lookup is generated without a nested pipeline. Sibling references to the same collection are not affected.
snapshot(schema)
Marks a field as a persisted copy. The ODM will not generate $lookup for it and will not convert it to ObjectId when saving. Useful for denormalized data you want to store as-is.
const userSchema = dbSchema({
name: z.string(),
company: snapshot(companySchema), // stored as a full copy, no lookup
});getPipeline(schema)
Generates the aggregation pipeline (with $lookup, $set, $project) from a schema.
const pipeline = getPipeline(userSchema);
// Use it with findMany
const users = await findMany<User>("users", [...pipeline, { $match: { active: true } }]);toSave(schema, data)
Parses data through the schema and converts relations to ObjectIds for saving.
const prepared = toSave(userSchema, rawData);
await save("users", prepared);id ↔ _id ↔ ObjectId
ZodMongo automatically handles conversions between your app's id (string) and MongoDB's _id (ObjectId):
| Direction | What happens |
|---|---|
| Reading (MongoDB → App) | _id (ObjectId) becomes id (string), recursively |
| Saving (App → MongoDB) | id (string) becomes _id (ObjectId), valid ObjectId strings are converted |
| Querying | { id: "..." } becomes { _id: ObjectId("...") }, supports dot-notation (company.id → company._id) |
Automatic Timestamps
createdAt— set automatically on insert ($setOnInsert), never modified on updateupdatedAt— set on every save ($set)
Promise Tracking
Use trackPromise() to register fire-and-forget operations. close() waits for all tracked promises before disconnecting.
import { trackPromise, close } from "@mauroandre/zodmongo";
trackPromise(save("logs", logEntry));
trackPromise(save("logs", anotherEntry));
await close(); // waits for both saves to completeDevelopment
# Start MongoDB
docker compose up -d
# Run tests
npm test
# Type check
npm run typecheck
# Build
npm run buildLicense
MIT
