TypeScript, Zod, and MongoDB: A Guide to Data Access Layer without ORM

Embracing New Paradigms for Enhanced Data Consistency and Type Safety

Thor Chen
6 min readJun 14, 2023
Image generated via Midjourney

In this article, we’re going to discuss an alternative approach to handling data access layer in TypeScript with MongoDB, without using ORM.

Traditionally, developers leverage Object-Relational Mapping (ORM) tools to map between data types in databases and object-oriented programming languages. However, ORM may sometimes lead to performance issues, complex configurations, or inflexibility to read/write data.

To circumvent these challenges, we will utilise a method that employs zod for data validation and type inference, companion object pattern for converting Entity to DTO (Data Transfer Objects), and encapsulate data logic within a Service class.

Why Schema Validation?

Schema validation is a programming process that ensures data conforms to a predefined schema or structure. The schema serves as a blueprint or model that defines permitted data and its organisation, often specified in terms of data types, constraints, and relationships. By enforcing schema validation, programs can catch data errors early, enhance data consistency, and prevent potential issues related to incorrect or malformed data.

There are two common scenarios in which we need to apply schema validation:

  1. When handling input data, such as with API request payloads. In this context, schema validation can help ensure that the submitted data conforms to the expected format and structure. This is especially important for complex data sets that may contain a variety of different fields and data types.
  2. When performing read and write operations with a NoSQL database, such as MongoDB. In this context, schema validation can help ensure that the written data conforms to the expected schema, and can also validate retrieved data. This is especially important when data consistency is a concern, or when there are strict requirements for data quality and accuracy.

This article is focusing primarily on the second scenario.

Why Zod?

Zod is a TypeScript-first library designed for schema declaration and validation. It enables developers to construct schemas that validate runtime data and generate TypeScript types, ensuring type safety without additional manual type annotations. By integrating with TypeScript, zod enhances the language's static typing capabilities with runtime validation, offering features like custom error handling, nested schemas, and transformations.

Please note that although MongoDB has a “JSON Schema Validation” feature, some MongoDB API-compatible services like AWS DocumentDB do not support it. Furthermore, I believe that the application code should be conscious of and enforce all the data constraints it is operating with. Therefore, I would still prefer to have schema validation as part of the application code, and this is where zod can be helpful.

Defining Entity with Zod

Let’s consider an example where we’re working with users in a MongoDB database. We’ll start by defining an entity representing how a user is stored in the database.

import { z } from "zod";
import { ObjectId } from "mongodb";

export const userEntitySchema = z.object({
_id: z.instanceof(ObjectId),
name: z.string(),
email: z.string().email(),
});

export type UserEntity = z.infer<typeof userEntitySchema>;

In the above code, we’re defining a userEntitySchema and a UserEntity type. The schema describes that a user entity has three fields: _id, name, and email. The type UserEntity is inferred directly from the schema.

Defining Data Transfer Object (DTO) with Zod

Next, we’ll define a DTO that represents how we’re going to use the user data in our application.

export const userDTOSchema = z.object({
id: z.string(),
name: userEntitySchema.shape.name,
email: userEntitySchema.shape.email,
});

export type UserDTO = z.infer<typeof userDTOSchema>;

Here, we’ve defined a similar schema and type for UserDTO, but notice that the _id field has been replaced with id, while name and email are both using the shape from userEntitySchema.

Applying the Companion Object Pattern

To convert an entity to a DTO, we’ll apply the companion object pattern. This pattern involves creating a companion object to hold static functions related to the class or type.

export const UserDTO = {
convertFromEntity(entity: UserEntity): UserDTO {
const candidate: UserDTO = {
id: entity._id.toHexString(),
name: entity.name,
email: entity.email,
};
return userDTOSchema.parse(candidate);
},
};

In the above code, we’ve defined an object called UserDTO with one function:convertFromEntity — this function can convert a UserEntity object to a UserDTO object.

To explain it a bit more, TypeScript manage “types” and “concrete object definitions” in different namespaces, that’s why we could define a type UserDTO as well as a concrete object called the same name. Object defined in this way usually has some util or helper functions that apply to the same-name type, thus it is so called a “companion object”.

Please also note that we are not only guarantee the type safety on compile time, but also ensure the runtime type safety via schema.parse() — which will make sure that the object meet the schema validation requirement.

Encapsulating Data Logic in a Service Class

Finally, we will encapsulate our data logic within a service class, which will handle all CRUD operations for the users. This service class will interact directly with MongoDB using the MongoDB Node.js driver.

import { MongoClient, Db } from "mongodb";

export class UserService {
private readonly db: Db;

constructor(mongoClient: MongoClient) {
this.db = mongoClient.db();
}

private getUsersCollection() {
return this.db.collection<UserEntity>("users");
}

async findUser(id: string): Promise<UserDTO | null> {
const entity = await this.getUsersCollection().findOne({ _id: new ObjectId(id) });
return entity ? UserDTO.convertFromEntity(entity) : null;
}

async createUser(dto: Omit<UserDTO, "id">): Promise<UserDTO> {
const candidate = userEntitySchema.parse({
...dto,
_id: new ObjectId(),
});
const { insertedId } = await this.getUsersCollection().insertOne(candidate);
return UserDTO.convertFromEntity({ ...dto, _id: insertedId });
}

async updateUser(id: string, dto: Omit<Partial<UserDTO>, "id">): Promise<UserDTO | null> {
const candidate = userEntitySchema.partial().parse(dto);

const { value } = await this.getUsersCollection().findOneAndUpdate(
{ _id: new ObjectId(id) },
{ $set: candidate },
{ returnDocument: "after" }
);
return value ? UserDTO.convertFromEntity(value) : null;
}

async deleteUser(id: string): Promise<void> {
await this.getUsersCollection().deleteOne({ _id: new ObjectId(id) });
}
}

In this UserService class, we implemented all CRUD operations for users: findUser, createUser, updateUser, and deleteUser. All these methods interact with MongoDB directly through the MongoDB Node.js driver and handle conversions between UserEntity and UserDTO.

Please note that this example may not represent the real-world business data constraints, but rather an idea to encapsulate the data logic around User.

Furthermore, though we call this class as UserService, it is actually following Repository pattern, and it is focusing on exposing CRUD operations to data. Thus, it can be named as UserRepository, or even better to be named in that way especially when the project grows larger and the needs of having a UserService to encapsulate business logic emerges.

Bonus: Out-of-Box IDE Support with IntelliSense

By define types in the way we described in this blog, IDE will get us covered without need for extra library or plugin. For example, when we try to build query using .findOne(), IDE or code editor knows what are the fields we can filer on:

JetBrains IDE (e.g., IntelliJ)
Visual Studio Code

Code Snippet for Idea Verification

Here we could have a simple code snippet to verify our idea:

import _ from "lodash";

const main = async () => {
const mongoClient = new MongoClient("mongodb://localhost:27017/data-access-example");

try {
await mongoClient.db().dropCollection("users");
} catch (e) {
if (_.get(e, "codeName") !== "NamespaceNotFound") {
throw e;
}
console.log(`Collection "users" does not exist, no need to drop`);
}

await mongoClient.db().createCollection("users");

const userService = new UserService(mongoClient);

const createdUser = await userService.createUser({ name: "example", email: "example@example.com" });
console.log({ createdUser });

const updatedUser = await userService.updateUser(createdUser.id, { name: "exampleX" });
console.log({ updatedUser });

const foundUser = await userService.findUser(createdUser.id);
console.log({ foundUser });

await userService.deleteUser(createdUser.id);
};

main()
.then(() => {
process.exit();
})
.catch((e) => {
console.error(e);
process.exit(1);
});
Result

Conclusion

In this blog post, we’ve discussed an alternative approach to handle the data access layer without ORM when using TypeScript and MongoDB. This method offers flexibility and eliminates potential bottlenecks associated with ORMs, while maintaining the consistency and clarity of our data layer. With the zod library for data validation and type inference, companion object pattern for conversion operations, and encapsulation within service classes, we're able to keep our code clear, concise, and highly functional.

--

--

Thor Chen
Thor Chen

Written by Thor Chen

Passionate JavaScript/TypeScript Developer with a Full-stack Background