The main consideration here is that the "keys" you want are actually ObjectId values as defined in your schema and not really a "string", which is actually a requirement for a JavaScript Object since all "keys" must be a "string". For JavaScript this really is not much of an issue since JavScript will "stringify" any argument specified as a "key", but it does matter for BSON, which is what MongoDB actually "speaks"
So you can do this with MongoDB, but you need MongoDB 4.x at least in order to support the $convert aggregation operator or it's shortcut method $toString. This also means that rather than populate(), you actually use MongoDB $lookup:
let results = await Books.aggregate([
{ "$lookup": {
"from": Author.collection.name,
"localField": "authors",
"foreignField": "_id",
"as": "authors"
}},
{ "$addFields": {
"authors": {
"$arrayToObject": {
"$map": {
"input": "$authors",
"in": { "k": { "$toString": "$$this._id" }, "v": "$$this" }
}
}
}
}}
])
Or if you prefer the alternate syntax:
let results = await Books.aggregate([
{ "$lookup": {
"from": "authors",
"let": { "authors": "$authors" },
"pipeline": [
{ "$match": { "$expr": { "$in": [ "$_id", "$$authors" ] } } },
{ "$project": {
"_id": 0,
"k": { "$toString": "$_id" },
"v": "$$ROOT"
}}
],
"as": "authors"
}},
{ "$addFields": {
"authors": { "$arrayToObject": "$authors" }
}}
])
Which would return something like:
{
"_id" : ObjectId("5c7f046a7cefb8bff9304af8"),
"name" : "Book 1",
"authors" : {
"5c7f042e7cefb8bff9304af7" : {
"_id" : ObjectId("5c7f042e7cefb8bff9304af7"),
"name" : "Author 1"
}
}
}
So the $arrayToObject does the actual "Object" output where you supply it an array of objects with k and v properties corresponding to key and value. But it must have a valid "string" in "k" which is why you $map over the array content to reformat it first.
Or as the alternate, you can $project within the pipeline argument of $lookup instead of using $map later for exactly the same thing.
With client side JavaScript, the translation is a similar process:
let results = await Books.aggregate([
{ "$lookup": {
"from": Author.collection.name,
"localField": "authors",
"foreignField": "_id",
"as": "authors"
}},
/*
{ "$addFields": {
"authors": {
"$arrayToObject": {
"$map": {
"input": "$authors",
"in": { "k": { "$toString": "$$this._id" }, "v": "$$this" }
}
}
}
}}
*/
])
results = results.map(({ authors, ...rest }) =>
({
...rest,
"authors": d.authors.reduce((o,e) => ({ ...o, [e._id.valueOf()]: e }),{})
})
)
Or with populate()
let results = await Book.find({}).populate("authors");
results = results.map(({ authors, ...rest }) =>
({
...rest,
"authors": (!authors) ? {} : authors.reduce((o,e) => ({ ...o, [e._id.toString()]: e }),{})
})
)
NOTE however that populate() and $lookup are really quite different. MongoDB $lookup is a single request to the server that returns one response. Using populate() actually invokes multiple queries and does the "joining" in client side JavaScript code even if it hides what it is doing from you.