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:
- An interface (
Iprefix) — the full document shape with mostly optional fields (as MongoDB may return partial projections). - A type alias for create inputs (
Tprefix +Createsuffix) usingPick. - A class implementing the interface — the constructor validates with a Joi schema, uses
Object.assignfor 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
| Aspect | Actual Pattern |
|---|---|
| Constructor parameter | (value: IUser, validate = true) — not (data: TUserCreate) |
| Field assignment | Object.assign(this, { ...value, ...defaults }) |
| Validation | Joi schema in constructor (via createUserSchema) |
ObjectId conversion | toObjectId() from codi-node-utils (not new ObjectId()) |
| Methods | Models CAN have get name(), toJSON(), getMorphClass() |
refreshToken | NOT on the User model — stored in a separate tokens collection |
| Email normalization | Done 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.assignwith...value+ defaults. - Joi validation in constructor — validates input before assignment. Pass
validate = falsefor internal hydration. toObjectId()fromcodi-node-utilsforObjectIdconversion (not rawnew ObjectId()).- Never skip
deletedAt: nullin the constructor — it enables the soft-delete filter{ deletedAt: null }on all queries. - Models CAN have methods —
get name(),toJSON(),getMorphClass()are common patterns. - Email normalization happens at the Joi level (
.lowercase().trim()) — not in the model constructor.
