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 layer — SchemaFactory.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;
}