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 11 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 @@ -5,6 +5,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 @@ -216,13 +222,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
97 changes: 78 additions & 19 deletions types/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1482,6 +1482,26 @@ declare module 'mongoose' {

type ProjectionFields<DocType> = { [Key in keyof Omit<LeanDocument<DocType>, '__v'>]?: any } & Record<string, any>;

type _QueryManyForModel<T, ResultDoc, TQueryHelpers = {}> =
QueryWithHelpers<Array<ResultDoc>, ResultDoc, TQueryHelpers, T>;
type _QueryOneForModel<T, ResultDoc, TQueryHelpers = {}> =
QueryWithHelpers<ResultDoc, ResultDoc, TQueryHelpers, T>;

/**
* Helper type for getting a find Query type for a given Model (return of Model.find)
*/
type QueryForModel<T extends Model<any>> =
T extends Model<infer MT, infer TQueryHelpers, infer TMethodsAndOverrides, infer TVirtuals> ?
Copy link
Collaborator

Choose a reason for hiding this comment

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

I'm very wary of using infer because it has caused some serious performance issues for us in the past. Can you please provide an example of why this type is helpful?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is just a helper utility type, so you can always not use it if it causes performance issues. Example of use would just be any time you need to return a query or create one but want to assign it to a variable declared earlier.

How I was using it:

function applyQueryRestrictions(userQ: mongoose.QueryForModel<typeof UserModel>, requiredPerm?: string) {
    if (requiredPerm) {
        userQ.where('permissions', requiredPerm);
    }
    userQ.select('passInvalid username call first last email modified permissions')
        .select('verifiedCall.callsign verifiedCall.displayName verifiedCall.sigdate')
        .select('providers.type');
    userQ.populate({
        path: 'veDocs',
        select: 'isTeamLead active deleted number display_name vec call',
        populate: {
            path: 'ulsRecord',
            select: 'callsign frn license_class expired_date',
        }
    });
    return userQ;
}

I certainly could have constructed the query differently, but I wanted to keep type checking as much as possible and it's much easier to do QueryForModel<MyModel> than it is to do QueryWithHelpers<ResultDoc[], ResultDoc, {}, DocType> -- not to mention that you have to figure out what all of those are. My original plan was to just do type MyQueryType = ReturnType<typeof MyModel.find> but since the return type of the different .find overrides aren't all identical that doesn't return a useful type.

Since this is just a utility method I can pull it and put it in my own code -- just seemed like something that others would also find useful and since it's purely optional it didn't seem like something which would cost anything to put in. Your call, just let me know.

_QueryManyForModel<MT, HydratedDocument<MT, TMethodsAndOverrides, TVirtuals>, TQueryHelpers>
: never;
/**
* Helper type for getting a findOne Query type for a given Model (return of Model.findOne)
*/
type QueryOneForModel<T extends Model<any>> =
T extends Model<infer MT, infer TQueryHelpers, infer TMethodsAndOverrides, infer TVirtuals> ?
_QueryOneForModel<MT, HydratedDocument<MT, TMethodsAndOverrides, TVirtuals>, TQueryHelpers>
: never;

class Query<ResultType, DocType, THelpers = {}, RawDocType = DocType> {
_mongooseOptions: MongooseQueryOptions;

Expand Down Expand Up @@ -1583,7 +1603,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 @@ -1600,7 +1620,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 @@ -1680,18 +1700,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 @@ -1707,11 +1727,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 @@ -1738,7 +1758,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 @@ -1751,15 +1771,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 @@ -1795,7 +1815,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 @@ -1848,7 +1868,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 @@ -2084,13 +2104,24 @@ declare module 'mongoose' {
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'>;

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

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

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 @@ -2100,18 +2131,46 @@ declare module 'mongoose' {
// This way, the conditional type is distributive over union types.
// This is required for PopulatedDoc.
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