Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Intersection type error in embedded schema when iterating through an embedded array #14793

Closed
2 tasks done
nikzanda opened this issue Aug 7, 2024 · 3 comments · Fixed by #14800
Closed
2 tasks done
Labels
confirmed-bug We've confirmed this is a bug in Mongoose and will fix it. typescript Types or Types-test related issue / Pull Request

Comments

@nikzanda
Copy link

nikzanda commented Aug 7, 2024

Prerequisites

  • I have written a descriptive issue title
  • I have searched existing issues to ensure the bug has not already been reported

Mongoose version

8.5.2

Node.js version

20.10.0

MongoDB server version

6.0.2

Typescript version (if applicable)

5.5.4

Description

I have a complex Mongoose schema: my model has an embedded field which in turn has an array of embedded fields. This last field contains a virtual field.

interface IPost {
  detail: IDetail;
}

interface IDetail {
  comments: IComment[];
}

interface IComment {
   // ...
}

In the embedded detail, I declare comments in IDetail as IComment[], and in the type DetailDocumentOverrides, I declare comments as CommentInstance[]. CommentInstance is of type: Types.Subdocument<Types.ObjectId> & IComment & ICommentVirtuals.

There is a difference in typing when I access a comment directly or when I iterate over them, for example: post.detail.comments[0] is of type CommentInstance, so I can access the virtual field; however, if I iterate: post.detail.comments.forEach((comment) => { … }), the variable comment is of type IComment, but this is not entirely correct, because comment actually contains all the other properties hydrated by Mongoose.

The actual type generated for the comments field is IComment[] & CommentInstance[], but it’s not a good idea to generate types this way, as suggested by an active member of the TypeScript team (stackoverflow comment, github issue).
Am I wrong in typing the embedded fields? In this case, feel free to correct my code.

In the following screen, TypeScript does not return an error in the first line, while it does inside the forEach (“user” is a virtual field):
image
Screenshot 2024-08-07 at 11 06 31

A workaround is to type the comment variable inside the forEach as CommentInstance.
However, I wanted to understand if this is a problem that can be solved in some way, if I will always have to type manually in these cases, or if I am wrong in typing the embedded fields.

Steps to Reproduce

Reproduction link: stackblitz

Expected Behavior

No response

@IslandRhythms IslandRhythms added confirmed-bug We've confirmed this is a bug in Mongoose and will fix it. typescript Types or Types-test related issue / Pull Request labels Aug 7, 2024
@IslandRhythms
Copy link
Collaborator

import * as mongoose from 'mongoose';



 interface IComment {
    userName: string;
    userSurname: string;
    body: string;
    createdAt: Date;
    updatedAt: Date;
  }
  
   interface ICommentVirtuals {
    user: string;
  }
  
  type CommentModelType = mongoose.Model<IComment, {}, {}, ICommentVirtuals>;
  
   type CommentInstance = mongoose.Types.Subdocument<mongoose.Types.ObjectId> &
    IComment &
    ICommentVirtuals;
  
  const options: mongoose.SchemaOptions<IComment> = {
    _id: false,
    timestamps: true,
  };
  
  export const commentSchema = new mongoose.Schema<IComment, CommentModelType>(
    {
      userName: {
        type: mongoose.SchemaTypes.String,
        required: true,
        trim: true,
      },
      userSurname: {
        type: mongoose.SchemaTypes.String,
        required: true,
        trim: true,
      },
      body: {
        type: mongoose.SchemaTypes.String,
        required: true,
        trim: true,
      },
    },
    options
  );
  
  commentSchema.virtual('user').get(function () {
    const result = `${this.userName} ${this.userSurname}`;
    return result;
  });


 interface IDetail {
    comments: IComment[];
  }
  
  type DetailDocumentOverrides = {
    comments: CommentInstance[];
  };
  
  type DetailModelType = mongoose.Model<IDetail, {}, DetailDocumentOverrides>;
  
   type DetailInstance =  mongoose.Types.Subdocument<mongoose.Types.ObjectId> &
    IDetail &
    DetailDocumentOverrides;
  
   const options2: mongoose.SchemaOptions<IDetail> = {
    _id: false,
  };
  
  const detailSchema = new mongoose.Schema<IDetail, DetailModelType>(
    {
      comments: {
        type: [commentSchema],
        default: [],
      },
    }, options2
   
  );
  


interface IPost {
    _id: mongoose.Types.ObjectId;
    title: string;
    body: string;
    detail: IDetail;
    createdAt: Date;
    updatedAt: Date;
  }
  
  type PostDocumentOverrides = {
    detail: DetailInstance;
  };
  
interface IPostVirtuals {
    id: string;
  }
  
  type PostModelType = mongoose.Model<IPost, {}, PostDocumentOverrides, IPostVirtuals>;
  
type PostInstance = InstanceType<PostModelType>;
  
  const options3: mongoose.SchemaOptions<IPost> = {
    timestamps: true,
    optimisticConcurrency: true,
  };
  
  const postSchema = new mongoose.Schema<IPost, PostModelType>(
    {
      title: {
        type: mongoose.SchemaTypes.String,
        required: true,
        trim: true,
      },
      body: {
        type: mongoose.SchemaTypes.String,
        required: true,
        trim: true,
      },
      detail: {
        type: detailSchema,
        required: true,
      },
    },
    options3
  );

  postSchema.index({ title: 1 });
    
  

const main = async () => {
    const connection = await mongoose.connect('mongodb://localhost:27017')
    const PostModel = mongoose.model<IPost, PostModelType>('Post', postSchema);
  
    await PostModel.create({
      title: 'title',
      body: 'post body',
      detail: {
        comments: [
          {
            userName: 'name',
            userSurname: 'surname',
            body: 'comment body',
          },
        ],
      },
    });
  
    const post = await PostModel.findOne();
    if (post) {
      console.log('post.detail.comments[0].user:', post.detail.comments[0].user);
  
      post.detail.comments.forEach((comment) => {
        console.log(comment.user);
      });
  
      post.detail.comments.forEach((comment: CommentInstance) => {
        console.log(comment.user);
      });
    }
  };
  
  main();

@vkarpov15
Copy link
Collaborator

Here's the complete script that compiles successfully with #14800 . The major changes are:

  1. Use HydratedSingleSubdocument rather than defining subdocument types yourself
  2. Use schema and model THydratedDocumentType generic param, instead of relying on TMethods to override properties that need to be different in hydrated type vs raw type.

There's some more examples of how this works in the TypeScript schema docs

import * as mongoose from 'mongoose';



 interface IComment {
    userName: string;
    userSurname: string;
    body: string;
    createdAt: Date;
    updatedAt: Date;
  }
  
   interface ICommentVirtuals {
    user: string;
  }
  
  type CommentInstance = mongoose.HydratedSingleSubdocument<IComment, ICommentVirtuals>;
  type CommentModelType = mongoose.Model<IComment, {}, {}, ICommentVirtuals, CommentInstance>;
  
  const options: mongoose.SchemaOptions<IComment> = {
    _id: false,
    timestamps: true,
  };
  
  export const commentSchema = new mongoose.Schema<IComment, CommentModelType, {}, {}, {}, {}, mongoose.SchemaOptions<IComment>, IComment, CommentInstance>(
    {
      userName: {
        type: mongoose.SchemaTypes.String,
        required: true,
        trim: true,
      },
      userSurname: {
        type: mongoose.SchemaTypes.String,
        required: true,
        trim: true,
      },
      body: {
        type: mongoose.SchemaTypes.String,
        required: true,
        trim: true,
      },
    },
    options
  );
  
  commentSchema.virtual('user').get(function () {
    const result = `${this.userName} ${this.userSurname}`;
    return result;
  });


 interface IDetail {
    comments: IComment[];
  }
  
  type DetailDocumentOverrides = {
    comments: CommentInstance[];
  };
  

  type DetailInstance = mongoose.HydratedSingleSubdocument<IDetail, DetailDocumentOverrides>;
  type DetailModelType = mongoose.Model<IDetail, {}, DetailDocumentOverrides, DetailInstance>;
   const options2: mongoose.SchemaOptions<IDetail> = {
    _id: false,
  };
  
  const detailSchema = new mongoose.Schema<IDetail, DetailModelType, {}, {}, {}, {}, mongoose.SchemaOptions<IDetail>, IDetail, DetailInstance>(
    {
      comments: {
        type: [commentSchema],
        default: [],
      },
    }, options2
   
  );
  


interface IPost {
    _id: mongoose.Types.ObjectId;
    title: string;
    body: string;
    detail: IDetail;
    createdAt: Date;
    updatedAt: Date;
  }
  
  type PostDocumentOverrides = {
    detail: DetailInstance;
  };
  
interface IPostVirtuals {
    id: string;
  }
  
  type PostInstance = mongoose.HydratedDocument<IPost, PostDocumentOverrides>;
  type PostModelType = mongoose.Model<IPost, {}, {}, IPostVirtuals, PostInstance>;
  
  const options3: mongoose.SchemaOptions<IPost> = {
    timestamps: true,
    optimisticConcurrency: true,
  };
  
  const postSchema = new mongoose.Schema<IPost, PostModelType, {}, {}, IPostVirtuals, {}, mongoose.SchemaOptions<IPost>, IPost, PostInstance>(
    {
      title: {
        type: mongoose.SchemaTypes.String,
        required: true,
        trim: true,
      },
      body: {
        type: mongoose.SchemaTypes.String,
        required: true,
        trim: true,
      },
      detail: {
        type: detailSchema,
        required: true,
      },
    },
    options3
  );

  postSchema.index({ title: 1 });
    
  

const main = async () => {
    const connection = await mongoose.connect('mongodb://localhost:27017')
    const PostModel = mongoose.model<IPost, PostModelType>('Post', postSchema);
  
    await PostModel.create({
      title: 'title',
      body: 'post body',
      detail: {
        comments: [
          {
            userName: 'name',
            userSurname: 'surname',
            body: 'comment body',
          },
        ],
      },
    });
  
    const post = await PostModel.findOne().orFail();
    if (post) {
      console.log('post.detail.comments[0].user:', post.detail.comments[0].user);
 
      post.detail.comments.forEach((comment) => {
        console.log(comment.user);
      });
  
      post.detail.comments.forEach((comment: CommentInstance) => {
        console.log(comment.user);
      });
    }
  };
  
  main();

@nikzanda
Copy link
Author

Okay, as soon as the PR is merged, I will try to type it this way, thanks!

vkarpov15 added a commit that referenced this issue Aug 13, 2024
types: make HydratedSingleSubdocument and HydratedArraySubdocument merge types instead of using &
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
confirmed-bug We've confirmed this is a bug in Mongoose and will fix it. typescript Types or Types-test related issue / Pull Request
Projects
None yet
3 participants