DEV Community

loading...

How can I reduce amount of boilerplate code while working with TypeScript+mongoose 🙈

nuclight profile image Konstantin Alikhanov ・3 min read

Let's say I have two entities: Vehicle and Tariff. They are related as 1-to-many (vehicle can has many tariffs).

This is my basic types definition of objects, I want to work with:

interface Tariff {
    id: string;         // <-- ID of entity
    createdAt: Date;    // <-- some meta data fields for sorting and etc.

    days: number;       // <-- userful info
    price: number;      // <-
}

interface Vehicle {
    id: string;          // <-- ID of entity
    createdAt: Date;     // <-- some meta data fields for sorting and etc.

    model: string;       // <-- userful info
    year?: string;       // <-
    seatsCount?: number; // <-
    tariffs: Tariff[];   // <-- array of tariffs
}

Looks great. No we need to create database model. First let's define mongoose schema:

import mongoose from "mongoose";

const vehicleSchema = new mongoose.Schema({
    createdAt: {type: Date, required: true, default: () => new Date()},

    model: {type: String, required: true},
    year: String,
    seatsCount: Number,
});

Awesome. I decided no to put tariffs inside vehicle documents and they will be stored separately.

Next we need to create mongoose model. But we need to provide generic type extending mongoose.Document to mongoose.model. And this is where code duplication begins:

// First we need to define TypeScript schema equalent of mongoose schema:
interface VehicleSchema {
    createdAt: Date;

    model: string;
    year?: string;
    seatsCount?: number;
}

// Then we need to defined a new type of out vehicle models:
type VehicleModel = VehicleSchema && mongoose.Document;

// And finaly we can create model:
const VehicleModel = mongoose.model<VehicleModel>("Car", carSchema);

// Look, here is a type named "VehicleModel" and a constant named "VehicleModel"


Now we can write functions for CRUD operations:

namespace vehicle {
    export const create = async (form: VehicleCreateForm): Promise<Vehicle> => {
        const fields = toDb(form);

        // assume form is prevalidated
        let dbVehicle = new VehicleModel(form);
        dbVehicle = await model.save();

        // tariff - is the separate module, like this one,  
        // createAllFor methods retuns Tariff[]
        const tariffs = await tariff.createAllFor(dbVehicle.id, form.tariffs);

        return fromDb(dbVehicle, tariffs);
    }

    //...

    // export const update = ...
    // export const list = ...
    // export const get = ...    
    // etc..
}

Here we intoduce one extra type: VehicleCreateForm, it describes all fields needed to create vehicle:

interface VehicleCreateForm {
    model: string;
    year?: string;
    seatsCount?: number;
    tariffs: TariffCreateFields[]; // <-- here is another one special type
}

interface TariffCreateFields {
    days: number;
    price: number;
}

Also we need to define two functions: toDb to prevent some fields, f.e. tariffs be passed to model. And fromDb "translates" model to Vehicle entity, removes or converts some fields:

const u = <T>(value: T | undefined | null): (T | undefined) => value === null ? undefined : value;

const toDb = (f: VehicleCreateForm): VehicleCreateFields => ({
    model: f.model,
    year: f.year,
    seatsCount: f.seatsCount,
});

const fromDb = (m: VehicleModel, tariffs: Tariff[]): Vehicle => ({
    id: m.id,
    createdAt: m.createdAt,

    model: m.model,
    year: u(m.year),
    tariffs,
});

And, yaaa, we need one more extra type: VehicleCreateFields. Fields we are passing to model constructor.

interface VehicleCreateFields {
    model: string;
    year?: string;
    seatsCount?: number;
}

Seems like here is done.

Also we need to define tariff namespace similar to vehicle. All types and will be duplicated too: TariffSchema, TariffModel, TariffCreateForm, TariffCreateDocument.

And this will happen for every new entity. In current case I can avoid creating VehicleUpdateFields type and use VehicleCreateFields for creating and updating. There could be also VehicleUpdateDocument and etc.

How can I reduce the amount of code duplications. How you deal with typescript/mongoose?

Ofcouse I can extract common fields to "common chunck" interfaces, but I event don't know how to name them. I tried to use interface inherence, end up with messy hell of small types. Like:

interface Entity {
    id: string;
    createdAt: Date;
}

interface VehicleOwnFields {
    model: string;
    year?: string;
    seatsCount?: number;
}

interface VehicleOwnFields {
    model: string;
    year?: string;
    seatsCount?: number;
}

interface VehicleExternalFields {
    tariffs: Tariff[];
}

type Vehicle = Entity && VehicleOwnFields && VehicleExternalFields;
// and so on... 

I think this is not the best solution.

Also there can be cases when fields has different types in Entity and in Schema. F.e. I can decide to serialize Tariff[] to JSON and store as string field in VehicleModel.

I think this is general problem for typescript and ORMs.
If you know good articles where this problem is described, please give me link.

Discussion (12)

pic
Editor guide
Collapse
nickytonline profile image
Nick Taylor (he/him) • Edited

You could do

type Entity<T> = T & {
    id: string;
    createdAt: Date;
}

// and use it like this:
const vehicle: Entity<VehicleOwnFields> = ...

Leveraging generics is probably the way to go.

Collapse
nuclight profile image
Konstantin Alikhanov Author

Ok, this is good one. Thank you.
How can I optimize other fields?

Collapse
nickytonline profile image
Nick Taylor (he/him)

I'm on mobile at the moment. I'll post some other suggestions in a bit when I'm back at my laptop. On general though, union types and generics really help with these kinds of situations to make types more maintainable.

Thread Thread
nickytonline profile image
Nick Taylor (he/him) • Edited

From there you could even do a Mongoose document generic type.

type Entity<T> = T & {
    id: string;
    createdAt: Date;
}

interface Vehicle {
    model: string;
    year?: string;
    seatsCount?: number;
}

type MongooseDocument<T> = T & mongoose.Document

const dbDocument: MongooseDocument<Entity<Vehicle>> = {
    SomeMongooseDocProperty: 'yolo',
    createdAt: new Date(),
    id: '5FCA1C32-1D92-47CA-A885-A5A747E6E4FB',
    model: 'Mini',
    seatsCount: 4,
    year: '1999'
}

You could refine this more if you wanted to make a Vehicle type which just wraps the Entity<T>.

You can see it in action in the enhanced TypeScript playground.

Also, this might interest you.

Thread Thread
nuclight profile image
Konstantin Alikhanov Author

Ok, I will play with it. Thank you.

Thread Thread
nickytonline profile image
Nick Taylor (he/him)

You can even go a bit further.

interface BaseEntity {
  id: string;
  createdAt: Date;
}

type Entity<T> = T & BaseEntity;

interface Vehicle {
  model: string;
  year?: string;
  seatsCount?: number;
}

// Enforce having the bare minimum entity properties
type MongooseDocument<T extends BaseEntity> = T & mongoose.Document;

const dbDocument: MongooseDocument<Entity<Vehicle>> = {
  SomeMongooseDocProperty: "yolo",
  createdAt: new Date(),
  id: "5FCA1C32-1D92-47CA-A885-A5A747E6E4FB",
  model: "Mini",
  seatsCount: 4,
  year: "1999"
};

See the updated playground example

Collapse
michaeljota profile image
Michael De Abreu

You could always use something like TypeORM. I haven't try it myself, as the last time I needed an ODM I used camo but I think it has been deprecated now.

Collapse
ajsharp profile image
Alex Sharp 🛠sharesecret.co • Edited

I recently found typegoose, which seems promising and seems to attempt to solve this problem. Disclaimer: I haven't used it.

I will say, I'm about a year into a codebase with typescript and mongoose, and I wish I would've just started with TypeORM, and at some point I may switch. Mongoose seems like a project that's either already dead or on life-support.

Collapse
codemouse92 profile image
Jason C. McDonald

How do I replace boilerplate...

Simple: Use Python!

...

Yes, I am absolutely, undeniably kidding. I've heard that joke in my head every time I read your article title, since I posted!

(Honestly, though, I know nothing about TypeScript or mongoose, so I can't make any factual or useful comparisons to Python...and anyway, even if I could, it wouldn't be useful, since you already picked the stack you need!)

Collapse
nuclight profile image
Konstantin Alikhanov Author

I moved to Node.JS from Python :)

Collapse
gochev profile image
Nayden Gochev • Edited

Hey there you can use typegoose also you can completely remove the double Model/Schema generation and even the DTOs are not required..

Example with nestjs + typegoose + mongodb :

github.com/gochev/nest-js-poc-mongodb

Collapse
gochev profile image
Nayden Gochev

also I have added a blog post about that
dev.to/gochev/nestjs-mongo-typegoo...