32
votes

Background

I'm using mongoose and TypeScript in my Node.JS app. I'm using mongoose's populate in a bunch of places when fetching data from the database.

The issue I'm facing is that I don't know how to type my models so that a property can be either an ObjectId or populated with data from another collection.

What I've tried

I've attempted using union types in my model type definition, which seems like something that TypeScript offers to cover these kind of things:

interface User extends Document {
    _id: Types.ObjectId;
    name: string
}

interface Item extends Document {
    _id: Types.ObjectId;

    // Union typing here
    user: Types.ObjectId | User;
}

My schema only defines the property as an ObjectId with ref.

const ItemSchema = new Schema({
    user: { type: Schema.Types.ObjectId, ref: "User", index: true }
})

Example:

So I might do something like this:

ItemModel.findById(id).populate("user").then((item: Item) => {
    console.log(item.user.name);
})

Which produces the compilation error:

[ts] Property 'name' does not exist on type 'User | ObjectId'.
     Property 'name' does not exist on type 'ObjectId'.

Question

How can I have a model property that can be either of two types in TypeScript?

4

4 Answers

14
votes

You need to use a type guard to narrow the type from Types.ObjectId | User to User...

If you are dealing with a User class, you can use this:

if (item.user instanceof User) {
    console.log(item.user.name);
} else {
    // Otherwise, it is a Types.ObjectId
}

If you have a structure that matches a User, but not an instance of a class (for example if User is an interface), you'll need a custom type guard:

function isUser(obj: User | any) : obj is User {
    return (obj && obj.name && typeof obj.name === 'string');
}

Which you can use with:

if (isUser(item.user)) {
    console.log(item.user.name);
} else {
    // Otherwise, it is a Types.ObjectId
}

If you don't want to check structures for this purpose, you could use a discriminated union.

4
votes

Cast the item.user to User, when the user is populated.

ItemModel.findById(id).populate("user").then((item: Item) => {
    console.log((<User>item.user).name);
})
3
votes

You can use PopulatedDoc type from the @types/mongoose lib. See mongoose.doc.

2
votes

Mongoose's TypeScript bindings export a PopulatedDoc type that helps you define populated documents in your TypeScript definitions:

import { Schema, model, Document, PopulatedDoc } from 'mongoose';

// `child` is either an ObjectId or a populated document
interface Parent {
  child?: PopulatedDoc<Child & Document>,
  name?: string
}
const ParentModel = model<Parent>('Parent', new Schema({
  child: { type: 'ObjectId', ref: 'Child' },
  name: String
}));

interface Child {
  name?: string;
}
const childSchema: Schema = new Schema({ name: String });
const ChildModel = model<Child>('Child', childSchema);

ParentModel.findOne({}).populate('child').orFail().then((doc: Parent) => {
  // Works
  doc.child.name.trim();
})

Below is a simplified implementation of the PopulatedDoc type. It takes 2 generic parameters: the populated document type PopulatedType, and the unpopulated type RawId. RawId defaults to an ObjectId.

type PopulatedDoc<PopulatedType, RawId = Types.ObjectId> = PopulatedType | RawId;

You as the developer are responsible for enforcing strong typing between populated and non-populated docs. Below is an example.

ParentModel.findOne({}).populate('child').orFail().then((doc: Parent) => {
  // `doc` doesn't have type information that `child` is populated
  useChildDoc(doc.child);
});

// You can use a function signature to make type checking more strict.
function useChildDoc(child: Child): void {
  console.log(child.name.trim());
}

This was lifted from the docs. You can check it here