Skip to main content

Nested Discriminators

Use-Case

If you don't know an use case for this, consider the following:
A Veterinarian that wants to store medication information about the current patients in their care, how would it be done in mongoose / typegoose?

note

This Guide will use similar examples and guide style to that from Non-Nested-Discriminators.

note

Nested Discriminators may also be called "Embedded Discriminators".

info

This Guide will use the assertion function that typegoose provides.
TL;DR: This function is basically like NodeJS's assert, just more typescript friendly.

First thought

At first you might think to do a basic array, that is of type Mixed:

interface MedicationA {
name: string;
amount: number;
}

interface MedicationB {
name: string;
length: number;
}

class Animal {
@prop({ required: true, unique: true })
public patientNumber!: number;

// Even when not setting the type explicitly, the resulting type would be "Mixed" with the typescript type below
@prop({ type: mongoose.Schema.Types.Mixed })
public medications?: (MedicationA | MedicationB)[];
}

const AnimalModel = getModelForClass(Animal);

And then in some code accessing the properties:

const doc = await AnimalModel.create({
patientNumber: 0,
medications: [
{
name: 'med1',
amount: 10,
} as MedicationA,
{
name: 'med2',
length: 5,
} as MedicationB,
{
unknownType: 1,
},
],
});

assertion(doc.medications[0].name === 'med1');
assertion(doc.medications[1].name === 'med2');
assertion(doc.medications[2].unknownType === 1);
assertion(doc.medications.length === 3);

Which is obviously problematic:

  • No Runtime validation and no Middleware applied to elements of the array (because of type Mixed)
  • Because of no validation, unknown properties like unknownType will persist

Fixing it with Nested Discriminators

The code from First thought is not that far off of what nested discriminators will need to work:

@modelOptions({
schemaOptions: {
// Set the property key which is used to discriminate between the different types
discriminatorKey: 'name',
// Disable automatic "_id" property
_id: false,
},
})
class MedicationBase {
@prop({ required: true })
public name!: string;
}

// A Enum is used to easily keep track of different types, instead of hardcoding it in many places
enum MedicationTypes {
MedicationA = 'MedicationA',
MedicationB = 'MedicationB',
}

class MedicationA extends MedicationBase {
@prop({ required: true })
public amount!: number;
}

class MedicationB extends MedicationBase {
@prop({ required: true })
public length!: number;
}

class Animal {
@prop({ required: true, unique: true })
public patientNumber!: number;

@prop({
required: true,
// Set the Base class, which all types need to extend from
type: MedicationBase,
// Set the nested discriminators that are used for this property
discriminators: () => [
// The "advanced" way of defining types is used here, to make it easier to understand, see section #Extras
{ type: MedicationA, value: MedicationTypes.MedicationA },
{ type: MedicationB, value: MedicationTypes.MedicationB },
],
})
public medications!: MedicationBase[];
}

const AnimalModel = getModelForClass(Animal);

And then in some code accessing the properties again:

const doc = await AnimalModel.create({
patientNumber: 1,
medications: [
{
name: MedicationTypes.MedicationA,
amount: 10,
} as MedicationA,
{
name: MedicationTypes.MedicationB,
length: 5,
} as MedicationB,
],
});

try {
await AnimalModel.create({
patientNumber: 2,
medications: [
{
unknownType: 1,
},
],
});

throw new Error('Expected create to fail');
} catch (err) {
assertion(err instanceof mongoose.Error.ValidationError);
}

assertion(doc.medications[0].name === MedicationTypes.MedicationA);
assertion(doc.medications[1].name === MedicationTypes.MedicationB);
assertion(doc.medications.length === 2);

This Time, it will correctly validate and apply middleware to all elements of the array, meaning it will correctly strip all unknown elements and error if elements are missing (as can be seen in the try-catch).

Extras

Multiple ways to define nested discriminators

There are currently multiple ways to define nested discriminators, which are:

  • Directly and only the Class
  • A DiscriminatorObject (which is used in the examples)
class Animal {
@prop({
type: MedicationBase,
// Define nested discriminators with a "DiscriminatorObject"
// Explicitly set the discriminator value
discriminators: () => [
{ type: MedicationA, value: MedicationTypes.MedicationA },
{ type: MedicationB, value: MedicationTypes.MedicationB },
],
// Define nested discriminators with the "Class" directly
// Implicitly converts the generated model name to the discriminator value
discriminators: () => [
MedicationA,
MedicationB,
],
})
public medications!: MedicationBase[];
}

See @prop option discriminators.

See Also