typescriptmongoosetypesmongoose-schema

How do I type a mongoose populated field in Typescript


I have a field that could be an id or populated and I am trying to type this field with TypeScript

import { InferSchemaType, Schema, Types, model } from 'mongoose';

const personSchema = new Schema({
  _id: Schema.Types.ObjectId,
  name: String,
  age: Number,
});

const storySchema = new Schema({
  author: { type: Schema.Types.ObjectId, ref: 'Person' },
  title: String,
});

export type PersonDao = { _id: Types.ObjectId } & InferSchemaType<typeof personSchema>;
export type StoryDao = { _id: Types.ObjectId; author: Types.ObjectId | PersonDao } & Omit<InferSchemaType<typeof storySchema>, 'author'>;

export const Person = model('Person', personSchema);
export const Story = model('Story', storySchema);

Story.findOne({ _id: 'fakeId' })
  .populate<StoryDao>('author')
  .exec()
  .then((story: StoryDao) => {
    console.log(story.author.name); // error
  });

In the console.log name attribute I have this error

Property 'name' does not exist on type 'ObjectId | PersonDao'.
  Property 'name' does not exist on type 'ObjectId'.ts(2339)

If I define the type like this

export type StoryDao = { _id: Types.ObjectId; author: PersonDao } & Omit<InferSchemaType<typeof storySchema>, 'author'>;

export const story: StoryDao = {
  _id: new Types.ObjectId(),
  author: new Types.ObjectId(), // error
  title: 'fakeTitle',
};

I don't have the error on name anymore but I have it on author when I create a StoryDao object

type 'ObjectId' is not assignable to type 'PersonDao'.
  Type 'ObjectId' is missing the following properties from type '{ createdAt: NativeDate; updatedAt: NativeDate; }': createdAt, updatedAtts(2322)

It's seem's it is a ts limitation on unions because I have the same issue here

type Test = number | { name: string }
const test: Test = {} as Test;
console.log('DEBUG: ', test.name); // error

How do you type this kind of field ?


Solution

  • TypeScript is not able to infer the type of the subdocument at runtime, so since author is defined as either ObjectId | PersonDao, it's assuming that it may be an ObjectId even after being populated, which does not have a name property. It's technically not wrong here, in the event that the population fails to produce a complete Author record. Union types (TypeA | TypeB) can be especially difficult to make TS cooperate with, since it's not aware of the logic that generally determines when a type should be one thing, as opposed to another.

    Either you can just use a type assertion to tell TS that this story is populated, e.g.:

    const author = story.author as PersonDao;
    console.log(author.name);
    

    Or if you want to avoid using a kludge like so you can add a typeguard function to check before accessing any questionable properties, a rough example would look like this:

    const isPopulatedAuthor = (x: any): x is PersonDao => {
        return x.name !== undefined
    }
    
    if (isPopulatedAuthor(story.author)) {
        console.log(story.author.name)
    }