Mongoose discriminators provide a practical way to model related document types that share identity, lifecycle fields, and queries while still having subtype-specific properties. This guide explains how discriminator-based model inheritance works, when to prefer it over separate collections or a shared schema, and how to use a reusable checklist before adding a subtype, migrating existing data, or debugging polymorphic queries.
Overview
A discriminator is a Mongoose model built from a base model and a child schema. Documents from the base and child models are stored in the same MongoDB collection, with a discriminator key identifying the concrete type. By default, Mongoose uses the __t path for this key, although you can choose a clearer name such as kind or eventType.
For example, an application may have several notification types. All notifications need a recipient, creation timestamp, and delivery state, while email and SMS notifications need different fields:
const notificationSchema = new mongoose.Schema({
recipientId: mongoose.Schema.Types.ObjectId,
status: { type: String, default: 'pending' }
}, { discriminatorKey: 'kind', timestamps: true });
const Notification = mongoose.model('Notification', notificationSchema);
const emailSchema = new mongoose.Schema({
subject: String,
templateId: String
});
const smsSchema = new mongoose.Schema({
phoneNumber: String,
message: String
});
const EmailNotification = Notification.discriminator('Email', emailSchema);
const SmsNotification = Notification.discriminator('Sms', smsSchema);Both child models use the same collection. Creating an EmailNotification adds kind: "Email", while creating an SmsNotification adds kind: "Sms". Queries through the base model can retrieve all notification types; queries through a child model automatically restrict results to that discriminator value.
| Approach | Best fit | Main trade-off |
|---|---|---|
| Mongoose discriminators | Related types share most fields and are queried together | One collection must support the hierarchy and its indexes |
| Separate collections | Types have independent lifecycles, permissions, or scale characteristics | Cross-type queries and shared operations require extra application logic |
| One shared schema | Differences are small, optional, or unlikely to grow | Validation and application code can become conditional and less explicit |
| Embedded discriminators | Polymorphic items belong inside a parent document | Document size, update patterns, and array complexity need careful review |
Use discriminators when the inheritance relationship is real in the domain, not merely because several schemas happen to contain similar fields. A shared base schema should represent stable common behavior. If every subtype has different retention rules, access controls, indexes, or operational ownership, separate collections may be easier to maintain.
Checklist by scenario
Adding a new subtype
- Confirm the common contract. Put identity, timestamps, status, and other genuinely shared fields in the base schema. Keep subtype-only fields in the child schema.
- Choose a stable discriminator key. Use a key that is readable in database inspection and unlikely to be renamed casually. Treat its values as persisted data, not temporary labels.
- Register the child model once. Define
BaseModel.discriminator('Name', childSchema)during model initialization. Avoid creating duplicate models during hot reloads or importing the same model through inconsistent paths. - Review indexes. A child-specific query may need an index that includes the discriminator key and its filter fields. Check the generated query shape rather than assuming the base indexes are sufficient.
- Test both model paths. Create and read documents through the child model, then query through the base model and verify that the returned data and validation behavior match expectations.
Querying a polymorphic collection
Use the base model when the operation applies to every subtype:
const pending = await Notification.find({ status: 'pending' });Use a child model when the operation is specific to one subtype:
const emails = await EmailNotification.find({
status: 'pending',
templateId: 'receipt'
});When building a query dynamically, keep the type selection explicit and validate it against an allowlist. Do not pass an arbitrary discriminator model name from an untrusted request directly into model lookup code.
Migrating an existing shared collection
- Inventory existing documents and identify a reliable rule for each subtype.
- Add the discriminator key without changing unrelated fields.
- Backfill in bounded batches so the migration can be monitored and resumed.
- Run validation or reporting queries to find documents that match no subtype or more than one subtype rule.
- Deploy the child models and application reads after the data is classified.
- Only then enforce subtype-specific requirements in writes.
For a migration with dependent updates or multiple collections, use a documented migration script and decide whether a transaction is appropriate for the workload. The Mongoose Data Migration Guide covers versioned scripts, safe backfills, and rollback planning.
Choosing separate collections instead
Pause before using discriminators if subtypes rarely share queries, have incompatible indexes, require different retention policies, or are expected to evolve independently. Separate collections can reduce conditional schema logic and make ownership clearer. The cost is duplicated base fields, separate model registration, and more work for queries that span all types.
What to double-check
- Discriminator key behavior: Confirm the configured key name and the exact stored values. Existing documents without the key will not automatically become a child type.
- Schema path conflicts: A child schema should not redefine a base path with an incompatible type, required rule, or meaning. Resolve the design conflict instead of relying on whichever schema happens to be registered last.
- Updates: Test
save(),updateOne(), andfindOneAndUpdate()separately. An update executed through a child model should preserve the intended discriminator scope. Changing a document's type is a domain migration, not an ordinary field edit, and should be handled deliberately. - Validation: Verify required child fields on creates and updates. Query-based updates do not necessarily validate every field unless the appropriate validation options are enabled.
- Indexes: Inspect indexes after introducing a subtype. A useful index for one child may be wasteful for another, and a compound index may need the discriminator key depending on query patterns.
- Serialization and APIs: Decide whether clients receive the discriminator key, a mapped public type, or neither. Keep internal model names separate from external API contracts when those names may change.
- Testing: Include fixtures for every subtype, an unknown or missing type, base-model queries, child-model queries, population, projections, and aggregation pipelines. See the Testing Mongoose Models guide for a broader test structure.
Common mistakes
Using discriminators for unrelated documents
Putting unrelated entities into one collection because they share a few fields usually produces weak validation and confusing queries. The base schema should express meaningful shared behavior, not just superficial field overlap.
Assuming the base model validates every subtype
The base model knows the common schema. Child-specific rules belong to the child model, so write paths should use the appropriate child model when subtype validation matters.
Changing the discriminator value casually
The discriminator key controls model selection. Renaming or changing its values can make old documents invisible to expected child queries. Treat such changes as a migration with compatibility checks.
Forgetting aggregation behavior
Model-based reads automatically apply discriminator conditions in the situations Mongoose supports, but aggregation pipelines deserve explicit inspection. If a pipeline starts from the base model and must include only one subtype, add and test the type filter intentionally.
Ignoring model registration order
Applications that load models conditionally can encounter missing models, duplicate registrations, or inconsistent discriminator definitions. Centralize initialization and make tests use the same registration path as production.
For surrounding schema choices, compare discriminator fields with the validation, defaults, virtuals, and timestamps discussed in the Mongoose Schema Design Guide. For write semantics, review the comparison of Mongoose update methods.
When to revisit
Revisit the discriminator design before adding each new subtype, especially when the new type introduces different indexing, retention, authorization, or operational requirements. Also review it during seasonal planning cycles, major schema refactors, database migrations, and changes to the workflows that create or query these documents.
Use this short pre-release checklist:
- Can the new type be described by the existing base contract?
- Is its discriminator value stable and covered by tests?
- Do base and child queries return the intended documents?
- Are validation, update, aggregation, and population paths tested?
- Have indexes and migration performance been checked against realistic query patterns?
- Is there a rollback or compatibility plan if existing documents must be classified?
If several answers are no, stop and reconsider the model boundary. A discriminator is most valuable when it makes the domain easier to query and validate. When it instead creates many exceptions, conditional fields, and specialized operational rules, separate collections or a simpler shared schema may be the more maintainable choice.