Building Monarch: A Type-Safe ODM for MongoDB
The Problem
MongoDB is incredibly flexible, but that flexibility comes at a cost. Without guardrails, your data layer becomes a minefield:
- Typos in field names go unnoticed until production
- Schema changes break queries silently
- There's no autocomplete for your document shape
Mongoose helps, but its TypeScript support has always felt like an afterthought. I wanted something TypeScript-first — where the schema is the source of truth for types, queries, and validation.
That's why I built Monarch.
What Is Monarch?
Monarch is a type-safe ODM (Object-Document Mapper) for MongoDB. It takes a schema-first approach where you define your document shapes once and get full type safety everywhere:
import { createDatabase, createSchema, field } from "monarchorm";
const UserSchema = createSchema("users", {
name: field.string().required(),
email: field.string().required(),
age: field.number().optional(),
role: field.enum(["admin", "user"]).default("user"),
createdAt: field.date().default(() => new Date()),
});
const db = createDatabase(client, {
users: UserSchema,
});
// Full autocomplete and type checking
const user = await db.users.findOne({
email: "prince@example.com",
});
// user.name ✓ (string)
// user.age ✓ (number | undefined)
// user.foo ✗ TypeScript error!Design Decisions
Schema as the Single Source of Truth
Instead of writing a schema AND a TypeScript interface, Monarch infers the type from the schema definition. One source of truth, zero drift:
// The schema IS the type
type User = InferSchemaType<typeof UserSchema>;
// {
// name: string;
// email: string;
// age?: number;
// role: "admin" | "user";
// createdAt: Date;
// }Zero Runtime Overhead
Monarch's type system is entirely compile-time. At runtime, it's a thin wrapper around the native MongoDB driver — no query translation layer, no ORM magic. This means:
- No performance penalty compared to raw MongoDB queries
- No hidden queries or N+1 problems
- Full access to MongoDB's native features when you need them
Built-in Validation
Every field type has built-in validation that runs before writes:
const PostSchema = createSchema("posts", {
title: field.string().min(1).max(200),
slug: field.string().regex(/^[a-z0-9-]+$/),
views: field.number().min(0),
status: field.enum(["draft", "published"]),
});
// This throws a validation error at runtime
await db.posts.insertOne({
title: "", // ✗ min length is 1
slug: "Invalid Slug!", // ✗ doesn't match regex
views: -5, // ✗ min is 0
status: "archived", // ✗ not in enum
});Lessons Learned
Building an open-source developer tool taught me a few things:
-
API design is everything. I rewrote the schema builder API three times before it felt right. The final version uses a chainable builder pattern that maps naturally to how developers think about data.
-
TypeScript's type system is incredibly powerful. Monarch's type inference relies on conditional types, mapped types, and recursive generics. It's essentially a compiler within a compiler.
-
Documentation is a feature. The best API in the world is useless if developers can't figure out how to use it. I invested heavily in docs, examples, and error messages.
What's Next
Monarch is actively maintained and growing. Some things on the roadmap:
- Migration support — versioned schema changes with rollback
- Plugin system — extend Monarch with custom field types and hooks
- Aggregation pipeline builder — type-safe aggregation queries
Check it out at monarchorm.com and let me know what you think.