How to properly use Mongoose models in Next.js?
Asked Answered
S

5

7

I'm building a Next.js application with TypeScript and MongoDB/Mongoose. I started running into an error when using Mongoose models, which was causing them to attempt an overwrite of the Model every time it was used.

Code causing Model Overwrite error:

import mongoose from 'mongoose';

const { Schema } = mongoose;

const categorySchema = new Schema({
  name: {type: String, required: true},
  color: {type: String, required: true}
})

export default mongoose.model('Category', categorySchema, 'categories')

I found that on many projects using Next.js and Mongoose, including the example project by Next.js, they used the following syntax on the export to fix this problem:

export default mongoose.models.Category || mongoose.model('Category', categorySchema, 'categories')

This feels pretty weird and "bandaid solution"-esque, but it seems to do the trick at first glance; it prevents an overwrite if the model already exists. However, using TypeScript I started running into another problem, which after some time I found was being caused by that very line. Since the export was finnicky, TypeScript couldn't parse the Category model, and threw errors whenever I tried to use most of its properties or methods. I looked deeper into it and found some other people going around this by doing:

import mongoose from 'mongoose';

const { Schema } = mongoose;

const categorySchema = new Schema({
  name: {type: String, required: true},
  color: {type: String, required: true}
})

interface CategoryFields {
  name: string,
  color: string
}

type CategoryDocument = mongoose.Document & CategoryFields

export default (mongoose.models.Category as mongoose.Model<CategoryDocument>) || mongoose.model('Category', categorySchema, 'categories')

Again, this seems to do the trick but it's merely tricking TypeScript into believing there's nothing weird going on, when in reality there is.

Is there no real solution to fix the Model Overwrite problem without jumping through hoops and covering errors with other errors?

Thanks in advance!

Snapshot answered 25/1, 2021 at 15:7 Comment(0)
C
3

The key issue of this problem is that things inside mongoose.models are all typed as Model<any> by default.

So that type of

mongoose.models.Customer || mongoose.model("Customer", CustomerSchema)

will be inferred as Model<any> since this is a more broaden type.

The goal is to type mongoose.models.Customer correctly. And the type should be inferred instead of redefined.

Regarding your example, a solution can be like this:

const CustomerModel = mongoose.model('Customer', CustomerSchema)
// type `mongoose.models.Customer` same as `CustomerModel`
export const Customer = (mongoose.models.Customer as typeof CustomerModel) || CustomerModel; 

This does not require adding extra interface since it reused the model type you already defined.

Cavanagh answered 18/1, 2023 at 2:44 Comment(5)
All right, that seems to work perfectly! Only thing is, I thought the mongoose.models.Customr || ... part was meant to avoid re-registering the model on file change. Doesn't the line const CustomerModel = ... cause OverwriteModelErrors?Balls
I fixed it by creating a generic type from the Schema, based on the mongoose type definitions. You can see the code hereBalls
It won't re-register because mongoose.models.Customer || ... means "get me registered model first if there is one, otherwise register one", it already ensures CustomerModel not to be called twice. And since const CustomerModel=... is not exported, nowhere else can invoke it. So it shouldn't cause OverriteModelErrors.Cavanagh
Yes, creating a generic type same as mongoose type works. However, the issue is that there is no gaurantee that unexported mongoose type will not change in the future. You will have to keep an eye on the lib's change and recreating this generic type once the lib author modifies that type. That's why I prefer to infer type from const CustomerModel rather than creating one.Cavanagh
@RubenHelsloot Your generic type based on the Schema works for me as well. It's a pretty good solution since I don't want to create the extra interface. But do you know how to typesafe a query when using the .populate() method? With this generic type, it gets typed to the ObjectId when in reality it's the entire model(?)Copperas
S
1
export interface CategoryDoc extends Document {
    name:string,
    color:string
}

export interface CategoryModel extends Model<CategoryDoc> {}

then export it like this

// you might remove the 'categories'
export default mongoose.models.Category || mongoose.model<CategoryDoc,CategoryModel>('Category', categorySchema, 'categories')
Skiver answered 15/1, 2023 at 14:43 Comment(3)
To be clear, I'd want to avoid having to write a separate interface and schema, because it means keeping the fields in two different places. Also, this would mean that more complex properties, like static methods, are lostBalls
@RubenHelsloot Can you pls create a reproducible code base or share your repo?Skiver
sure! github.com/rubenhelsloot/so-mongoose-type-exampleBalls
C
1

I know this question is a bit old, but here's a TypeScript helper to solve it. Hope it helps someone in the future

export const Category = modelHelper('Category', categorySchema, 'categories')
// modelHelper.ts

import {
  CompileModelOptions,
  InferSchemaType,
  Model,
  model,
  models,
  Schema,
} from "mongoose"

export function modelHelper<TSchema extends Schema = any>(
  name: string,
  schema?: TSchema,
  collection?: string,
  options?: CompileModelOptions
) {
  return (
    (models[name] as Model<InferSchemaType<typeof schema>>) ||
    model(name, schema, collection, options)
  )
}
Croft answered 6/8 at 5:34 Comment(0)
R
0

for typesefyty just do

export type UserDocument = InferSchemaType<typeof userSchema>;

export const User = models['user'] as Model<UserDocument> || model('user', userSchema);
Ranita answered 12/12, 2023 at 22:6 Comment(0)
G
0

Another approach you could use is casting the mongoose.models.Category as never in a function so it is ignored. You then keep full type inference from your schema without having to redefine an interface elsewhere.

Using your example:

const categorySchema = new mongoose.Schema({
  name: { type: String, required: true },
  color: { type: String, required: true },
});

function createModel() {
  if (mongoose.models.Category) {
    return mongoose.models.Category as never;
  }
  return mongoose.model("category", categorySchema);
}

const Category = createModel();
export default Category;

// accessing retrieved properties is then inferred properly
const category = await Category.findOne();
category?.name;
Gory answered 13/6 at 1:34 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.