ITADN

`MergeType` is not distributive over union types — typed `.populate()` breaks discriminator unions

#16101Opennikzanda 创建于 2026-03-16
typescript
N
nikzandacommented
### 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 条评论