MongoDB #
MongoDB is a document-based NoSQL database that stores data in BSON format — a flexible JSON-like structure that doesn’t require a rigid schema. Unlike relational databases that require you to define tables and columns first, MongoDB lets each document in a single collection have a different structure. This flexibility makes it the right choice for data that is dynamic, hierarchical, or often changing its structure. In TypeScript, combining MongoDB with a strong type system produces code that is both safe and expressive — you get NoSQL flexibility without losing type safety at compile time.
Installation #
The official MongoDB driver for Node.js already supports TypeScript natively and doesn’t require an additional @types package.
npm install mongodb
If you’re using Mongoose (the popular ODM for MongoDB), the installation differs:
npm install mongoose
npm install --save-dev @types/mongoose
This article focuses on the official MongoDB driver (mongodb), not Mongoose. The official driver gives more direct control over the database and is lighter for use cases that don’t need a full ODM.
Connecting to the Database #
Before doing any operations, you need to create a connection to MongoDB via MongoClient. Always manage the connection carefully — creating a new connection for every operation is an expensive anti-pattern.
import { MongoClient, Db } from "mongodb";
// ANTI-PATTERN: creating a new connection in every function
async function getUser(id: string) {
const client = new MongoClient("mongodb://localhost:27017"); // ✗ new connection per call
await client.connect();
const db = client.db("myapp");
// ...
}
// CORRECT: singleton pattern for the connection
class Database {
private static client: MongoClient;
private static db: Db;
static async connect(uri: string, dbName: string): Promise<Db> {
if (!Database.client) {
Database.client = new MongoClient(uri, {
maxPoolSize: 10, // max 10 connections in the pool
minPoolSize: 2, // min 2 connections always active
serverSelectionTimeoutMS: 5000,
connectTimeoutMS: 10000,
});
await Database.client.connect();
Database.db = Database.client.db(dbName);
}
return Database.db;
}
static async disconnect(): Promise<void> {
if (Database.client) {
await Database.client.close();
}
}
}
// usage
const db = await Database.connect("mongodb://localhost:27017", "myapp");
Connection strings for various environments:
# local without authentication
mongodb://localhost:27017
# local with authentication
mongodb://username:***@localhost:27017/dbname
# MongoDB Atlas (cloud)
mongodb+srv://username:***@cluster.mongodb.net/dbname?retryWrites=true&w=majority
# replica set
mongodb://host1:27017,host2:27017,host3:27017/dbname?replicaSet=myReplicaSet
Defining Types for Documents #
One of the advantages of using TypeScript with MongoDB is the ability to define types for each collection. The MongoDB driver provides generics that enable type safety across all operations.
import { ObjectId, Document } from "mongodb";
// interface for a document stored in the database
interface Produk {
_id?: ObjectId;
nama: string;
harga: number;
kategori: string;
stok: number;
tags: string[];
createdAt: Date;
updatedAt: Date;
}
// interface for a User document with relationships
interface User {
_id?: ObjectId;
nama: string;
email: string;
alamat: {
jalan: string;
kota: string;
provinsi: string;
kodePos: string;
};
createdAt: Date;
}
// get collections with defined types
const produkCollection = db.collection<Produk>("produk");
const userCollection = db.collection<User>("users");
With this approach, TypeScript will give errors when you try to insert a document that doesn’t match the interface, or when accessing a field that doesn’t exist.
CRUD Operations #
Insert — Storing Documents #
insertOne for a single document, insertMany for many documents at once.
import { ObjectId } from "mongodb";
const produkCollection = db.collection<Produk>("produk");
// insert a single document
async function tambahProduk(data: Omit<Produk, "_id">): Promise<ObjectId> {
const hasil = await produkCollection.insertOne({
...data,
createdAt: new Date(),
updatedAt: new Date(),
});
return hasil.insertedId;
}
// insert many documents at once — more efficient than a loop of insertOne
async function tambahBanyakProduk(dataProduk: Omit<Produk, "_id">[]): Promise<number> {
const dokumenSiapInsert = dataProduk.map((p) => ({
...p,
createdAt: new Date(),
updatedAt: new Date(),
}));
const hasil = await produkCollection.insertMany(dokumenSiapInsert, {
ordered: false, // continue even if some fail
});
return hasil.insertedCount;
}
// example usage
const id = await tambahProduk({
nama: "Laptop Gaming X1",
harga: 15000000,
kategori: "elektronik",
stok: 50,
tags: ["laptop", "gaming", "performa-tinggi"],
createdAt: new Date(),
updatedAt: new Date(),
});
Read — Reading Documents #
Queries in MongoDB use a filter object. The driver provides the Filter<T> type to ensure the fields you query match the document interface.
import { Filter, ObjectId } from "mongodb";
// find one document by ID
async function cariProdukById(id: string): Promise<Produk | null> {
return produkCollection.findOne({ _id: new ObjectId(id) });
}
// find by criteria with projection
async function cariProdukByKategori(
kategori: string,
hargaMaks?: number
): Promise<Produk[]> {
const filter: Filter<Produk> = { kategori };
if (hargaMaks !== undefined) {
filter.harga = { $lte: hargaMaks };
}
return produkCollection
.find(filter)
.sort({ harga: 1 }) // sort from lowest price
.limit(20)
.toArray();
}
// pagination with skip and limit
async function daftarProduk(halaman: number, perHalaman: number = 10) {
const skip = (halaman - 1) * perHalaman;
const [produk, total] = await Promise.all([
produkCollection
.find({})
.skip(skip)
.limit(perHalaman)
.toArray(),
produkCollection.countDocuments({}),
]);
return {
data: produk,
total,
halaman,
totalHalaman: Math.ceil(total / perHalaman),
};
}
Query operators that are often used:
// comparison operators
{ harga: { $gt: 100000 } } // greater than
{ harga: { $gte: 100000 } } // greater than or equal
{ harga: { $lt: 500000 } } // less than
{ harga: { $lte: 500000 } } // less than or equal
{ harga: { $ne: 0 } } // not equal
{ harga: { $in: [10000, 20000] } } // in the array
// logical operators
{ $and: [{ kategori: "elektronik" }, { stok: { $gt: 0 } }] }
{ $or: [{ kategori: "laptop" }, { kategori: "tablet" }] }
{ $nor: [{ stok: 0 }, { harga: 0 }] }
// array operators
{ tags: { $in: ["gaming"] } } // array contains any of these
{ tags: { $all: ["gaming", "laptop"] } } // array contains all of these
{ tags: { $size: 3 } } // array has exactly 3 elements
// text operator (requires a text index)
{ $text: { $search: "laptop gaming" } }
Update — Updating Documents #
MongoDB provides various update operators. Avoid replacing the entire document unless really necessary.
import { UpdateFilter } from "mongodb";
// ANTI-PATTERN: replacing the whole document — deletes fields not included
async function updateProdukSalah(id: string, data: Partial<Produk>) {
await produkCollection.replaceOne(
{ _id: new ObjectId(id) },
data as Produk // ✗ deletes fields like createdAt, tags, etc.
);
}
// CORRECT: use $set for partial updates
async function updateProduk(
id: string,
data: Partial<Omit<Produk, "_id" | "createdAt">>
): Promise<boolean> {
const hasil = await produkCollection.updateOne(
{ _id: new ObjectId(id) },
{
$set: {
...data,
updatedAt: new Date(),
},
}
);
return hasil.matchedCount > 0;
}
// update with atomic operators — safe for concurrent operations
async function kurangiStok(idProduk: string, jumlah: number): Promise<boolean> {
const hasil = await produkCollection.updateOne(
{
_id: new ObjectId(idProduk),
stok: { $gte: jumlah }, // make sure stock is sufficient before reducing
},
{
$inc: { stok: -jumlah }, // reduce stock
$set: { updatedAt: new Date() },
}
);
return hasil.modifiedCount > 0;
}
// upsert — insert if not exists, update if exists
async function upsertProduk(
nama: string,
data: Partial<Produk>
): Promise<void> {
await produkCollection.updateOne(
{ nama },
{
$set: { ...data, updatedAt: new Date() },
$setOnInsert: { createdAt: new Date() }, // only set on insert
},
{ upsert: true }
);
}
Important update operators:
$set: { field: nilai } // set a field's value
$unset: { field: "" } // remove a field from the document
$inc: { stok: -1 } // add/subtract a numeric value
$push: { tags: "baru" } // add an element to an array
$pull: { tags: "lama" } // remove an element from an array
$addToSet: { tags: "unik" } // add to array only if not already there
$rename: { namaLama: "namaBaru" } // rename a field
Delete — Deleting Documents #
// delete one document
async function hapusProduk(id: string): Promise<boolean> {
const hasil = await produkCollection.deleteOne({
_id: new ObjectId(id),
});
return hasil.deletedCount > 0;
}
// delete many documents by criteria
async function hapusProdukTidakAktif(): Promise<number> {
const hasil = await produkCollection.deleteMany({
stok: 0,
updatedAt: { $lt: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000) }, // not updated in 30 days
});
return hasil.deletedCount;
}
// soft delete — a safer practice for production
async function softDeleteProduk(id: string): Promise<boolean> {
const hasil = await produkCollection.updateOne(
{ _id: new ObjectId(id) },
{
$set: {
deletedAt: new Date(),
updatedAt: new Date(),
},
}
);
return hasil.modifiedCount > 0;
}
AdeleteManyoperation without a filter ({}) will delete every document in the collection. Always verify the filter before running a bulk delete in production. Consider soft delete (adding adeletedAtfield) instead of hard delete for data that might need to be recovered.
The Aggregation Pipeline #
The aggregation pipeline is the most powerful mechanism in MongoDB for processing and transforming data. The pipeline consists of several stages that process documents sequentially — the output of one stage becomes the input of the next.
flowchart LR
A[(Collection)] --> B["$match\n(filter documents)"]
B --> C["$group\n(group & count)"]
C --> D["$sort\n(sort results)"]
D --> E["$project\n(select fields)"]
E --> F[Final Result]Aggregation Stages Often Used #
interface HasilPenjualan {
_id: string; // category
totalPendapatan: number;
jumlahTransaksi: number;
rataRataHarga: number;
}
async function laporanPenjualanPerKategori(
tanggalMulai: Date,
tanggalAkhir: Date
): Promise<HasilPenjualan[]> {
return produkCollection
.aggregate<HasilPenjualan>([
// Stage 1: filter documents
{
$match: {
createdAt: {
$gte: tanggalMulai,
$lte: tanggalAkhir,
},
stok: { $gt: 0 },
},
},
// Stage 2: group and count
{
$group: {
_id: "$kategori",
totalPendapatan: { $sum: { $multiply: ["$harga", "$stok"] } },
jumlahTransaksi: { $count: {} },
rataRataHarga: { $avg: "$harga" },
},
},
// Stage 3: sort by revenue
{
$sort: { totalPendapatan: -1 },
},
// Stage 4: format the output
{
$project: {
_id: 1,
totalPendapatan: { $round: ["$totalPendapatan", 0] },
jumlahTransaksi: 1,
rataRataHarga: { $round: ["$rataRataHarga", 0] },
},
},
])
.toArray();
}
Lookup — Joining Collections #
MongoDB supports join operations through the $lookup stage. Use it to combine data from two different collections.
interface OrderDenganUser {
_id: ObjectId;
produkId: ObjectId;
jumlah: number;
user: User[]; // the lookup result
}
async function orderDenganDetailUser(): Promise<OrderDenganUser[]> {
return db
.collection("orders")
.aggregate<OrderDenganUser>([
{
$match: {
status: "selesai",
},
},
{
$lookup: {
from: "users", // the collection being joined
localField: "userId", // the field in the orders collection
foreignField: "_id", // the field in the users collection
as: "user", // the output field name
},
},
{
$unwind: {
path: "$user",
preserveNullAndEmptyArrays: true, // still show the order even if no user is found
},
},
])
.toArray();
}
A Complex Pipeline Example #
Here’s an example pipeline that calculates product statistics per category while also adding percentage information:
async function statistikProduk() {
return produkCollection
.aggregate([
// count total products first
{
$facet: {
// track 1: statistics per category
perKategori: [
{
$group: {
_id: "$kategori",
jumlahProduk: { $count: {} },
totalStok: { $sum: "$stok" },
hargaTerendah: { $min: "$harga" },
hargaTertinggi: { $max: "$harga" },
},
},
{ $sort: { jumlahProduk: -1 } },
],
// track 2: overall total to calculate percentages
total: [
{
$group: {
_id: null,
totalProduk: { $count: {} },
},
},
],
},
},
// combine both tracks
{
$project: {
perKategori: {
$map: {
input: "$perKategori",
as: "kategori",
in: {
nama: "$$kategori._id",
jumlahProduk: "$$kategori.jumlahProduk",
totalStok: "$$kategori.totalStok",
hargaTerendah: "$$kategori.hargaTerendah",
hargaTertinggi: "$$kategori.hargaTertinggi",
persentase: {
$round: [
{
$multiply: [
{
$divide: [
"$$kategori.jumlahProduk",
{ $arrayElemAt: ["$total.totalProduk", 0] },
],
},
100,
],
},
1,
],
},
},
},
},
},
},
])
.toArray();
}
Indexing #
Indexes are the biggest factor determining MongoDB query performance. Without an index, MongoDB has to read the entire collection for every query — called a collection scan. With the right index, MongoDB can jump directly to the relevant documents.
flowchart TD
A["Query received"] --> B{"Matching index\navailable?"}
B -- Yes --> C["Index Scan\nO(log n)"]
B -- No --> D["Collection Scan\nO(n)"]
C --> E["Documents found"]
D --> E
E --> F["Results returned"]
style C fill:#16a34a,color:#fff
style D fill:#dc2626,color:#fffCreating Indexes #
async function setupIndex(): Promise<void> {
// single field index — the most common
await produkCollection.createIndex({ kategori: 1 });
// compound index — for queries that often involve two or more fields
// field order matters: match the order of fields in your query
await produkCollection.createIndex(
{ kategori: 1, harga: 1 },
{ name: "idx_kategori_harga" }
);
// unique index — ensures no duplicate values
await userCollection.createIndex(
{ email: 1 },
{ unique: true, name: "idx_email_unique" }
);
// text index — for text search
await produkCollection.createIndex(
{ nama: "text", deskripsi: "text" },
{ name: "idx_text_search" }
);
// partial index — only indexes documents matching the condition
// far smaller and more efficient than a full index
await produkCollection.createIndex(
{ stok: 1 },
{
partialFilterExpression: { stok: { $gt: 0 } },
name: "idx_stok_tersedia",
}
);
// TTL index — documents are automatically deleted after a certain time
await db.collection("sessions").createIndex(
{ createdAt: 1 },
{
expireAfterSeconds: 3600, // delete after 1 hour
name: "idx_ttl_session",
}
);
}
Analyzing Query Performance #
Use explain() to see whether a query uses an index or not:
async function analyzeQuery() {
const result = await produkCollection
.find({ kategori: "elektronik", harga: { $lt: 5000000 } })
.explain("executionStats");
console.log("Stage:", result.queryPlanner?.winningPlan?.stage);
// "IXSCAN" = using an index ✓
// "COLLSCAN" = collection scan, index needed ✗
console.log(
"Documents examined:",
result.executionStats?.totalDocsExamined
);
console.log(
"Documents returned:",
result.executionStats?.totalDocsReturned
);
// Ideal ratio: totalDocsExamined close to totalDocsReturned
}
Transactions #
MongoDB supports multi-document ACID transactions since version 4.0 (only on replica sets and sharded clusters). Use transactions for operations that must succeed or fail atomically.
async function transferStok(
idProdukAsal: string,
idProdukTujuan: string,
jumlah: number
): Promise<void> {
const session = Database.client.startSession();
try {
await session.withTransaction(async () => {
// reduce the source product's stock
const hasilKurang = await produkCollection.updateOne(
{
_id: new ObjectId(idProdukAsal),
stok: { $gte: jumlah },
},
{
$inc: { stok: -jumlah },
$set: { updatedAt: new Date() },
},
{ session }
);
if (hasilKurang.modifiedCount === 0) {
throw new Error("Stok tidak mencukupi atau produk tidak ditemukan");
}
// add stock to the destination product
await produkCollection.updateOne(
{ _id: new ObjectId(idProdukTujuan) },
{
$inc: { stok: jumlah },
$set: { updatedAt: new Date() },
},
{ session }
);
});
} finally {
await session.endSession();
}
}
MongoDB transactions require a replica set or sharded cluster — they can’t be used on standalone MongoDB. For local development, you can run MongoDB as a single-node replica set with the command mongod --replSet rs0.Error Handling #
MongoDB throws specific errors that you can catch and handle appropriately.
import { MongoError, MongoServerError, WriteConcernError } from "mongodb";
async function simpanUserAman(data: Omit<User, "_id">): Promise<ObjectId | null> {
try {
const hasil = await userCollection.insertOne({
...data,
createdAt: new Date(),
});
return hasil.insertedId;
} catch (error) {
if (error instanceof MongoServerError) {
// error code 11000 = duplicate key (unique constraint violation)
if (error.code === 11000) {
const fieldDuplikat = Object.keys(error.keyValue || {}).join(", ");
throw new Error(`Data sudah ada: ${fieldDuplikat} telah terdaftar`);
}
}
if (error instanceof MongoError) {
// connection or timeout error
if (error.message.includes("connection")) {
throw new Error("Gagal terhubung ke database, coba lagi sebentar");
}
}
throw error; // re-throw unknown errors
}
}
// retry wrapper for operations that might temporarily fail
async function withRetry<T>(
operasi: () => Promise<T>,
maxRetry: number = 3,
delayMs: number = 1000
): Promise<T> {
let percobaan = 0;
while (percobaan < maxRetry) {
try {
return await operasi();
} catch (error) {
percobaan++;
const bisaRetry =
error instanceof MongoError &&
(error.message.includes("connection") ||
error.message.includes("timeout"));
if (!bisaRetry || percobaan >= maxRetry) {
throw error;
}
await new Promise((resolve) => setTimeout(resolve, delayMs * percobaan));
}
}
throw new Error("Operasi gagal setelah semua percobaan");
}
When to Use MongoDB vs a Relational Database #
Choosing a database is an important architectural decision. MongoDB isn’t always the best choice.
Choose MongoDB if:
✓ Data structures are non-uniform or often changing
✓ Data is hierarchical and often accessed together (product + variants + images)
✓ Need easy horizontal scaling (sharding)
✓ Read/write speed is very critical and data relationships are minimal
✓ Storing data like logs, events, or schema-less content
Choose a relational database (PostgreSQL, MySQL) if:
✗ Data is highly relational with many joins between tables
✗ Require complex ACID transactions across many collections
✗ Financial or transaction data demanding high consistency
✗ The team is familiar and the SQL ecosystem fits better
✗ Need flexible ad-hoc queries with complex joins
Summary #
- The official
mongodbdriver already supports TypeScript natively — use genericscollection<Interface>()for full type safety across all CRUD operations.- A singleton connection with a connection pool is a must — don’t create a new connection for every operation.
- Use
$setinstead ofreplaceOnefor partial updates so other fields aren’t accidentally deleted.- The aggregation pipeline is the best way to process data on the database side — take advantage of
$match,$group,$lookup, and$facetfor reports and complex data transformations.- Indexes are the key to performance — create indexes for all fields frequently used as filters; use
explain()to verify whether a query uses an index.- Transactions are only available on replica sets — use them for operations that must be atomic, like transfers or debit/credit.
- Catch specific errors like
MongoServerErrorcode11000for duplicate keys, and implement retry logic for transient errors like connection timeouts.- Soft delete is safer than hard delete in production — add a
deletedAtfield and filter it in every query rather than permanently deleting documents.