TypeScript, Zod, and MongoDB: A Guide to Data Access Layer without ORM
Tags: programming, javascript, typescript, mongodb
Published: 2023-06-14 11:22:59

> This article is also posted on [Medium](https://zzdjk6.medium.com/typescript-zod-and-mongodb-a-guide-to-orm-free-data-access-layers-f83f39aabdf3)

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

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.

```ts
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.

```ts
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.

```ts
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.

```ts
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`.

Some people may prefer to enforce the Repository pattern and call it `UserRepository`, making the class solely focused on CRUD operations without any business logic, then use `UserRepository` inside the `UserService`. For small to medium-sized projects, I would recommend to start with `Service` which should already efficiently fulfil the needs without too much overhead.

### 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):

![JetBrains IDE (e.g., IntelliJ)](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/5js20pkty3920lxri8mq.png)


Visual Studio Code:

![Visual Studio Code](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/wxqw3m0m4v0qhfn0hbtp.png)

### Code Snippet for Idea Verification

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

```ts
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:

![Result](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/sleakm0s2v1pzzysyeef.png)

### 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.