Skip to content

types: stricter projection typing with 1-level deep nesting #15418

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

Merged
merged 4 commits into from
May 16, 2025
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.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 0 additions & 4 deletions test/types/models.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,10 +225,6 @@ function find() {
Project.find({}, { name: 1 });
Project.find({}, { name: 0 });

// filter + callback
Project.find({}, (error: CallbackError, result: IProject[]) => console.log(error, result));
Project.find({ name: 'Hello' }, (error: CallbackError, result: IProject[]) => console.log(error, result));

// filter + projection + options
Project.find({}, undefined, { limit: 5 });
Project.find({}, null, { limit: 5 });
Expand Down
54 changes: 42 additions & 12 deletions test/types/queries.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,12 @@ import {
QuerySelector,
InferSchemaType,
ProjectionFields,
QueryOptions
QueryOptions,
ProjectionType
} from 'mongoose';
import { ModifyResult, ObjectId } from 'mongodb';
import { expectAssignable, expectError, expectNotAssignable, expectType } from 'tsd';
import { autoTypedModel } from './models.test';
import { AutoTypedSchemaType } from './schema.test';

interface QueryHelpers {
_byName(this: QueryWithHelpers<any, ITest, QueryHelpers>, name: string): QueryWithHelpers<Array<ITest>, ITest, QueryHelpers>;
Expand Down Expand Up @@ -54,6 +54,9 @@ interface ISubdoc {
myId?: Types.ObjectId;
id?: number;
tags?: string[];
profiles: {
name?: string
}
}

interface ITest {
Expand Down Expand Up @@ -93,16 +96,6 @@ Test.find({ name: ['Test1', 'Test2'] }).exec();
// Implicit `$in` for regex string
Test.find({ name: [/Test1/, /Test2/] });

Test.find({ name: 'test' }, (err: Error | null, docs: ITest[]) => {
console.log(!!err, docs[0].age);
});

Test.findOne({ name: 'test' }, (err: Error | null, doc: ITest | null) => {
if (doc != null) {
console.log(!!err, doc.age);
}
});

Test.find({ name: { $gte: 'Test' } }, null, { collation: { locale: 'en-us' } }).exec().
then((res: Array<ITest>) => console.log(res[0].name));

Expand Down Expand Up @@ -160,6 +153,43 @@ const p1: Record<string, number> = Test.find().projection('age docs.id');
const p2: Record<string, number> | null = Test.find().projection();
const p3: null = Test.find().projection(null);

expectError(Test.find({ }, { name: 'ss' })); // Only 0 and 1 are allowed
expectError(Test.find({ }, { name: 3 })); // Only 0 and 1 are allowed
expectError(Test.find({ }, { name: true, age: false, endDate: true, tags: 1 })); // Exclusion in a inclusion projection is not allowed
expectError(Test.find({ }, { name: true, age: false, endDate: true })); // Inclusion in a exclusion projection is not allowed
expectError(Test.find({ }, { name: false, age: false, tags: false, child: { name: false }, docs: { myId: false, id: true } })); // Inclusion in a exclusion projection is not allowed in nested objects and arrays
expectError(Test.find({ }, { tags: { something: 1 } })); // array of strings or numbers should only be allowed to be a boolean or 1 and 0
Test.find({}, { name: true, age: true, endDate: true, tags: 1, child: { name: true }, docs: { myId: true, id: true } }); // This should be allowed
Test.find({}, { name: 1, age: 1, endDate: 1, tags: 1, child: { name: 1 }, docs: { myId: 1, id: 1 } }); // This should be allowed
Test.find({}, { _id: 0, name: 1, age: 1, endDate: 1, tags: 1, child: 1, docs: 1 }); // _id is an exception and should be allowed to be excluded
Test.find({}, { name: 0, age: 0, endDate: 0, tags: 0, child: 0, docs: 0 }); // This should be allowed
Test.find({}, { name: 0, age: 0, endDate: 0, tags: 0, child: { name: 0 }, docs: { myId: 0, id: 0 } }); // This should be allowed
Test.find({}, { name: 1, age: 1, _id: 0 }); // This should be allowed since _id is an exception
Test.find({}, { someOtherField: 1 }); // This should be allowed since it's not a field in the schema
expectError(Test.find({}, { name: { $slice: 1 } })); // $slice should only be allowed on arrays
Test.find({}, { tags: { $slice: 1 } }); // $slice should be allowed on arrays
Test.find({}, { tags: { $slice: [1, 2] } }); // $slice with the format of [ <number to skip>, <number to return> ] should also be allowed on arrays
expectError(Test.find({}, { age: { $elemMatch: {} } })); // $elemMatch should not be allowed on non arrays
Test.find({}, { docs: { $elemMatch: { id: 'aa' } } }); // $elemMatch should be allowed on arrays
expectError(Test.find({}, { tags: { $slice: 1, $elemMatch: {} } })); // $elemMatch and $slice should not be allowed together
Test.find({}, { age: 1, tags: { $slice: 5 } }); // $slice should be allowed in inclusion projection
Test.find({}, { age: 0, tags: { $slice: 5 } }); // $slice should be allowed in exclusion projection
Test.find({}, { age: 1, tags: { $elemMatch: {} } }); // $elemMatch should be allowed in inclusion projection
Test.find({}, { age: 0, tags: { $elemMatch: {} } }); // $elemMatch should be allowed in exclusion projection
expectError(Test.find({}, { 'docs.id': 11 })); // Dot notation should be allowed and does not accept any
expectError(Test.find({}, { docs: { id: '1' } })); // Dot notation should be able to use a combination with objects
Test.find({}, { docs: { id: false } }); // Dot notation should be allowed with valid values - should correctly handle arrays
Test.find({}, { docs: { id: true } }); // Dot notation should be allowed with valid values - should correctly handle arrays
Test.find({ docs: { $elemMatch: { id: 1 } } }, { 'docs.$': 1 }); // $ projection should be allowed
Test.find({}, { child: 1 }); // Dot notation should be able to use a combination with objects
// Test.find({}, { 'docs.profiles': { name: 1 } }); // 3 levels deep not supported
expectError(Test.find({}, { 'docs.profiles': { name: 'aa' } })); // should support a combination of dot notation and objects
expectError(Test.find({}, { endDate: { toString: 1 } }));
expectError(Test.find({}, { tags: { trim: 1 } }));
expectError(Test.find({}, { child: { toJSON: 1 } }));

// Manual Casting using ProjectionType
Test.find({}, { docs: { unknownParams: 1 } } as ProjectionType<ITest>);
// Sorting
Test.find().sort();
Test.find().sort('-name');
Expand Down
34 changes: 31 additions & 3 deletions types/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -657,9 +657,37 @@ declare module 'mongoose' {

export type ReturnsNewDoc = { new: true } | { returnOriginal: false } | { returnDocument: 'after' };

export type ProjectionElementType = number | string;
export type ProjectionType<T> = { [P in keyof T]?: ProjectionElementType } | AnyObject | string;

type ArrayOperators = { $slice: number | [number, number]; $elemMatch?: never } | { $elemMatch: Record<string, any>; $slice?: never };
/**
* This Type Assigns `Element | undefined` recursively to the `T` type.
* if it is an array it will do this to the element of the array, if it is an object it will do this for the properties of the object.
* `Element` is the truthy or falsy values that are going to be used as the value of the projection.(1 | true or 0 | false)
* For the elements of the array we will use: `Element | `undefined` | `ArrayOperators`
* @example
* type CalculatedType = Projector<{ a: string, b: number, c: { d: string }, d: string[] }, true>
* type CalculatedType = {
a?: true | undefined;
b?: true | undefined;
c?: true | {
d?: true | undefined;
} | undefined;
d?: true | ArrayOperators | undefined;
}
*/
type Projector<T, Element> = T extends Array<infer U>
? Projector<U, Element> | ArrayOperators
: T extends TreatAsPrimitives
? Element
: T extends Record<string, any>
? {
[K in keyof T]?: T[K] extends Record<string, any> ? Projector<T[K], Element> | Element : Element;
}
: Element;
type _IDType = { _id?: boolean | 1 | 0 };
export type InclusionProjection<T> = IsItRecordAndNotAny<T> extends true ? Projector<WithLevel1NestedPaths<T>, true | 1> & _IDType : AnyObject;
export type ExclusionProjection<T> = IsItRecordAndNotAny<T> extends true ? Projector<WithLevel1NestedPaths<T>, false | 0> & _IDType : AnyObject;

export type ProjectionType<T> = (InclusionProjection<T> & AnyObject) | (ExclusionProjection<T> & AnyObject) | string;
export type SortValues = SortOrder;

export type SortOrder = -1 | 1 | 'asc' | 'ascending' | 'desc' | 'descending';
Expand Down
2 changes: 1 addition & 1 deletion types/query.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ declare module 'mongoose' {
* Set `overwriteImmutable` to `true` to allow updating immutable properties using other update operators.
*/
overwriteImmutable?: boolean;
projection?: ProjectionType<DocType>;
projection?: { [P in keyof DocType]?: number | string } | AnyObject | string;
/**
* if true, returns the full ModifyResult rather than just the document
*/
Expand Down
4 changes: 2 additions & 2 deletions types/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ declare module 'mongoose' {

class Decimal128 extends mongodb.Decimal128 { }

class DocumentArray<T, THydratedDocumentType extends Types.Subdocument<any> = Types.Subdocument<InferId<T>, any, T> & T> extends Types.Array<THydratedDocumentType> {
class DocumentArray<T, THydratedDocumentType extends Types.Subdocument<any, any, T> = Types.Subdocument<InferId<T>, any, T> & T> extends Types.Array<THydratedDocumentType> {
/** DocumentArray constructor */
constructor(values: AnyObject[]);

Expand All @@ -85,7 +85,7 @@ declare module 'mongoose' {
class ObjectId extends mongodb.ObjectId {
}

class Subdocument<IdType = unknown, TQueryHelpers = any, DocType = any> extends Document<IdType, TQueryHelpers, DocType> {
class Subdocument<IdType = any, TQueryHelpers = any, DocType = any> extends Document<IdType, TQueryHelpers, DocType> {
$isSingleNested: true;

/** Returns the top level document of this sub-document. */
Expand Down
38 changes: 31 additions & 7 deletions types/utility.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,44 @@ declare module 'mongoose' {

type WithLevel1NestedPaths<T, K extends keyof T = keyof T> = {
[P in K | NestedPaths<Required<T>, K>]: P extends K
? T[P]
// Handle top-level paths
// First, drill into documents so we don't end up surfacing `$assertPopulated`, etc.
? Extract<NonNullable<T[P]>, Document> extends never
// If not a document, then return the type. Otherwise, get the DocType.
? NonNullable<T[P]>
: Extract<NonNullable<T[P]>, Document> extends Document<any, any, infer DocType, any>
? DocType
: never
// Handle nested paths
: P extends `${infer Key}.${infer Rest}`
? Key extends keyof T
? Rest extends keyof NonNullable<T[Key]>
? NonNullable<T[Key]>[Rest]
: never
? T[Key] extends (infer U)[]
? Rest extends keyof NonNullable<U>
? NonNullable<U>[Rest]
: never
: Rest extends keyof NonNullable<T[Key]>
? NonNullable<T[Key]>[Rest]
: never
: never
: never;
};

type NestedPaths<T, K extends keyof T> = K extends string
? T[K] extends Record<string, any> | null | undefined
? `${K}.${keyof NonNullable<T[K]> & string}`
: never
? T[K] extends TreatAsPrimitives
? never
: Extract<NonNullable<T[K]>, Document> extends never
? T[K] extends Array<infer U>
? U extends Record<string, any>
? `${K}.${keyof NonNullable<U> & string}`
: never
: T[K] extends Record<string, any> | null | undefined
? `${K}.${keyof NonNullable<T[K]> & string}`
: never
: Extract<NonNullable<T[K]>, Document> extends Document<any, any, infer DocType, any>
? DocType extends Record<string, any>
? `${K}.${keyof NonNullable<DocType> & string}`
: never
: never
: never;

type WithoutUndefined<T> = T extends undefined ? never : T;
Expand Down