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

Feature/ts type improvements #11650

Merged
merged 13 commits into from
Apr 28, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
29 changes: 27 additions & 2 deletions test/types/docArray.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Schema, model, Document, Types, LeanDocument } from 'mongoose';
import { expectError } from 'tsd';
import { expectError, expectType } from 'tsd';

const schema: Schema = new Schema({ tags: [new Schema({ name: String })] });

Expand Down Expand Up @@ -29,4 +29,29 @@ void async function main() {
const _doc: LeanDocument<ITest> = await Test.findOne().orFail().lean();
_doc.tags[0].name.substring(1);
expectError(_doc.tags.create({ name: 'fail' }));
}();
}();

// https://github.com/Automattic/mongoose/issues/10293
async function gh10293() {
interface ITest {
name: string;
arrayOfArray: Types.Array<string[]>; // <-- Array of Array
}

const testSchema = new Schema<ITest>({
name: {
type: String,
required: true
},
arrayOfArray: [[String]]
});

const TestModel = model('gh10293TestModel', testSchema);

testSchema.methods.getArrayOfArray = function(this: InstanceType<typeof TestModel>): string[][] { // <-- function to return Array of Array
const test = this.toObject();

expectType<string[][]>(test.arrayOfArray);
return test.arrayOfArray; // <-- error here if the issue persisted
};
}
68 changes: 63 additions & 5 deletions test/types/lean.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Schema, model, Document, LeanDocument, Types } from 'mongoose';
import { expectError } from 'tsd';
import { Schema, model, Document, LeanDocument, Types, BaseDocumentType, DocTypeFromUnion, DocTypeFromGeneric } from 'mongoose';
import { expectError, expectNotType, expectType } from 'tsd';

const schema: Schema = new Schema({ name: { type: 'String' } });

Expand All @@ -24,14 +24,18 @@ schema.method('testMethod', () => 42);
const Test = model<ITest>('Test', schema);

void async function main() {
const doc: ITest = await Test.findOne().orFail();
const doc = await Test.findOne().orFail();

doc.subdoc = new Subdoc({ name: 'test' });
doc.id = 'Hello';

doc.testMethod();

const pojo = doc.toObject();
// Because ITest extends Document there is no good way for toObject
// to infer the type which doesn't add a high probability of a circular
// reference, so it must be specified here or else ITest above could be changed
// to `extends Document<number, {}, ITestBase>`
const pojo = doc.toObject<ITestBase>();
expectError(await pojo.save());

const _doc: ITestBase = await Test.findOne().orFail().lean();
Expand Down Expand Up @@ -103,4 +107,58 @@ async function gh11118(): Promise<void> {
for (const doc of docs) {
const _id: Types.ObjectId = doc._id;
}
}
}

async function getBaseDocumentType(): Promise<void> {
interface User {
name: string;
email: string;
avatar?: string;
}

type UserDocUnion = User & Document<Types.ObjectId>;
type UserDocGeneric = Document<Types.ObjectId, {}, User>;

// DocTypeFromUnion should correctly infer the User type from our unioned type
type fromUnion1 = DocTypeFromUnion<UserDocUnion>;
expectType<User>({} as fromUnion1);
// DocTypeFromUnion should give a "false" type if it isn't a unioned type
type fromUnion2 = DocTypeFromUnion<UserDocGeneric>;
expectType<false>({} as fromUnion2);
// DocTypeFromUnion should give a "false" type of it's an any
expectType<false>({} as DocTypeFromUnion<any>);

// DocTypeFromGeneric should correctly infer the User type from our Generic constructed type
type fromGeneric1 = DocTypeFromGeneric<UserDocGeneric>;
expectType<User>({} as fromGeneric1);
// DocTypeFromGeneric should give a "false" type if it's not a type made with Document<?, ?, DocType>
type fromGeneric2 = DocTypeFromGeneric<UserDocUnion>;
expectType<false>({} as fromGeneric2);
// DocTypeFromGeneric should give a "false" type of it's an any
expectType<false>({} as DocTypeFromGeneric<any>);

type baseDocFromUnion = BaseDocumentType<UserDocUnion>;
expectType<User>({} as baseDocFromUnion);

type baseDocFromGeneric = BaseDocumentType<UserDocGeneric>;
expectType<User>({} as baseDocFromGeneric);
}

async function getBaseDocumentTypeFromModel(): Promise<void> {
interface User {
name: string;
email: string;
avatar?: string;
}
const schema = new Schema<User>({});
const Model = model('UserBaseDocTypeFromModel', schema);
type UserDocType = InstanceType<typeof Model>;

type baseFromUserDocType = BaseDocumentType<UserDocType>;

expectType<User & { _id: Types.ObjectId }>({} as baseFromUserDocType);

const a: UserDocType = {} as any;

const b = a.toJSON();
}
2 changes: 1 addition & 1 deletion test/types/populate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ ParentModel.findOne({}).populate('child').orFail().then((doc: Parent & Document)
} else {
useChildDoc(child);
}
const lean = doc.toObject();
const lean = doc.toObject<Parent>();
const leanChild = lean.child;
if (leanChild == null || leanChild instanceof ObjectId) {
throw new Error('should be populated');
Expand Down
14 changes: 9 additions & 5 deletions types/document.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ declare module 'mongoose' {
/** A list of paths to skip. If set, Mongoose will validate every modified path that is not in this list. */
type pathsToSkip = string[] | string;

/**
* Generic types for Document:
* * T - the type of _id
* * TQueryHelpers - Object with any helpers that should be mixed into the Query type
* * DocType - the type of the actual Document created
*/
class Document<T = any, TQueryHelpers = any, DocType = any> {
constructor(doc?: any);

Expand Down Expand Up @@ -215,13 +221,11 @@ declare module 'mongoose' {
set(value: any): this;

/** The return value of this method is used in calls to JSON.stringify(doc). */
toJSON(options: ToObjectOptions & { flattenMaps: false }): LeanDocument<this>;
toJSON(options?: ToObjectOptions): FlattenMaps<LeanDocument<this>>;
toJSON<T = FlattenMaps<DocType>>(options?: ToObjectOptions): T;
toJSON<T = DocType>(options?: ToObjectOptions & { flattenMaps?: true }): FlattenMaps<LeanDocument<T>>;
toJSON<T = DocType>(options: ToObjectOptions & { flattenMaps: false }): LeanDocument<T>;

/** Converts this document into a plain-old JavaScript object ([POJO](https://masteringjs.io/tutorials/fundamentals/pojo)). */
toObject(options?: ToObjectOptions): LeanDocument<this>;
toObject<T = DocType>(options?: ToObjectOptions): T;
toObject<T = DocType>(options?: ToObjectOptions): LeanDocument<T>;

/** Clears the modified state on the specified path. */
unmarkModified(path: string): void;
Expand Down
77 changes: 58 additions & 19 deletions types/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1673,7 +1673,7 @@ declare module 'mongoose' {

/** Specifies a `$elemMatch` query condition. When called with one argument, the most recent path passed to `where()` is used. */
elemMatch(val: Function | any): this;
elemMatch(path: string, val: Function | any): this;
elemMatch<KEY extends keyof FilterQuery<DocType>>(path: KEY, val: FilterQuery<DocType>[KEY]['$elemMatch']): this;

/**
* Gets/sets the error flag on this query. If this flag is not null or
Expand All @@ -1690,7 +1690,7 @@ declare module 'mongoose' {

/** Specifies a `$exists` query condition. When called with one argument, the most recent path passed to `where()` is used. */
exists(val: boolean): this;
exists(path: string, val: boolean): this;
exists<KEY extends keyof FilterQuery<DocType>>(path: KEY, val: FilterQuery<DocType>[KEY]['$exists']): this;

/**
* Sets the [`explain` option](https://docs.mongodb.com/manual/reference/method/cursor.explain/),
Expand Down Expand Up @@ -1805,18 +1805,18 @@ declare module 'mongoose' {

/** Specifies a `$gt` query condition. When called with one argument, the most recent path passed to `where()` is used. */
gt(val: number): this;
gt(path: string, val: number): this;
gte<KEY extends keyof FilterQuery<DocType>>(path: KEY, val: FilterQuery<DocType>[KEY]['$gt']): this;
Copy link
Contributor

@iammola iammola May 2, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey, I was just reading through the diffs and noticed this minor error.

You changed the name of the function. From gt to gte and there is now only one overload for the gt function.

@vkarpov15 @taxilian

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ack! You're right, good catch! @vkarpov15 very sorry about that! I'm assuming you'll want to just fix it since it's merged already? Let me know if you need me for anything

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

covered by #11760


/** Specifies a `$gte` query condition. When called with one argument, the most recent path passed to `where()` is used. */
gte(val: number): this;
gte(path: string, val: number): this;
gte<KEY extends keyof FilterQuery<DocType>>(path: KEY, val: FilterQuery<DocType>[KEY]['$gte']): this;

/** Sets query hints. */
hint(val: any): this;

/** Specifies an `$in` query condition. When called with one argument, the most recent path passed to `where()` is used. */
in(val: Array<any>): this;
in(path: string, val: Array<any>): this;
in<KEY extends keyof FilterQuery<DocType>>(path: KEY, val: FilterQuery<DocType>[KEY]['$in']): this;

/** Declares an intersects query for `geometry()`. */
intersects(arg?: any): this;
Expand All @@ -1832,11 +1832,11 @@ declare module 'mongoose' {

/** Specifies a `$lt` query condition. When called with one argument, the most recent path passed to `where()` is used. */
lt(val: number): this;
lt(path: string, val: number): this;
lt<KEY extends keyof FilterQuery<DocType>>(path: KEY, val: FilterQuery<DocType>[KEY]['$lt']): this;

/** Specifies a `$lte` query condition. When called with one argument, the most recent path passed to `where()` is used. */
lte(val: number): this;
lte(path: string, val: number): this;
lte<KEY extends keyof FilterQuery<DocType>>(path: KEY, val: FilterQuery<DocType>[KEY]['$lte']): this;

/**
* Runs a function `fn` and treats the return value of `fn` as the new value
Expand All @@ -1863,7 +1863,7 @@ declare module 'mongoose' {

/** Specifies a `$mod` condition, filters documents for documents whose `path` property is a number that is equal to `remainder` modulo `divisor`. */
mod(val: Array<number>): this;
mod(path: string, val: Array<number>): this;
mod<KEY extends keyof FilterQuery<DocType>>(path: KEY, val: FilterQuery<DocType>[KEY]['$mod']): this;

/** The model this query was created from */
model: typeof Model;
Expand All @@ -1876,15 +1876,15 @@ declare module 'mongoose' {

/** Specifies a `$ne` query condition. When called with one argument, the most recent path passed to `where()` is used. */
ne(val: any): this;
ne(path: string, val: any): this;
ne<KEY extends keyof FilterQuery<DocType>>(path: KEY, val: FilterQuery<DocType>[KEY]['$ne']): this;

/** Specifies a `$near` or `$nearSphere` condition */
near(val: any): this;
near(path: string, val: any): this;
near<KEY extends keyof FilterQuery<DocType>>(path: KEY, val: FilterQuery<DocType>[KEY]['$near']): this;

/** Specifies an `$nin` query condition. When called with one argument, the most recent path passed to `where()` is used. */
nin(val: Array<any>): this;
nin(path: string, val: Array<any>): this;
nin<KEY extends keyof FilterQuery<DocType>>(path: KEY, val: FilterQuery<DocType>[KEY]['$nin']): this;

/** Specifies arguments for an `$nor` condition. */
nor(array: Array<FilterQuery<DocType>>): this;
Expand Down Expand Up @@ -1920,7 +1920,7 @@ declare module 'mongoose' {

/** Specifies a `$regex` query condition. When called with one argument, the most recent path passed to `where()` is used. */
regex(val: string | RegExp): this;
regex(path: string, val: string | RegExp): this;
regex<KEY extends keyof FilterQuery<DocType>>(path: KEY, val: FilterQuery<DocType>[KEY]['$regex']): this;

/**
* Declare and/or execute this query as a remove() operation. `remove()` is
Expand Down Expand Up @@ -1973,7 +1973,7 @@ declare module 'mongoose' {

/** Specifies an `$size` query condition. When called with one argument, the most recent path passed to `where()` is used. */
size(val: number): this;
size(path: string, val: number): this;
size<KEY extends keyof FilterQuery<DocType>>(path: KEY, val: FilterQuery<DocType>[KEY]['$size']): this;

/** Specifies the number of documents to skip. */
skip(val: number): this;
Expand Down Expand Up @@ -2198,13 +2198,24 @@ declare module 'mongoose' {
export type actualPrimitives = string | boolean | number | bigint | symbol | null | undefined;
export type TreatAsPrimitives = actualPrimitives | NativeDate | RegExp | symbol | Error | BigInt | Types.ObjectId;

// This will -- when possible -- extract the original type of the subdocument in question
type LeanSubdocument<T> = T extends (Types.Subdocument<Require_id<T>['_id']> & infer U) ? LeanDocument<U> : Omit<LeanDocument<T>, '$isSingleNested' | 'ownerDocument' | 'parent'>;

export type LeanType<T> =
0 extends (1 & T) ? T : // any
T extends TreatAsPrimitives ? T : // primitives
T extends Types.Subdocument ? Omit<LeanDocument<T>, '$isSingleNested' | 'ownerDocument' | 'parent'> : // subdocs
T extends Types.Subdocument ? LeanSubdocument<T> : // subdocs
LeanDocument<T>; // Documents and everything else

export type LeanArray<T extends unknown[]> = T extends unknown[][] ? LeanArray<T[number]>[] : LeanType<T[number]>[];
// Used only when collapsing lean arrays for ts performance reasons:
type LeanTypeOrArray<T> = T extends unknown[] ? LeanArray<T> : LeanType<T>;
taxilian marked this conversation as resolved.
Show resolved Hide resolved

export type LeanArray<T extends unknown[]> =
// By checking if it extends Types.Array we can get the original base type before collapsing down,
// rather than trying to manually remove the old types. This matches both Array and DocumentArray
T extends Types.Array<infer U> ? LeanTypeOrArray<U>[] :
// If it isn't a custom mongoose type we fall back to "do our best"
T extends unknown[][] ? LeanArray<T[number]>[] : LeanType<T[number]>[];

export type _LeanDocument<T> = {
[K in keyof T]: LeanDocumentElement<T[K]>;
Expand All @@ -2214,18 +2225,46 @@ declare module 'mongoose' {
// This way, the conditional type is distributive over union types.
// This is required for PopulatedDoc.
export type LeanDocumentElement<T> =
T extends unknown[] ? LeanArray<T> : // Array
T extends Document ? LeanDocument<T> : // Subdocument
T;
0 extends (1 & T) ? T :// any
T extends unknown[] ? LeanArray<T> : // Array
T extends Document ? LeanDocument<T> : // Subdocument
T;

export type SchemaDefinitionType<T> = T extends Document ? Omit<T, Exclude<keyof Document, '_id' | 'id' | '__v'>> : T;

// Helpers to simplify checks
type IfAny<IFTYPE, THENTYPE> = 0 extends (1 & IFTYPE) ? THENTYPE : IFTYPE;
type IfUnknown<IFTYPE, THENTYPE> = unknown extends IFTYPE ? THENTYPE : IFTYPE;

// tests for these two types are located in test/types/lean.test.ts
export type DocTypeFromUnion<T> = T extends (Document<infer T1, infer T2, infer T3> & infer U) ?
[U] extends [Document<T1, T2, T3> & infer U] ? IfUnknown<IfAny<U, false>, false> : false : false;

export type DocTypeFromGeneric<T> = T extends Document<infer IdType, infer TQueryHelpers, infer DocType> ?
IfUnknown<IfAny<DocType, false>, false> : false;

/**
* Helper to choose the best option between two type helpers
*/
export type _pickObject<T1, T2, Fallback> = T1 extends false ? T2 extends false ? Fallback : T2 : T1;

/**
* There may be a better way to do this, but the goal is to return the DocType if it can be infered
* and if not to return a type which is easily identified as "not valid" so we fall back to
* "strip out known things added by extending Document"
* There are three basic ways to mix in Document -- "Document & T", "Document<ObjId, mixins, T>",
* and "T extends Document". In the last case there is no type without Document mixins, so we can only
* strip things out. In the other two cases we can infer the type, so we should
*/
export type BaseDocumentType<T> = _pickObject<DocTypeFromUnion<T>, DocTypeFromGeneric<T>, false>;

/**
* Documents returned from queries with the lean option enabled.
* Plain old JavaScript object documents (POJO).
* @see https://mongoosejs.com/docs/tutorials/lean.html
*/
export type LeanDocument<T> = Omit<_LeanDocument<T>, Exclude<keyof Document, '_id' | 'id' | '__v'> | '$isSingleNested'>;
export type LeanDocument<T> = BaseDocumentType<T> extends Document ? _LeanDocument<BaseDocumentType<T>> :
Omit<_LeanDocument<T>, Exclude<keyof Document, '_id' | 'id' | '__v'> | '$isSingleNested'>;

export type LeanDocumentOrArray<T> = 0 extends (1 & T) ? T :
T extends unknown[] ? LeanDocument<T[number]>[] :
Expand Down