Non-Nested Discriminators
Use-Case
If you don't know an use case for this, consider the following:
A Veterinarian that wants to store information about the current patients in their care, how would it be done in mongoose / typegoose?
First thought
At first you might think to do the following:
// to have an shared collection
@modelOptions({ schemaOptions: { collection: "animal" } })
class Animal {
@prop({ required: true, unique: true })
public patientNumber!: number;
}
class Dog extends Animal {
@prop()
public cageNumber!: number;
}
class Cat extends Animal {
@prop()
public nameTag!: string;
}
class Parrot extends Animal {
@prop()
public commonMessage?: string;
}
const AnimalModel = getModelForClass(Animal);
const DogModel = getModelForClass(Dog);
const CatModel = getModelForClass(Cat);
const ParrotModel = getModelForClass(Parrot);
And then in some querying code:
await CatModel.create({ patientNumber: 0, nameTag: "Catty-1" });
await DogModel.create({ patientNumber: 1, cageNumber: 1 });
// for this example its a "findOne" to lower the example code
const found = await ParrotModel.findOne({}).exec();
// this will "find" should log one of the 2 created above
console.log("found", found);
Which is obviously not what is wanted, there would be ways to test for what document is what, but there is an easier way: Discriminators.
Fixing it with Discriminators
The code from First thought is actually not so far off of what discriminators will need:
- Difference
- Full Code
const AnimalModel = getModelForClass(Animal);
- const DogModel = getModelForClass(Dog);
- const CatModel = getModelForClass(Cat);
- const ParrotModel = getModelForClass(Parrot);
+ const DogModel = getDiscriminatorModelForClass(AnimalModel, Dog);
+ const CatModel = getDiscriminatorModelForClass(AnimalModel, Cat);
+ const ParrotModel = getDiscriminatorModelForClass(AnimalModel, Parrot);
@modelOptions({ schemaOptions: { collection: "animal" } })
class Animal {
@prop({ required: true, unique: true })
public patientNumber!: number;
}
class Dog extends Animal {
@prop()
public cageNumber!: number;
}
class Cat extends Animal {
@prop()
public nameTag!: string;
}
class Parrot extends Animal {
@prop()
public commonMessage?: string;
}
const AnimalModel = getModelForClass(Animal);
// difference is below here
const DogModel = getDiscriminatorModelForClass(AnimalModel, Dog);
const CatModel = getDiscriminatorModelForClass(AnimalModel, Cat);
const ParrotModel = getDiscriminatorModelForClass(AnimalModel, Parrot);
And then the same querying code:
await CatModel.create({ patientNumber: 0, nameTag: "Catty-1" });
await DogModel.create({ patientNumber: 1, cageNumber: 1 });
// for this example its an "findOne" to lower the example code
const found = await ParrotModel.findOne({}).exec();
console.log("found", found);
and this time it will log null
, because there is no Parrot
document inside the collection.
You might ask "how does this work?", well, it is easy: mongoose will by default use the hidden property __t
to differentiate between registered models from the shared parent, and the default value for the __t
property is the model name. (Look here for more on how typegoose generates an model name)
The property __t
can be changed to something different, see Extras.
Query with Shared Parent Model
When using discriminators, it is also possible to use the shared parent to query for documents:
- Difference
- Full Code
await CatModel.create({ patientNumber: 0, nameTag: "Catty-1" });
await DogModel.create({ patientNumber: 1, cageNumber: 1 });
// for this example its an "findOne" to lower the example code
- const found = await ParrotModel.findOne({}).exec();
+ const found = await AnimalModel.findOne({}).exec();
console.log("found", found);
await CatModel.create({ patientNumber: 0, nameTag: "Catty-1" });
await DogModel.create({ patientNumber: 1, cageNumber: 1 });
// for this example its an "findOne" to lower the example code
const found = await AnimalModel.findOne({}).exec();
console.log("found", found);
This should find one of the 2 created documents, with full properties at runtime, but at compile time (in the editor), it is still shown as Animal
.
This can be solved by using custom type guards:
Classes & Models:
- Difference
- Full Code
+ enum Names {
+ DOG = "DOG",
+ CAT = "CAT",
+ PARROT = "PARROT",
+ }
@modelOptions({ schemaOptions: { collection: "animal" } })
class Animal {
@prop({ required: true, unique: true })
public patientNumber!: number;
}
class Dog extends Animal {
@prop()
public cageNumber!: number;
}
class Cat extends Animal {
@prop()
public nameTag!: string;
}
class Parrot extends Animal {
@prop()
public commonMessage?: string;
}
const AnimalModel = getModelForClass(Animal);
- const DogModel = getDiscriminatorModelForClass(AnimalModel, Dog);
- const CatModel = getDiscriminatorModelForClass(AnimalModel, Cat);
- const ParrotModel = getDiscriminatorModelForClass(AnimalModel, Parrot);
+ const DogModel = getDiscriminatorModelForClass(AnimalModel, Dog, Names.DOG);
+ const CatModel = getDiscriminatorModelForClass(AnimalModel, Cat, Names.CAT);
+ const ParrotModel = getDiscriminatorModelForClass(AnimalModel, Parrot, Names.PARROT);
// an enum to make it easier to access the names for the typeguard
enum Names {
DOG = "DOG",
CAT = "CAT",
PARROT = "PARROT",
}
@modelOptions({ schemaOptions: { collection: "animal" } })
class Animal {
@prop({ required: true, unique: true })
public patientNumber!: number;
}
class Dog extends Animal {
@prop()
public cageNumber!: number;
}
class Cat extends Animal {
@prop()
public nameTag!: string;
}
class Parrot extends Animal {
@prop()
public commonMessage?: string;
}
const AnimalModel = getModelForClass(Animal);
const DogModel = getDiscriminatorModelForClass(AnimalModel, Dog, Names.DOG);
const CatModel = getDiscriminatorModelForClass(AnimalModel, Cat, Names.CAT);
const ParrotModel = getDiscriminatorModelForClass(AnimalModel, Parrot, Names.PARROT);
Query Code:
- Difference
- Full Code
+ function checkForClass<T extends Animal>(doc: mongoose.Document & KeyStringAny, name: string): doc is DocumentType<T> {
+ return doc?.__t === name;
+ }
await CatModel.create({ patientNumber: 0, nameTag: "Catty-1" });
await DogModel.create({ patientNumber: 1, cageNumber: 1 });
// for this example its an "findOne" to lower the example code
- const found = await AnimalModel.findOne({}).exec();
+ const found = await AnimalModel.findOne({ patientNumber: 0 }).orFail().exec();
+ if (checkForClass<Cat>(found, Names.CAT)) {
+ console.log("runtime Cat", found.nameTag);
+ }
console.log("found", found);
function checkForClass<T extends Animal>(doc: mongoose.Document & KeyStringAny, name: string): doc is DocumentType<T> {
return doc?.__t === name;
}
await CatModel.create({ patientNumber: 0, nameTag: "Catty-1" });
await DogModel.create({ patientNumber: 1, cageNumber: 1 });
// for this example its an "findOne" to lower the example code
const found = await AnimalModel.findOne({ patientNumber: 0 }).orFail().exec();
if (checkForClass<Cat>(found, Names.CAT)) {
console.log("runtime Cat", found.nameTag);
}
console.log("found", found);
this code should now log runtime Cat Catty-1
and the full document and types should also work inside the if-block.
Extras
The value of the discriminatorKey
(default: __t
) can be changed, by defining the property on the class (/ schema) and pointing discriminatorKey
to that property.
Example:
- Difference
- Full Code
enum Names {
DOG = "DOG",
CAT = "CAT",
PARROT = "PARROT",
}
- @modelOptions({ schemaOptions: { collection: "animal" } })
+ @modelOptions({ schemaOptions: { collection: "animal", discriminatorKey: "customKey" } })
class Animal {
@prop({ required: true, unique: true })
public patientNumber!: number;
+ @prop({ required: true })
+ public customKey!: string; // its recommended to only use "string" or "number"
}
class Dog extends Animal {
@prop()
public cageNumber!: number;
}
class Cat extends Animal {
@prop()
public nameTag!: string;
}
class Parrot extends Animal {
@prop()
public commonMessage?: string;
}
const AnimalModel = getModelForClass(Animal);
const DogModel = getDiscriminatorModelForClass(AnimalModel, Dog, Names.DOG);
const CatModel = getDiscriminatorModelForClass(AnimalModel, Cat, Names.CAT);
const ParrotModel = getDiscriminatorModelForClass(AnimalModel, Parrot, Names.PARROT);
// an enum to make it easier to access the names for the typeguard
enum Names {
DOG = "DOG",
CAT = "CAT",
PARROT = "PARROT",
}
@modelOptions({ schemaOptions: { collection: "animal", discriminatorKey: "customKey" } })
class Animal {
@prop({ required: true, unique: true })
public patientNumber!: number;
// options "enum" & "default" can also be specified, but don't have much effect
// the property set in "discriminatorKey" does not actually need to be defined, but its for types like usage in an typeguard
@prop({ required: true })
public customKey!: string; // its recommended to only use "string" or "number"
}
class Dog extends Animal {
@prop()
public cageNumber!: number;
}
class Cat extends Animal {
@prop()
public nameTag!: string;
}
class Parrot extends Animal {
@prop()
public commonMessage?: string;
}
const AnimalModel = getModelForClass(Animal);
const DogModel = getDiscriminatorModelForClass(AnimalModel, Dog, Names.DOG);
const CatModel = getDiscriminatorModelForClass(AnimalModel, Cat, Names.CAT);
const ParrotModel = getDiscriminatorModelForClass(AnimalModel, Parrot, Names.PARROT);
And so instead of the model name (example: Cat
), it will be stored as customCat
inside property customKey
.
Extra Notes
strictQuery
In mongoose 6.x, the option strictQuery
is true
by default, basically meaning that it will strip all properties from a query that are not on the schema the query is executed on.
See mongoose 6.0 Migration guide.
Example:
// The following will result in a empty array
await AnimalModel.find({ cageNumber: 10 });
// use the following if it is required to be used this way
await AnimalModel.find({ cageNumber: 10 }, null, { strictQuery: false })