Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Correctly typing single Nested Paths and Subdocuments #11061

Closed
janos-kasa opened this issue Dec 8, 2021 · 5 comments
Closed

Correctly typing single Nested Paths and Subdocuments #11061

janos-kasa opened this issue Dec 8, 2021 · 5 comments
Labels
docs This issue is due to a mistake or omission in the mongoosejs.com documentation typescript Types or Types-test related issue / Pull Request
Milestone

Comments

@janos-kasa
Copy link

janos-kasa commented Dec 8, 2021

Do you want to request a feature or report a bug?

Feature (?)

What is the current behavior?

I'm currently unsure on how to type single nested paths or subdocuments correctly with typescript and it would be awesome to get some clarification around this.

First things first: I am trying to avoid using extends Document because of the reasons mentioned here.

My other goal is to export type definitions of the plain JS version of the document (i.e. what you would get after a .toObject() call), and the type of the document instance too, since they can be both useful for our applications (like as parameters to a function).

I've also read the instructions on how to correctly type arrays of subdocuments, but haven't found any on single subdocuments.

So I have created three gists to showcase how I approached the problem, and what worked and what not.

Version 1 (using a standard interface):

When having a single nested subdocument by only supplying the User interface the subdocument itself won't get all the methods that are actually available on the subdocument instance. If you try that code with JS it will correctly work, but TS is throwing errors.

Version 2 (using HydratedDocument):

The same thing happens if it's typed with the HydratedDocument type but the subdocument is still just a standard interface.

Version 3 (using HydratedDocument on the subdocument also):

This works for me, and it's also a good thing that this way I can also export the "raw" interfaces that don't have any mongoose specific fields on them, but not sure this is the correct solution as I've never seen this documented anywhere. I'm also not sure whether the subdocument will now have any methods on it that it shouldn't (the .db field being available looks a bit weird for example).

Could you please help us figure out how to type these correctly? Also if there is a difference between typing a "nested path" and a "subdocument" please let us know!

Thanks!

tsconfig.json

{
  "compilerOptions": {
    "target": "es2016",
    "module": "commonjs",
    "sourceMap": true,
    "outDir": "./dist",

    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,

    "strict": true,
    "skipLibCheck": true
  }
}

What are the versions of Node.js, Mongoose and MongoDB you are using? Note that "latest" is not a version.

Mongoose version: v6.0.14
Node version v14.17.0
Mongo version: v4.4.10

@janos-kasa janos-kasa changed the title Correctly typing single nested paths/subdocuments Correctly typing single Nested Paths and Subdocuments Dec 8, 2021
@janos-kasa
Copy link
Author

janos-kasa commented Dec 9, 2021

Did some more investigation.

There is also the possibility to use Types.Subdocument which seems to be more correct as it does have methods like .parent() (and also an _id field) on it, but only if you are actually creating a subdocument with new Schema.

But there are still a couple of issues here.

Setup:

import mongoose, { HydratedDocument, Types, Schema } from 'mongoose';

interface Names {
  firstName: string;
}

interface User {
  names: Names;
}

type UserDocument = HydratedDocument<
  User & {
    names: Types.Subdocument;
  }
>;

const userSchema = new Schema<User>({
  names: new Schema<Names>({ firstName: String }) // You have to use new Schema and not a nested path
});

const UserModel = mongoose.model<UserDocument>('User', userSchema);

Issue 1:

TS allows to set the firstName field on the names object, even though names in this case is undefined which will result in runtime errors.

const user = new UserModel({});

user.names === undefined; // true, as detailed here https://mongoosejs.com/docs/subdocs.html#subdocuments-versus-nested-paths

user.names.firstName = 'John'; // TypeError: Cannot set properties of undefined

Issue 2:

Assignment on the subdocument is correctly handled in JS but ​TS is throwing errors.

const user = new UserModel({});

user.names = { firstName: 'John' };  // Type '{ firstName: string; }' is missing the following properties from type 'Subdocument': $isSingleNested, ownerDocument, parent, $parent, and 49 more

console.log(user.names.constructor.name); // correctly SingleNested and not just a simple object with { firstName: 'John' }

Issue 3:

The nested object is of class SingleNested but that class is not exported by the type definitions. Not sure if this is a deliberate decision but if you'd want to type things up by using the same classes as the debugger gives you, this may be a bit confusing.

const user = new UserModel({ names: {} }); // <- now names is defined as empty object

user.names.constructor.name; // SingleNested

Issue 4:

The _id field on the subdocument will always be any.

When the _id field is set on the interface (which would be the logical typing as the _id field will still be present after a .toObject() call):

interface Names {
  _id: Types.ObjectId;
  firstName: string;
}

// ...rest of setup

const user = new UserModel({ names: {} });

user.names._id; // (property) _id: any

When the _id field is not set on the interface:

interface Names {
  firstName: string;
}

// ...rest of setup

const user = new UserModel({ names: {} });

user.names._id; // (property) Document<any, any, any>._id?: any

Issue 5:

Although using HydratedDocument on the root interface helps with being able to type the document instance itself (and with the above pattern it's also able to correctly type the subdocument), the issue is that it can't be used to configure the model, because it will have issues with the queries:

// When using UserDocument that was created with `HydratedDocument`

const UserModel = mongoose.model<UserDocument>('User', userSchema);

const user = await UserModel.find({ baseModelName: 'stuff' }) // <- TS thinks this is a valid query but it's not

image


I'm still unsure whether I'm going in the right direction or not, and I still don't have a great way to type nested paths.

@vkarpov15 vkarpov15 added this to the 6.0.18 milestone Dec 9, 2021
@vkarpov15 vkarpov15 added docs This issue is due to a mistake or omission in the mongoosejs.com documentation typescript Types or Types-test related issue / Pull Request labels Dec 9, 2021
@vkarpov15 vkarpov15 modified the milestones: 6.1.4, 6.1.6 Dec 15, 2021
@barca-reddit
Copy link

barca-reddit commented Dec 30, 2021

Hey @janos-kasa did you manage to find a solution? I have actually tried copying your example from gist number 3, but typescript is giving me a whole bunch of errors, I'm using the latest version of mongoose (6.1.4) at the time of writing this.

Argument of type 'Schema<User, Model<User, any, any, any>, any>' is not assignable to parameter of type 'Schema<Document<any, any, User & { names: Document<any, any, Names> & Names & { _id: ObjectId; }; }> & User & { names: Document<...> & ... 1 more ... & { ...; }; } & { ...; }, any, any> | Schema<...>'.
  Type 'Schema<User, Model<User, any, any, any>, any>' is not assignable to type 'Schema<Document<any, any, User & { names: Document<any, any, Names> & Names & { _id: ObjectId; }; }> & User & { names: Document<...> & Names & { ...; }; } & { ...; }, any, any>'.
    Types of property 'add' are incompatible.
      Type '(obj: { names?: SchemaDefinitionProperty<Names>; } | Schema<any, Model<any, any, any, any>, any>, prefix?: string) => Schema<User, Model<...>, any>' is not assignable to type '(obj: Schema<any, Model<any, any, any, any>, any> | { names?: SchemaDefinitionProperty<Names & Document<any, any, Names> & { _id: ObjectId; }>; _id?: SchemaDefinitionProperty<...>; __v?: SchemaDefinitionProperty<...>; id?: SchemaDefinitionProperty<...>; }, prefix?: string) => Schema<...>'.
        Types of parameters 'obj' and 'obj' are incompatible.
          Type 'Schema<any, Model<any, any, any, any>, any> | { names?: SchemaDefinitionProperty<Names & Document<any, any, Names> & { _id: ObjectId; }>; _id?: SchemaDefinitionProperty<...>; __v?: SchemaDefinitionProperty<...>; id?: SchemaDefinitionProperty<...>; }' is not assignable to type '{ names?: SchemaDefinitionProperty<Names>; } | Schema<any, Model<any, any, any, any>, any>'.
            Type '{ names?: SchemaDefinitionProperty<Names & Document<any, any, Names> & { _id: ObjectId; }>; _id?: SchemaDefinitionProperty<any>; __v?: SchemaDefinitionProperty<...>; id?: SchemaDefinitionProperty<...>; }' is not assignable to type '{ names?: SchemaDefinitionProperty<Names>; } | Schema<any, Model<any, any, any, any>, any>'.
              Type '{ names?: SchemaDefinitionProperty<Names & Document<any, any, Names> & { _id: ObjectId; }>; _id?: SchemaDefinitionProperty<any>; __v?: SchemaDefinitionProperty<...>; id?: SchemaDefinitionProperty<...>; }' is not assignable to type '{ names?: SchemaDefinitionProperty<Names>; }'.
                Types of property 'names' are incompatible.
                  Type 'SchemaDefinitionProperty<Names & Document<any, any, Names> & { _id: ObjectId; }>' is not assignable to type 'SchemaDefinitionProperty<Names>'.
                    Type 'SchemaTypeOptions<Names & Document<any, any, Names> & { _id: ObjectId; }>' is not assignable to type 'SchemaDefinitionProperty<Names>'.
                      Type 'SchemaTypeOptions<Names & Document<any, any, Names> & { _id: ObjectId; }>' is not assignable to type 'SchemaTypeOptions<Names>'.
                        Types of property 'validate' are incompatible.
                          Type 'Function | RegExp | [RegExp, string] | [Function, string] | ValidateOpts<Names & Document<any, any, Names> & { _id: ObjectId; }> | ValidateOpts<...>[]' is not assignable to type 'Function | RegExp | [RegExp, string] | [Function, string] | ValidateOpts<Names> | ValidateOpts<Names>[]'.
                            Type 'ValidateOpts<Names & Document<any, any, Names> & { _id: ObjectId; }>' is not assignable to type 'Function | RegExp | [RegExp, string] | [Function, string] | ValidateOpts<Names> | ValidateOpts<Names>[]'.
                              Type 'ValidateOpts<Names & Document<any, any, Names> & { _id: ObjectId; }>' is not assignable to type 'ValidateOpts<Names>'.
                                Type 'Names' is not assignable to type 'Names & Document<any, any, Names> & { _id: ObjectId; }'.

So not even sure where to begin with this...

In my example I am trying to do something similar, but I am not sure if this is the correct way to do this.

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

interface ITeam {
    id: number;
    name: string;
}

export interface IMatch {
    _id: Types.ObjectId;
    id: number;
    homeTeam: ITeam;
    awayTeam: ITeam;
}

const TeamSchema = new Schema<ITeam>({
    id: { type: Number, required: true, index: true },
    name: { type: String, required: true },
})

const MatchSchema = new Schema<IMatch>(
    {
        _id: { type: Schema.Types.ObjectId, auto: true },
        id: { type: Number, required: true, unique: true },
        homeTeam: { type: TeamSchema, required: true },
        awayTeam: { type: TeamSchema, required: true },
    }
);

export const Matches = model<IMatch>('matches', MatchSchema);

I have also noticed that if you try to add subdocument defaults then TypeScript will give you errors.

const TeamSchema = new Schema<ITeam>({
    id: { type: Number, required: true, index: true, default: 1 },
    name: { type: String, required: true, default: 'Team Name' },
})

const MatchSchema = new Schema<IMatch>(
    {
        _id: { type: Schema.Types.ObjectId, auto: true },
        id: { type: Number, required: true, unique: true },
        homeTeam: { type: TeamSchema, required: true, default: () => ({}) },
        awayTeam: { type: TeamSchema, required: true, default: () => ({}) },
    }
);
Type '{ type: Schema<ITeam, Model<ITeam, any, any, any>, any>; required: true; default: () => {}; }' is not assignable to type 'SchemaDefinitionProperty<ITeam>'.
  Types of property 'default' are incompatible.
    Type '() => {}' is not assignable to type 'ITeam | ((this: any, doc: any) => ITeam)'.
      Type '() => {}' is not assignable to type '(this: any, doc: any) => ITeam'.
        Type '{}' is missing the following properties from type 'ITeam': id, name
Type '{ type: Schema<ITeam, Model<ITeam, any, any, any>, any>; required: true; default: () => {}; }' is not assignable to type 'SchemaDefinitionProperty<ITeam>'.
  Types of property 'default' are incompatible.
    Type '() => {}' is not assignable to type 'ITeam | ((this: any, doc: any) => ITeam)'.
      Type '() => {}' is not assignable to type '(this: any, doc: any) => ITeam'.
        Type '{}' is missing the following properties from type 'ITeam': id, name

I suppose the error makes sense since these are required properties of TeamSchema and you're trying to assign an empty object instead of the default ones which mongoose does for you via the () => ({}) trick.

@vkarpov15
Copy link
Collaborator

Going through the issues on this comment: #11061 (comment)

Issue 1: Expected behavior. TypeScript doesn't catch this potential error case. Below example also compiles fine

interface Names {
  firstName: string;
}

interface User {
  names?: Names;
}

const doc: User = {};
doc.names.firstName = 'foo';

Issue 2: See discussion on issue 5

Issue 3: This is a naming inconsistency. SingleNested is the same as Types.Subdocument.

Issue 4: Do this instead:

interface Names {
  _id: Types.ObjectId;
  firstName: string;
}

interface User {
  names: Names;
}

type UserDocument = HydratedDocument<
  User & {
    names: Types.Subdocument & Names;
  }
>;

We'll add a generic param to Types.Subdocument to allow specifying the _id type

Issue 5: This is why we typically don't recommend passing a class that extends Document to model<>. Mongoose tries its best to guess which properties are actually part of the schema vs which are part of Document, but hard to do with 100% accuracy. I'm thinking the only workaround to this and issue (2) is to allow passing a separate generic to Model and Query that represents the type of the fully hydrated document, so we can better support subdocuments.


@barca-reddit you can work around this by pulling the subdocument defaults into the homeTeam and awayTeam defaults, default: () => ({ id: 1, name: 'Team name' }). We'll add support for function defaults that return Partial<T> to allow working around this.

vkarpov15 added a commit that referenced this issue Jan 5, 2022
…returning `Partial<T>` from default functions for nested defaults

Re: #11061
@vkarpov15
Copy link
Collaborator

@janos-kasa I took a closer look, the best approach I've been able to find is to use a document interface that doesn't extends Document, but uses Types.Subdocument<> for subdocs:

interface Names {
  _id: Types.ObjectId;
  firstName: string;
}

interface User {
  names: Names;
}

type UserDocument = User & { names: Types.Subdocument & Names; };

Mongoose's typings are smart enough to handle switching between HydratedDocument and raw document interface for top-level documents, but things get messy with subdocuments. So explicitly telling Mongoose which paths are subdocuments is currently necessary.

We'll keep this issue open so we take another look later and add some info on this to the docs, but right now 0ad0d94 and the suggestion to use User & { names: Types.Subdocument & Names; } seems to solve your issues.

@vkarpov15 vkarpov15 modified the milestones: 6.1.6, 6.1.8 Jan 6, 2022
@vkarpov15 vkarpov15 modified the milestones: 6.1.8, 6.1.9, 6.1.10 Jan 24, 2022
@vkarpov15
Copy link
Collaborator

We clarified how to handle subdocs in TypeScript in our docs, you can read the markdown here. The changes will be live on our site when we ship our next release.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
docs This issue is due to a mistake or omission in the mongoosejs.com documentation typescript Types or Types-test related issue / Pull Request
Projects
None yet
Development

No branches or pull requests

3 participants