Skip to content

[TypeScript] Usage of 'any' in HydratedDocument and Document types causes document's _id property to have 'any' type #11085

@drewkht

Description

@drewkht

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

Bug

What is the current behavior?

After defining an interface T, creating a schema Schema<T> by calling new Schema<T>(), then generating a model of type Model<T> by calling mongoose.model('ModelName', schema), the type of the _id property on a document created via either new ModelName() or await ModelName.create() resolves to any in most situations, instead of T['_id'] or Types.ObjectId as expected.

Based on the definition of Mongoose's internal Require_id<T> type which is used in HydratedDocument, I believe the intent is for a document's _id to never resolve to any, but rather default to Types.ObjectId when an explicit type isn't provided for _id?

This bug appears to be caused by usage of any as a default type parameter in the definitions of the Document and HydratedDocument types.

I believe it stems from how TypeScript handles a union an intersection of two types containing the same property name, where the property is any in one type and some explicit type in the other.

Edit: Forgot to add that there's one specific way of defining the interface that will produce an _id property that resolves to Types.ObjectId in the resulting documents: If the interface both includes _id: Types.ObjectId and extends Document. This is demonstrated in the full example further below. I assume that this isn't the intended behavior, as the Mongoose docs recommend against extending Document.

Example:

import { Types } from 'mongoose';

interface Type1 {
  _id?: any;
}

interface Type2 {
  _id: Types.ObjectId;
}

type IntersectionType = Type1 & Type2;

const intersectionVar: IntersectionType = { _id: new Types.ObjectId() };

intersectionVar._id;    // _id: any

However, using unknown instead of any generates a different result:

import { Types } from 'mongoose';

interface Type1 {
  _id?: unknown;
}

interface Type2 {
  _id: Types.ObjectId;
}

type IntersectionType = Type1 & Type2;

const intersectionVar: IntersectionType = { _id: new Types.ObjectId() };

intersectionVar._id;    // _id: Types.ObjectId

Essentially, if I'm understanding this right, any & T = any whereas unknown & T = T. I believe the latter behavior is what's desired in the HydratedDocument and Require_id types provided by Mongoose.

Simply editing Mongoose's index.d.ts file in node_modules as follows seems to resolve the problem in my testing, but I'm not sure what sort of cascading changes this might cause elsewhere:

Before:

export type HydratedDocument<DocType, TMethods = {}, TVirtuals = {}> = DocType extends Document ? Require_id<DocType>  :  (Document<any, any, DocType> & Require_id<DocType> & TVirtuals & TMethods);

class Document<T = any, TQueryHelpers = any, DocType = any> {
    ...
}

After:

export type HydratedDocument<DocType, TMethods = {}, TVirtuals = {}> = DocType extends Document ? Require_id<DocType>  :  (Document<unknown, any, DocType> & Require_id<DocType> & TVirtuals & TMethods);

class Document<T = unknown, TQueryHelpers = any, DocType = any> {
    ...
}

If the current behavior is a bug, please provide the steps to reproduce.

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

interface User {
  username: string;
  email: string;
}

interface UserDocument extends Document {
  username: string;
  email: string;
}

interface UserWithId {
  _id: Types.ObjectId;
  username: string;
  email: string;
}

interface UserWithStringId {
  _id: string;
  username: string;
  email: string;
}

interface UserWithIdDocument extends Document {
  _id: Types.ObjectId;
  username: string;
  email: string;
}

interface UserWithStringIdDocument extends Document {
  _id: string;
  username: string;
  email: string;
}

const userSchema = new Schema<User>({
  username: String,
  email: String,
});

const userDocumentSchema = new Schema<UserDocument>({
  username: String,
  email: String,
});

const userWithIdSchema = new Schema<UserWithId>({
  username: String,
  email: String,
});

const userWithStringIdSchema = new Schema<UserWithStringId>({
  username: String,
  email: String,
});

const userWithIdDocumentSchema = new Schema<UserWithIdDocument>({
  username: String,
  email: String,
});

const userWithStringIdDocumentSchema = new Schema<UserWithStringIdDocument>({
  username: String,
  email: String,
});

const UserModel = model('User', userSchema);
const UserWithIdModel = model('UserWithId', userWithIdSchema);
const UserWithStringIdModel = model('UserWithStringId', userWithStringIdSchema);
const UserDocumentModel = model('UserDocument', userDocumentSchema);
const UserWithIdDocumentModel = model(
  'UserWithIdDocument',
  userWithIdDocumentSchema
);
const UserWithStringIdDocumentModel = model(
  'UserWithStringIdDocument',
  userWithStringIdDocumentSchema
);

const newUser = new UserModel();
const newUserWithId = new UserWithIdModel();
const newUserWithStringId = new UserWithStringIdModel();
const newUserDocument = new UserDocumentModel();
const newUserWithIdDocument = new UserWithIdDocumentModel();
const newUserWithStringIdDocument = new UserWithStringIdDocumentModel();

newUser._id; // _id: any
newUserWithId._id; // _id: any
newUserWithStringId._id; // _id: any
newUserDocument._id; // _id: any
newUserWithIdDocument._id; // _id: Types.ObjectId
newUserWithStringIdDocument._id; // _id: string

tsconfig.json:

{
    "compilerOptions": {
      "allowJs": true,
      "allowSyntheticDefaultImports": true,
      "alwaysStrict": true,
      "baseUrl": "src",
      "declaration": true,
      "declarationMap": true,
      "esModuleInterop": true,
      "forceConsistentCasingInFileNames": true,
      "importHelpers": true,
      "incremental": true,
      "isolatedModules": true,
      "lib": [
        "dom",
        "dom.iterable",
        "ESNext"
      ],
      "module": "ESNext",
      "moduleResolution": "node",
      "noEmit": true,
      "noFallthroughCasesInSwitch": true,
      "noImplicitAny": true,
      "noImplicitThis": true,
      "noUncheckedIndexedAccess": true,
      "resolveJsonModule": true,
      "skipLibCheck": true,
      "sourceMap": true,
      "strict": true,
      "target": "ESNext",
      "noErrorTruncation": true
    },
    "exclude": [
      "node_modules",
      "**/node_modules"
    ],
    "include": [
      "src"
    ],
    "ts-node": {
      "compilerOptions": {
        "module": "CommonJS"
      },
      "experimentalReplAwait": true,
      "files": true,
      "transpileOnly": true
    }
  }

What is the expected behavior?

Assuming we have an interface of type T, used to create a schema by calling new Schema<T>(), which is passed to mongoose.model('ModelName', schema) to create a model of type Model<T>, a new document created by either new ModelName() or await ModelName.create() should have an _id property of either type T['_id'] if T extends { _id?: any } or Types.ObjectId otherwise.

What are the versions of Node.js, Mongoose and MongoDB you are using? Note that "latest" is not a version.
Node: 16.10.0
TypeScript: 4.5.3
Mongoose: 6.1.1
MongoDB: 4.2.1

Edit 2: Realized I've been referring to unions when I'm actually talking about intersections. Always reverse those two mentally, oops.

Metadata

Metadata

Assignees

No one assigned

    Labels

    typescriptTypes or Types-test related issue / Pull Request

    Type

    No type

    Projects

    No projects

    Milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions