diff --git a/test/types/populate.test.ts b/test/types/populate.test.ts index cc34233254..6014fcceed 100644 --- a/test/types/populate.test.ts +++ b/test/types/populate.test.ts @@ -420,6 +420,136 @@ function gh14441() { const docObject = docs[0]!.toObject(); ExpectType(docObject.child.name); }); + + interface ArrayParent { + children?: Types.ObjectId[]; + title?: string; + } + const ArrayParentModel = model( + 'ArrayParent', + new Schema({ + title: String, + children: [{ type: Schema.Types.ObjectId, ref: 'Child' }] + }) + ); + + type PopulatedChildren = { + children: mongoose.Types.DocumentArray>; + }; + + ArrayParentModel.findOne({}) + .orFail() + .then(async doc => { + const populatedDoc = await doc.populate('children'); + + // Populating changes the path type from ObjectIds to documents, so the result should + // not be assignable back to the model's original hydrated type. + // @ts-expect-error Type 'PopulateDocumentResult & ArrayParent...' + const hydratedDoc: mongoose.HydratedDocFromModel = populatedDoc; + ExpectAssignable>()(populatedDoc); + ExpectAssignable>()(populatedDoc); + ExpectType(populatedDoc.children[0]?.name); + const plainObject = populatedDoc.toObject(); + ExpectType(plainObject.children[0].name); + const depopulatedObject = populatedDoc.toObject({ depopulate: true }); + ExpectType(depopulatedObject.children![0]); + + const objectWithVirtuals = populatedDoc.toObject({ virtuals: true }); + ExpectType(objectWithVirtuals.children![0].name); + + const objectWithFlattenObjectIds = populatedDoc.toObject({ flattenObjectIds: true }); + ExpectType(objectWithFlattenObjectIds.children![0].name); + + const depopulatedAndFlattened = populatedDoc.toObject({ depopulate: true, flattenObjectIds: true }); + ExpectType(depopulatedAndFlattened.children![0]); + + const jsonObject = populatedDoc.toJSON(); + ExpectType(jsonObject.children![0].name); + const jsonObjectWithVirtuals = populatedDoc.toJSON({ virtuals: true }); + ExpectType(jsonObjectWithVirtuals.children![0].name); + const jsonDepopulated = populatedDoc.toJSON({ depopulate: true }); + ExpectType(jsonDepopulated.children![0]); + + // Known limitation: structural wrappers that drop the marker lose the populated toObject() behavior. + const strippedMarkerDoc: Omit> = + populatedDoc; + const strippedMarkerObject = strippedMarkerDoc.toObject(); + // @ts-expect-error Property 'name' does not exist on type 'ObjectId' + ExpectType(strippedMarkerObject.children![0].name); + + // Known limitation: generic helpers that erase the marker only see the base toObject() typing. + function toObjectWithBaseTyping>(input: T) { + return input.toObject(); + } + const genericObject = toObjectWithBaseTyping(populatedDoc); + // Generic helper removed the populated behavior so we only get the raw ObjectId back. + ExpectType(genericObject.children![0]); + }); + + ArrayParentModel.findOne({}) + .populate('children') + .orFail() + .then(populatedDoc => { + ExpectAssignable>()(populatedDoc); + ExpectAssignable>()(populatedDoc); + ExpectType(populatedDoc.children[0]?.name); + const plainObject = populatedDoc.toObject(); + ExpectType(plainObject.children[0].name); + const depopulatedObject = populatedDoc.toObject({ depopulate: true }); + ExpectType(depopulatedObject.children![0]); + }); + + ArrayParentModel.findOne({}) + .orFail() + .then(async doc => { + const populatedDoc = await ArrayParentModel.populate(doc, 'children'); + + // @ts-expect-error Type 'PopulateDocumentResult & ArrayParent...' + const hydratedDoc: mongoose.HydratedDocFromModel = populatedDoc; + ExpectAssignable>()(populatedDoc); + ExpectAssignable>()(populatedDoc); + ExpectType(populatedDoc.children[0]?.name); + const plainObject = populatedDoc.toObject(); + ExpectType(plainObject.children[0].name); + const depopulatedObject = populatedDoc.toObject({ depopulate: true }); + ExpectType(depopulatedObject.children![0]); + }); + + interface MultiPopulateParent { + firstChild?: Types.ObjectId; + secondChild?: Types.ObjectId; + } + + const MultiPopulateParentModel = model( + 'MultiPopulateParent', + new Schema({ + firstChild: { type: Schema.Types.ObjectId, ref: 'Child' }, + secondChild: { type: Schema.Types.ObjectId, ref: 'Child' } + }) + ); + + type PopulatedFirstChild = { + firstChild: mongoose.HydratedDocFromModel; + }; + + type PopulatedSecondChild = { + secondChild: mongoose.HydratedDocFromModel; + }; + + MultiPopulateParentModel.findOne({}) + .populate('firstChild') + .populate('secondChild') + .orFail() + .then(populatedDoc => { + ExpectType(populatedDoc.firstChild?.name); + ExpectType(populatedDoc.secondChild?.name); + const plainObject = populatedDoc.toObject(); + ExpectType(plainObject.firstChild!.name); + ExpectType(plainObject.secondChild!.name); + const depopulatedObject = populatedDoc.toObject({ depopulate: true }); + ExpectType(depopulatedObject.firstChild!); + ExpectType(depopulatedObject.secondChild!); + }); } async function gh14574() { diff --git a/types/document.d.ts b/types/document.d.ts index bfe8c8357d..77af4e3366 100644 --- a/types/document.d.ts +++ b/types/document.d.ts @@ -32,7 +32,7 @@ declare module 'mongoose' { _id: T; /** Assert that a given path or paths is populated. Throws an error if not populated. */ - $assertPopulated(path: string | string[], values?: Partial): Omit & Paths; + $assertPopulated(path: string | string[], values?: Partial): PopulateDocumentResult, DocType>; /** Clear the document's modified paths. */ $clearModifiedPaths(): this; @@ -238,8 +238,8 @@ declare module 'mongoose' { $parent(): Document | undefined; /** Populates document references. */ - populate(path: string | PopulateOptions | (string | PopulateOptions)[]): Promise>; - populate(path: string, select?: string | AnyObject, model?: Model, match?: AnyObject, options?: PopulateOptions): Promise>; + populate(path: string | PopulateOptions | (string | PopulateOptions)[]): Promise, DocType>>; + populate(path: string, select?: string | AnyObject, model?: Model, match?: AnyObject, options?: PopulateOptions): Promise, DocType>>; /** Gets _id(s) used during population of the given `path`. If the path was not populated, returns `undefined`. */ populated(path: string): any; @@ -262,11 +262,41 @@ declare module 'mongoose' { toBSON(): Require_id; /** The return value of this method is used in calls to JSON.stringify(doc). */ + toJSON( + this: PopulatedDocumentMarker, + options: { depopulate: true } + ): Default__v, TSchemaOptions>; + toJSON( + this: PopulatedDocumentMarker, + options: O + ): ToObjectReturnType; + toJSON( + this: PopulatedDocumentMarker, + options: O + ): ToObjectReturnType; + toJSON( + this: PopulatedDocumentMarker + ): Default__v, TSchemaOptions>; toJSON(options: O): ToObjectReturnType; toJSON(options?: ToObjectOptions): Default__v, TSchemaOptions>; toJSON(options?: ToObjectOptions): Default__v, ResolveSchemaOptions>; /** Converts this document into a plain-old JavaScript object ([POJO](https://masteringjs.io/tutorials/fundamentals/pojo)). */ + toObject( + this: PopulatedDocumentMarker, + options: { depopulate: true } + ): Default__v, TSchemaOptions>; + toObject( + this: PopulatedDocumentMarker, + options: O + ): ToObjectReturnType; + toObject( + this: PopulatedDocumentMarker, + options: O + ): ToObjectReturnType; + toObject( + this: PopulatedDocumentMarker + ): Default__v, TSchemaOptions>; toObject(options: O): ToObjectReturnType; toObject(options?: ToObjectOptions): Default__v, TSchemaOptions>; toObject(options?: ToObjectOptions): Default__v, ResolveSchemaOptions>; diff --git a/types/index.d.ts b/types/index.d.ts index 4b30322868..99f69fcc58 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -1088,6 +1088,9 @@ declare module 'mongoose' { // Handle DocumentArray - recurse into items : T extends Types.DocumentArray ? Types.DocumentArray> + // Handle plain arrays - recurse into items + : T extends Array + ? ApplyFlattenTransforms[] // Handle Subdocument - recurse into subdoc type : T extends Types.Subdocument ? HydratedSingleSubdocument> diff --git a/types/models.d.ts b/types/models.d.ts index 9de93b8d2c..ff99e26301 100644 --- a/types/models.d.ts +++ b/types/models.d.ts @@ -628,10 +628,10 @@ declare module 'mongoose' { populate( docs: Array, options: PopulateOptions | Array | string - ): Promise>>; + ): Promise, TRawDocType>>>; populate( doc: any, options: PopulateOptions | Array | string - ): Promise>; + ): Promise, TRawDocType>>; /** * Update an existing [Atlas search index](https://www.mongodb.com/docs/atlas/atlas-search/create-index/). diff --git a/types/populate.d.ts b/types/populate.d.ts index dac2a24821..aa96d0b328 100644 --- a/types/populate.d.ts +++ b/types/populate.d.ts @@ -8,6 +8,50 @@ declare module 'mongoose' { RawId extends RefType = (PopulatedType extends { _id?: RefType; } ? NonNullable : Types.ObjectId) | undefined > = PopulatedType | RawId; + const mongoosePopulatedDocumentMarker: unique symbol; + + type ExtractDocumentObjectType = T extends infer ObjectType & Document ? FlatRecord : T; + + type PopulatePathToRawDocType = + T extends Types.DocumentArray + ? PopulatePathToRawDocType[] + : T extends Array + ? PopulatePathToRawDocType[] + : T extends Document + ? SubdocsToPOJOs> + : T extends Record + ? { [K in keyof T]: PopulatePathToRawDocType } + : T; + + type PopulatedPathsDocumentType = UnpackedIntersection>; + + type PopulatedDocumentMarker< + PopulatedRawDocType, + DepopulatedRawDocType, + > = { + [mongoosePopulatedDocumentMarker]?: { + populated: PopulatedRawDocType, + depopulated: DepopulatedRawDocType + } + }; + + type ResolvePopulatedRawDocType< + ThisType, + FallbackRawDocType, + O = never + > = ThisType extends PopulatedDocumentMarker + ? O extends { depopulate: true } + ? DepopulatedRawDocType + : PopulatedRawDocType + : FallbackRawDocType; + + type PopulateDocumentResult< + Doc, + Paths, + PopulatedRawDocType, + DepopulatedRawDocType = PopulatedRawDocType + > = MergeType & PopulatedDocumentMarker; + interface PopulateOptions { /** space delimited path(s) to populate */ path: string; diff --git a/types/query.d.ts b/types/query.d.ts index d4874350d5..3bcd6cc858 100644 --- a/types/query.d.ts +++ b/types/query.d.ts @@ -201,10 +201,10 @@ declare module 'mongoose' { ? ResultType : ResultType extends (infer U)[] ? U extends Document - ? HydratedDocument, TDocOverrides, TQueryHelpers>[] + ? PopulateDocumentResult, RawDocType>[] : (MergeType)[] : ResultType extends Document - ? HydratedDocument, TDocOverrides, TQueryHelpers> + ? PopulateDocumentResult, RawDocType> : MergeType : MergeType;