Node / Express Guide

Models

Interface + class model patterns for MongoDB documents.

Models define the shape of MongoDB documents. There is no Mongoose — just TypeScript interfaces and plain classes.

TL;DR — Interface (IUser) for read shape, Pick type for create inputs, class with Object.assign constructor that validates via Joi schema. Models CAN have getter methods (get name()), toJSON(), and getMorphClass().

Pattern: Interface + Class

Each model file exports:

  1. An interface (I prefix) — the full document shape with mostly optional fields (as MongoDB may return partial projections).
  2. A type alias for create inputs (T prefix + Create suffix) using Pick.
  3. A class implementing the interface — the constructor validates with a Joi schema, uses Object.assign for field population, and sets defaults.
// src/models/user.model.ts
import { ObjectId } from "mongodb";
import { BadRequestError, toObjectId } from "@codisolutions23/node-utils";
import {
  UserType,
  TUserType,
  TUserStatus,
  UserStatus,
} from "../enums/user.enum";
import { createUserSchema } from "../validations/user.validation";

export interface IUser {
  _id?: ObjectId;
  type?: TUserType;
  organizationId?: string | ObjectId;
  firstName: string;
  middleName?: string;
  lastName: string;
  email: string;
  phone: string;
  password?: string;
  emailVerifiedAt?: Date;
  rememberToken?: string;
  mayaCustomerId?: string;
  defaultPaymentMethodId?: string;
  hasPaymentMethod?: boolean;
  status?: TUserStatus;
  createdAt?: Date;
  updatedAt?: Date;
  deletedAt?: Date | null;
}

export type TUserCreate = Pick<
  IUser,
  | "type"
  | "organizationId"
  | "firstName"
  | "middleName"
  | "lastName"
  | "email"
  | "phone"
> & { password: string };

export class User implements IUser {
  _id?: ObjectId;
  type!: TUserType;
  organizationId?: string | ObjectId;
  firstName!: string;
  middleName?: string;
  lastName!: string;
  email!: string;
  phone!: string;
  password!: string;
  emailVerifiedAt?: Date;
  rememberToken?: string;
  mayaCustomerId?: string;
  defaultPaymentMethodId?: string;
  hasPaymentMethod?: boolean;
  status?: TUserStatus;
  createdAt?: Date;
  updatedAt?: Date;
  deletedAt?: Date | null;

  constructor(value: IUser, validate = true) {
    // Validate with Joi schema (can be skipped for internal hydration)
    if (validate) {
      const { error } = createUserSchema.validate({ ...value });
      if (error) throw new BadRequestError(error.message);
    }

    Object.assign(this, {
      ...value,
      type: value.type || UserType.CUSTOMER,
      organizationId: value.organizationId
        ? toObjectId(value.organizationId)
        : value.organizationId,
      status: value.status || UserStatus.ACTIVE,
      createdAt: value.createdAt || new Date(),
      updatedAt: value.updatedAt || new Date(),
      deletedAt: value.deletedAt || null,
    });
  }

  get name(): string {
    return `${this.firstName} ${this.lastName}`;
  }

  toJSON() {
    return { ...this, name: this.name };
  }

  getMorphClass(): string {
    return "User";
  }
}

Key Differences from the Old Pattern

AspectActual Pattern
Constructor parameter(value: IUser, validate = true) — not (data: TUserCreate)
Field assignmentObject.assign(this, { ...value, ...defaults })
ValidationJoi schema in constructor (via createUserSchema)
ObjectId conversiontoObjectId() from codi-node-utils (not new ObjectId())
MethodsModels CAN have get name(), toJSON(), getMorphClass()
refreshTokenNOT on the User model — stored in a separate tokens collection
Email normalizationDone at the Joi validation level, not in the constructor

Resource Model Example

// src/models/resource.model.ts
import { ObjectId } from "mongodb";
import { BadRequestError, toObjectId } from "@codisolutions23/node-utils";
import { ResourceStatus, TResourceStatus } from "../enums/resource.enum";
import { createResourceSchema } from "../validations/resource.validation";

export interface IResource {
  _id?: ObjectId;
  organizationId?: ObjectId | string;
  name: string;
  description?: string;
  status?: TResourceStatus;
  createdAt?: Date;
  updatedAt?: Date;
  deletedAt?: Date | null;
}

export type TResourceCreate = Pick<
  IResource,
  "name" | "description" | "organizationId"
>;

export class Resource implements IResource {
  _id?: ObjectId;
  organizationId?: ObjectId | string;
  name!: string;
  description?: string;
  status?: TResourceStatus;
  createdAt?: Date;
  updatedAt?: Date;
  deletedAt?: Date | null;

  constructor(value: IResource, validate = true) {
    if (validate) {
      const { error } = createResourceSchema.validate({ ...value });
      if (error) throw new BadRequestError(error.message);
    }

    Object.assign(this, {
      ...value,
      organizationId: value.organizationId
        ? toObjectId(value.organizationId)
        : value.organizationId,
      status: value.status || ResourceStatus.ACTIVE,
      createdAt: value.createdAt || new Date(),
      updatedAt: value.updatedAt || new Date(),
      deletedAt: value.deletedAt || null,
    });
  }
}

Rules

  • Interfaces own the query/read shape — fields are optional where MongoDB might not include them.
  • Classes own the write shape — the constructor uses Object.assign with ...value + defaults.
  • Joi validation in constructor — validates input before assignment. Pass validate = false for internal hydration.
  • toObjectId() from codi-node-utils for ObjectId conversion (not raw new ObjectId()).
  • Never skip deletedAt: null in the constructor — it enables the soft-delete filter { deletedAt: null } on all queries.
  • Models CAN have methodsget name(), toJSON(), getMorphClass() are common patterns.
  • Email normalization happens at the Joi level (.lowercase().trim()) — not in the model constructor.