Models & Schemas

Define data schemas using Zod and interact with MongoDB using the native driver in Thunder.

Thunder uses Zod as its schema definition and validation library. Zod provides a TypeScript-first approach to declaring the shape of your data - from request bodies to database documents - with full static type inference.

For database interaction, Thunder integrates the native MongoDB driver directly, giving you full control over your queries without the overhead of an abstraction layer.

File Structure

Models live inside the schemas/ directory of your Thunder project. The recommended structure is:

user.ts
post.ts
comment.ts
database.ts

Creating a Model

Define a Zod Schema

Use z.object() to define the shape of your data. It is recommended to separate your input schema (what the user provides) from the full document schema (what gets stored in the database). By convention, schemas are prefixed with $:

schemas/user.ts
import z from "zod";
import { mongodb } from "@/database.ts";
import { $objectId } from "@/core/utils/createCRUD.ts";

// Input schema - fields accepted from the client
export const $userInput = z.object({
  name: z.string(),
  email: z.string().email(),
});

// Full document schema - includes server-managed fields
export const $user = $userInput.extend({
  _id: $objectId.optional(),
  createdAt: z.date().default(() => new Date()),
});

Create the Model

Use the native MongoDB driver to bind your schema to a collection:

schemas/user.ts
export const userModel = mongodb.db().collection<
  z.infer<typeof $user>
>("users");

Using the Model

Once a model is defined, import and use it anywhere in your business logic:

routes/users.ts
import { ObjectId } from "mongodb";
import { mongodb } from "@/database.ts";
import { userModel, $user } from "@/schemas/user.ts";

// Insert a new user (see strictParse below)
await userModel.insertOne(
  $user.strictParse({ name: "John", email: "john@mail.com" })
);

// Find a user by id
await userModel.findOne({ _id: new ObjectId(id) });

// Update
await userModel.updateOne({ _id }, { $set: { name: "Jane" } });

// Delete
await userModel.deleteOne({ _id });

Validate Before Insert with strictParse

When inserting data, always validate it against your schema first. Thunder adds a custom strictParse method to your schemas that:

  • Validates the input shape at runtime.
  • Automatically applies any default values (e.g. createdAt) so you don't hard-code them.
  • Enforces the exact input shape when typing manually.
await userModel.insertOne(
  $user.strictParse({ name: "John", email: "john@mail.com" })
);

You can still use Zod's regular .parse() method, but strictParse is Thunder's type-safe variant that forces the input shape and inserts defaults for you - the recommended practice for writes.


Transactions

For operations that must succeed or fail together, use mongodb.withSession to run them inside a transaction. Pass the session to each operation:

import { mongodb } from "@/database.ts";

await mongodb.withSession((session) =>
  session.withTransaction(async () => {
    await userModel.insertOne(
      $user.strictParse({ name: "John", email: "john@mail.com" }),
      { session }
    );
    await logsModel.insertOne({ action: "user.created" }, { session });
  })
);

Import Conventions

Always use the @/ import alias instead of relative paths. Relative imports into core/ or other framework internals are forbidden and will trigger a lint error:

Relative import into "core/" is forbidden. Use "@/core/..." instead.

Correct:

import { userModel } from "@/schemas/user.ts";

Incorrect:

import { userModel } from "../schemas/user.ts"; // ❌ Avoid this

Complete Model Example

schemas/post.ts
import z from "zod";
import { mongodb } from "@/database.ts";
import { $objectId } from "@/core/utils/createCRUD.ts";

export const $postInput = z.object({
  title: z.string(),
  content: z.string(),
  authorId: z.string(),
});

export const $post = $postInput.extend({
  _id: $objectId.optional(),
  createdAt: z.date().default(() => new Date()),
});

export const postModel = mongodb.db().collection<
  z.infer<typeof $post>
>("posts");

On this page