-
-
Notifications
You must be signed in to change notification settings - Fork 3.9k
Description
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.