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

Populate type with TypeScript not working #10758

Closed
GameelSadek opened this issue Sep 21, 2021 · 18 comments
Closed

Populate type with TypeScript not working #10758

GameelSadek opened this issue Sep 21, 2021 · 18 comments
Labels
can't reproduce Mongoose devs have been unable to reproduce this issue. Close after 14 days of inactivity.

Comments

@GameelSadek
Copy link

Do you want to request a feature or report a bug?
bug

What is the current behavior?
property in interface with type PopulatedDoc<interface & Document> has type any
If the current behavior is a bug, please provide the steps to reproduce.

import mongoose, { Schema, model, Document, PopulatedDoc } from 'mongoose';
async function run() {
    mongoose.connect('mongodb://localhost:27017/test');
    // `child` is either an ObjectId or a populated document
    interface Parent {
        child?: PopulatedDoc<Child & Document>,
        name?: string
    }
    const ParentModel = model<Parent>('Parent', new Schema<Parent>({
        child: { type: 'ObjectId', ref: 'Child' },
        name: String
    }));

    interface Child {
        name?: string;
    }
    const childSchema: Schema = new Schema<Child>({ name: String });
    const ChildModel = model<Child>('Child', childSchema);

    const parent = await ParentModel.findOne({}).populate('child')
    parent!.child.name;
}
{
  "compilerOptions": {
    "target": "ES6",                      
    "module": "commonjs",                     
    "strict": true,                          
    "esModuleInterop": true,                 
    "forceConsistentCasingInFileNames": true 
  }
}

What is the expected behavior?
it should support the type of given interface
What are the versions of Node.js, Mongoose and MongoDB you are using? Note that "latest" is not a version.
Node.js: 14.17.4 , Mongoose: 6.0.7 , MongoDB: 4.4.3

@AbdelrahmanHafez
Copy link
Collaborator

AbdelrahmanHafez commented Sep 21, 2021

Welcome @GameelSadek
I can confirm, the example in the docs compiles without errors because in the line before-last doc.child has type of any, not Child interface.

@thiagokisaki
Copy link
Contributor

I was facing the same problem some time ago. The only way I found to workaround this:

import mongoose, { Schema, model, Document, PopulatedDoc, Types } from 'mongoose';
async function run() {
    mongoose.connect('mongodb://localhost:27017/test');
    // `child` is either an ObjectId or a populated document
    interface Parent {
        child?: PopulatedDoc<Child>,
        name?: string
    }
    const ParentModel = model<Parent>('Parent', new Schema<Parent>({
        child: { type: Schema.Types.ObjectId, ref: 'Child' }, // Change from 'ObjectId' to `Schema.Types.ObjectId`
        name: String
    }));

    interface Child extends Document { // Make `Child` extends `Document`
        _id: Types.ObjectId, // Explicitly define `_id` type
        name?: string;
    }
    const childSchema: Schema = new Schema<Child>({ name: String });
    const ChildModel = model<Child>('Child', childSchema);

    const parent = await ParentModel.findOne({}).populate('child')
    parent!.child.name;
}

Although Mongoose does not recommend extending Document.

@thiagokisaki
Copy link
Contributor

thiagokisaki commented Sep 21, 2021

Note that even with the PopulatedDoc helper we end up making type assertions. So in the last two lines of the example we would do something like:

const parent = await ParentModel.findOne({}).populate('child').orFail();
(parent.child as Child | null)?.name;

In order to access the name property of child without errors.

PS: I asserted parent.child as Child | null because, according to mongoose docs, when there's no document to populate, parent.child will be null.

@thiagokisaki
Copy link
Contributor

Just found another workaround without extending Document:

import mongoose, { Schema, model, Document, PopulatedDoc, Types } from 'mongoose';
async function run() {
    mongoose.connect('mongodb://localhost:27017/test');
    // `child` is either an ObjectId or a populated document
    interface Parent {
        child?: PopulatedDoc<Child & Document<Types.ObjectId>>, // Pass `_id` type to `Document` generic parameter
        name?: string
    }
    const ParentModel = model<Parent>('Parent', new Schema<Parent>({
        child: { type: Schema.Types.ObjectId, ref: 'Child' }, // Change from 'ObjectId' to `Schema.Types.ObjectId`
        name: String
    }));

    interface Child {
        name?: string;
    }
    const childSchema: Schema = new Schema<Child>({ name: String });
    const ChildModel = model<Child>('Child', childSchema);

    const parent = await ParentModel.findOne({}).populate('child')
    parent!.child.name;
}

@ahmedelshenawy25
Copy link
Contributor

@thiagokisaki using your example above, there's still no name property on child.
image

@IslandRhythms IslandRhythms added the typescript Types or Types-test related issue / Pull Request label Sep 22, 2021
@IslandRhythms
Copy link
Collaborator

property child does not exist on type void

@thiagokisaki
Copy link
Contributor

mongoose/index.d.ts

Lines 1687 to 1690 in bf4f107

export type PopulatedDoc<
PopulatedType,
RawId extends RefType = (PopulatedType extends { _id?: RefType; } ? NonNullable<PopulatedType['_id']> : mongoose.Types.ObjectId) | undefined
> = PopulatedType | RawId;

PopulatedDoc is just a helper to create an union type of PopulatedType and its _id property type.
Unfortunately, TypeScript doesn't know that child is populated after calling populate('child'), so it only allows you to access properties that Child & Document<Types.ObjectId> and Types.ObjectId have in common. To access the name property we need to use type assertions as I mentioned earlier: #10758 (comment)

@IslandRhythms
Copy link
Collaborator

I was leaving a note for valeri karpov when he reads the thread

@thiagokisaki
Copy link
Contributor

Sorry, actually my previous comment was a reply to @ahmedelshenawy25 comment.

@vkarpov15 vkarpov15 added this to the 6.0.11 milestone Sep 25, 2021
@vkarpov15
Copy link
Collaborator

@thiagokisaki we made some improvements to make this easier by passing a generic param to populate() as shown below.

import { Schema, model, Document, Types } from 'mongoose';

// `Parent` represents the object as it is stored in MongoDB
interface Parent {
  child?: Types.ObjectId,
  name?: string
}
interface Child {
  name: string;
}
// `PopulatedParent` represents the possible populated paths
interface PopulatedParent {
  child: Child | null;
}
const ParentModel = model<Parent>('Parent', new Schema({
  child: { type: 'ObjectId', ref: 'Child' },
  name: String
}));
const childSchema: Schema = new Schema({ name: String });
const ChildModel = model<Child>('Child', childSchema);

// Populate with `Paths` generic `{ child: Child }` to override `child` path
ParentModel.findOne({}).populate<Pick<PopulatedParent, 'child'>>('child').orFail().then(doc => {
  // Works
  const t: string = doc.child.name;
});

@judgegodwins
Copy link

Ok @vkarpov15 It seems that the generic populate method returns an intersection type. Example: Types.ObjecId & Child

@vkarpov15
Copy link
Collaborator

@judgegodwins can you please elaborate?

@sheerlox
Copy link

sheerlox commented Dec 2, 2021

@vkarpov15 I'm running in the same issue as @judgegodwins regarding the populate method returning an intersection type:

ParentModel.findOne({})
  .populate<{ child: Child }>('child')
  .orFail()
  .then((doc) => {
    const t: string = doc.child.name
  })

The type of doc.child in the callback is Types.ObjectId & Child, while we expect Child | null. (iirc the null type should be automatically added since Mongoose might return it if the document was not found).

Even when using .populate<{ child: Child | null }>('child'), doc.child stays Types.ObjectId & Child.

Typescript 4.2.4 / Mongoose 6.0.13

@vkarpov15 vkarpov15 reopened this Dec 5, 2021
@vkarpov15 vkarpov15 modified the milestones: 6.0.11, 6.0.17 Dec 5, 2021
@vkarpov15 vkarpov15 modified the milestones: 6.1.4, 6.1.3 Dec 15, 2021
@vkarpov15
Copy link
Collaborator

@SherloxTV can you please provide a more complete repro script? The below script compiles correctly with TypeScript 4.2.4:

import { Schema, model, Document, PopulatedDoc, Types } from 'mongoose'; 
async function run() {
    // `child` is either an ObjectId or a populated document
    interface Parent {
        child?: Types.ObjectId,
        name?: string
    }
    const ParentModel = model<Parent>('Parent', new Schema<Parent>({
        child: { type: 'ObjectId', ref: 'Child' },
        name: String
    }));

    interface Child {
        name?: string;
    }
    const childSchema: Schema = new Schema<Child>({ name: String });
    const ChildModel = model<Child>('Child', childSchema);

    ParentModel.findOne({}).populate<{ child: Child }>('child').orFail().then(doc => {
      const t: string = doc.child.name;
    });
}   

@vkarpov15 vkarpov15 removed this from the 6.1.3 milestone Dec 18, 2021
@vkarpov15 vkarpov15 added can't reproduce Mongoose devs have been unable to reproduce this issue. Close after 14 days of inactivity. and removed typescript Types or Types-test related issue / Pull Request labels Dec 18, 2021
@sheerlox
Copy link

Hi, this isn't about any compilation issues, it's just the actual behavior of the populate regarding types doesn't seem to be expected behavior:

The type of doc.child in the callback is Types.ObjectId & Child, while we expect Child | null. (iirc the null type should be automatically added since Mongoose might return it if the document was not found).

Even when using .populate<{ child: Child | null }>('child'), doc.child stays Types.ObjectId & Child.

Am I misunderstanding how this should work or is there something not making sense to you as well ?

@vkarpov15
Copy link
Collaborator

I'm confused by what you mean by "the actual behavior of the populate regarding types"?

@dantenol
Copy link

dantenol commented Feb 3, 2022

@vkarpov15 's code seems to work but i'm having issues with find() not findOne(). It seems that mongoose doesn't consider the Path inside the array. Check below.

Screenshot from 2022-02-03 09-27-08
As the example, works fine

Screenshot from 2022-02-03 09-26-34
Now it doesn't consider IUser type for userId prop. This happened because it returns an array

Screenshot from 2022-02-03 09-29-30
Oddly this doesn't throw an error even though I'm trying to access a non-existing prop on the array

@vkarpov15
Copy link
Collaborator

@dantenol please open a new issue and follow the issue template

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
can't reproduce Mongoose devs have been unable to reproduce this issue. Close after 14 days of inactivity.
Projects
None yet
Development

No branches or pull requests

9 participants