`MergeType` is not distributive over union types — typed `.populate()` breaks discriminator unions
typescript
### Prerequisites
- [x] I have written a descriptive issue title
- [x] I have searched existing issues to ensure the bug has not already been reported
### Mongoose version
9.2.1
### Node.js version
24.11.0
### MongoDB server version
8.x
### Typescript version (if applicable)
5.9.3
### Description
When a document type is a **discriminated union** (as is the case with discriminator models), using the typed `.populate<{ field: PopulatedType }>()` produces a type that is **incompatible** with the original document type. The discriminated union collapses into a single flat type, losing all discriminator-specific properties.
The root cause is in `mongoose/types/utility.d.ts`:
```typescript
type MergeType<A, B> = Omit<A, keyof B> & B;
```
TypeScript's `Omit` is **not distributive** over union types. Given `Omit<A | B | C, K>`, TypeScript does not produce `Omit<A, K> | Omit<B, K> | Omit<C, K>`. Instead, it resolves to a `Pick` based only on the **common keys** of `A`, `B`, and `C` (minus `K`), which destroys the union.
The same issue affects `UnpackedIntersection` (line 98 of the same file), whose last branch also uses a non-distributive `Omit`.
### Steps to Reproduce
```typescript
import mongoose, { Schema, model, HydratedDocument, Types, PopulatedDoc, Document } from 'mongoose';
// --- Base schema ---
interface IAnimal {
name: string;
owner: PopulatedDoc<Document<Types.ObjectId> & IOwner>;
}
interface IOwner {
fullName: string;
}
// --- Discriminator interfaces ---
interface IDog extends IAnimal {
breed: string;
}
interface ICat extends IAnimal {
indoor: boolean;
}
// --- Models ---
const animalSchema = new Schema<IAnimal>({ name: String, owner: { type: Schema.Types.ObjectId, ref: 'Owner' } });
const Animal = model<IAnimal>('Animal', animalSchema);
const Dog = Animal.discriminator<IDog>('Dog', new Schema<IDog>({ breed: String }));
const Cat = Animal.discriminator<ICat>('Cat', new Schema<ICat>({ indoor: Boolean }));
const Owner = model<IOwner>('Owner', new Schema<IOwner>({ fullName: String }));
// --- Union type (typical pattern for discriminators) ---
type DogInstance = HydratedDocument<IDog>;
type CatInstance = HydratedDocument<ICat>;
type AnimalInstance = DogInstance | CatInstance;
// --- The bug ---
async function example() {
const animal: AnimalInstance = {} as AnimalInstance;
// ✅ Without typed populate — AnimalInstance is preserved
const a1 = await Animal.findById('test').orFail();
// ❌ With typed populate — union is destroyed
const a2 = await Animal.findById('test')
.populate<{ owner: HydratedDocument<IOwner> }>('owner')
.orFail();
// a2 is now: HydratedDocument<Omit<...flattened..., 'owner'> & { owner: HydratedDocument<IOwner> }>
// The type no longer has 'breed' (from IDog) or 'indoor' (from ICat).
// It only has the keys common to ALL union members (i.e., 'name' and 'owner').
// This assignment fails:
const test: AnimalInstance = a2; // ❌ Type error
}
```
### Expected Behavior
The typed `.populate<{ owner: ... }>()` should preserve the discriminated union. The resulting type should still be assignable to `AnimalInstance` (i.e., `DogInstance | CatInstance`), with each union member individually having its `owner` field replaced by the populated type.
3 条评论