Skip to main content

Discriminator Schema Pattern

Overview

The Discriminator Schema Pattern is how we implement polymorphic document types in MongoDB using Mongoose discriminators with NestJS. A single collection stores documents of multiple "shapes" (variants), distinguished by a discriminator key field. Each variant adds its own fields on top of a shared base schema.

This pattern has two critical layers:

  • Mongoose layerSchemaFactory.createForClass() + discriminator registration handles runtime schema merging
  • TypeScript layer — variant classes must declare ALL fields (base + variant-specific) for type completeness

Architecture

Base Schema

The base schema class defines:

  • The discriminatorKey in the @Schema() decorator
  • All shared fields with @Prop() decorators
  • The Document type alias (intersected with the domain entity type)
@Schema({
timestamps: true,
collection: Collections.MY_COLLECTION,
discriminatorKey: 'type_key',
strict: 'throw',
})
export class MyBase {
@Prop({ type: String, required: true, enum: Object.values(MyTypeEnum) })
type_key!: MyTypeEnum;

@Prop({ type: Types.ObjectId, required: true, index: true, ref: 'Organization' })
organization_id!: Types.ObjectId;

@Prop({ type: SharedConfigSchema, required: true })
shared_config!: SharedConfigSchema;
}

export const MyBaseSchema = SchemaFactory.createForClass(MyBase);

export type MyBaseDocument = HydratedDocument<MyBase & MyBaseDomain>;

Variant Schema

The variant schema class defines:

  1. Narrowed discriminator key — TS-only, no @Prop() (Mongoose already has this from the base)
  2. Base fields — TS-only, no @Prop() (Mongoose merges these at runtime from the base schema)
  3. Variant-specific fields — with @Prop() (these are the new paths added by the discriminator)
@Schema()
export class MyVariant {
type_key: MyTypeEnum.VARIANT_A = MyTypeEnum.VARIANT_A;

// Base schema props
organization_id!: Types.ObjectId;
shared_config!: SharedConfigSchema;

@Prop({ type: VariantConfigSchema, required: true })
variant_config!: VariantConfigSchema;
}

export const MyVariantSchema = SchemaFactory.createForClass(MyVariant);

Module Registration

Discriminators are registered in the module's MongooseModule.forFeature():

MongooseModule.forFeature([
{
name: MyBase.name,
schema: MyBaseSchema,
discriminators: [
{ name: MyTypeEnum.VARIANT_A, schema: MyVariantSchema },
{ name: MyTypeEnum.VARIANT_B, schema: MyOtherVariantSchema },
],
},
]),

Rules

1. Variant classes MUST declare all base fields

Every variant schema class must include TypeScript declarations for ALL fields from the base schema — without @Prop() decorators. This ensures the variant class produces a complete TypeScript type.

Why: SchemaFactory.createForClass() only processes @Prop() decorators. Undecorated fields are invisible to Mongoose but visible to TypeScript. Mongoose handles the runtime merge of base + variant schemas. The TypeScript declarations ensure the variant's type reflects the full document shape.

2. No class inheritance (extends) for variant schemas

Variant classes must NOT extend the base schema class. SchemaFactory.createForClass() walks the prototype chain and would collect @Prop() decorators from the base, generating a discriminator schema with duplicate base paths.

3. Use implements with domain types when types match cleanly

When the domain variant type doesn't extend BaseEntity / BaseEntityWithOrganization (i.e., no string vs Types.ObjectId mismatch), use implements DomainVariantType for compile-time enforcement:

// ✅ Domain type has no BaseEntity — types match cleanly
@Schema({ _id: false })
export class MyNodeVariant implements MyNodeVariantDomain {
type!: typeof MyNodeType.VARIANT;
_id!: string;

@Prop({ ... })
variant_field!: string;
}

When the domain type extends BaseEntity / BaseEntityWithOrganization, implements won't work due to organization_id: string vs Types.ObjectId mismatch. In this case, declare base fields manually:

// ✅ Domain type extends BaseEntityWithOrganization — manual declarations
@Schema()
export class MyDocVariant {
type_key: MyTypeEnum.VARIANT = MyTypeEnum.VARIANT;

// Base schema props
organization_id!: Types.ObjectId;
shared_config!: SharedConfigSchema;

@Prop({ ... })
variant_config!: VariantConfigSchema;
}

4. Reuse shared types from @wrkbelt/shared/types-data

  • Enums — always import from shared types (e.g., BookingFlowStepType, BookingSessionEventMomentType)
  • Value types — reuse config types when schema and domain types match (e.g., BookingFlowStepConfig, CustomerFormData)
  • ObjectId fields — use Types.ObjectId from Mongoose (persistence layer concern)

5. Document type lives on the base

The full document type is always defined on the base schema, intersected with the domain entity type:

export type MyBaseDocument = HydratedDocument<MyBase & MyBaseDomain>;

This intersection gives the document type both:

  • Schema class fields (with Mongoose-specific types like Types.ObjectId)
  • Domain entity fields (the full discriminated union)

Variant classes do NOT need their own document types (except in rare cases where a variant-specific hook needs typed access).

Naming Conventions

ComponentConventionExample
Base schema class{EntityName}BookingFlowStep
Variant schema class{VariantName}{EntityName} or {VariantName}AdditionalDetailsBookingFlowStep, ErrorEvent
Base schema constant{EntityName}SchemaBookingFlowStepSchema
Variant schema constant{VariantName}SchemaAdditionalDetailsBookingFlowStepSchema
Document type{EntityName}DocumentBookingFlowStepDocument
Nested base schema class{EntityName}BaseSchemaServiceSelectionNodeBaseSchema

Patterns in the Codebase

Top-level document discriminators

BaseDiscriminator KeyVariants
BookingFlowStepstep_typeAdditionalDetailsBookingFlowStep, CustomerBookingFlowStep, ServiceSelectionBookingFlowStep, SummaryBookingFlowStep, TimeslotSelectionBookingFlowStep, ZipcodeBookingFlowStep
BookingSessionEventevent_momentStepNavigationEvent, ServiceSelectionEvent, CustomerDataEvent, SessionStateEvent, ErrorEvent
BookingQuestionAnswertypePlainTextAnswer, ServiceReferenceAnswer

Nested subdocument discriminators

BaseDiscriminator KeyVariants
ServiceSelectionNodeBaseSchematypeServiceSelectionQuestionNodeSchema, ServiceSelectionServiceNodeSchema, ServiceSelectionPlainAnswerNodeSchema
BookingCustomerDetailsBaseSchematypeBookingCustomerNew, BookingCustomerExisting

Anti-patterns

❌ Variant missing base fields

// BAD — TypeScript type is incomplete
@Schema()
export class MyVariant {
type_key: MyTypeEnum.VARIANT = MyTypeEnum.VARIANT;

@Prop({ ... })
variant_config!: VariantConfigSchema;
}
// typeof MyVariant = { type_key, variant_config } — missing organization_id, shared_config

❌ Variant extending base class

// BAD — SchemaFactory picks up base @Prop decorators, creating duplicate paths
@Schema()
export class MyVariant extends MyBase {
override type_key: MyTypeEnum.VARIANT = MyTypeEnum.VARIANT;

@Prop({ ... })
variant_config!: VariantConfigSchema;
}

❌ Variant re-declaring base fields with @Prop

// BAD — Mongoose discriminator registration may conflict with duplicate @Prop paths
@Schema()
export class MyVariant {
type_key: MyTypeEnum.VARIANT = MyTypeEnum.VARIANT;

@Prop({ type: Types.ObjectId, required: true }) // ← duplicate of base @Prop
organization_id!: Types.ObjectId;

@Prop({ ... })
variant_config!: VariantConfigSchema;
}