Skip to content

Support for type narrowing when using projection in find and findOne #15545

@cph-jh332

Description

@cph-jh332

Prerequisites

  • I have written a descriptive issue title
  • I have searched existing issues to ensure the feature has not already been requested

🚀 Feature Proposal

It would be nice if the return type was correctly typed based on the projection input when using find or findOne.

Motivation

I was having a problem where we did not project a parameter that we used later in the code, but TypeScript was happy, because it thought everything was returned.

Example

const someSchema = new Schema({ a: string, b: string})

const someModel = model('Something', someSchema);

const test = someModel.findOne({_id: 'someId'}, {a: 1});

The line above would only give back the "a" parameter from the document, but TypeScript will say you have access to all. Here is my rough plugin code to fix it, mind you I've removed all the mongoose method types:

// plugins/findWithProjection.ts
import { Model, Schema, Types } from 'mongoose';

// Extract array element type more reliably
type ArrayElement<T> = T extends readonly (infer U)[] ? U : never;

// Get filtered keys for both autocomplete AND return types
type FilteredKeys<T> = {
    [K in keyof T]: FilterAutoCompleteKeys<K>;
}[keyof T];

// Recursively filter array elements to remove Mongoose methods
type FilterArrayElements<T> = T extends readonly (infer U)[]
    ? U extends object
        ? Array<{
              [K in FilteredKeys<U>]: FilterArrayElements<U[K]>;
          }>
        : T
    : T;

// Apply filtering to any type (objects, arrays, primitives)
type DeepFilter<T> = T extends readonly any[]
    ? FilterArrayElements<T>
    : T extends object
      ? {
            [K in FilteredKeys<T>]: DeepFilter<T[K]>;
        }
      : T;

// Filter out MongoDB operators and Mongoose methods for AUTOCOMPLETE ONLY
type FilterAutoCompleteKeys<K> = K extends string
    ? K extends `$${string}`
        ? never // Exclude MongoDB operators
        : K extends 'constructor' | 'prototype' | '__proto__'
          ? never // Exclude JavaScript object properties
          : K extends 'save' | 'remove' | 'delete' | 'update' | 'toObject' | 'toJSON' | 'populate' | 'exec'
            ? never // Exclude common Mongoose methods
            : K extends 'collection' | 'db' | 'model' | 'schema' | 'base' | 'modelName'
              ? never // Exclude Mongoose model/collection properties
              : K extends 'isNew' | 'isModified' | 'isSelected' | 'isDirectModified' | 'isDirectSelected'
                ? never // Exclude Mongoose document state methods
                : K extends 'get' | 'set' | 'unset' | 'increment' | 'decrement' | 'push' | 'pull'
                  ? never // Exclude Mongoose document manipulation methods
                  : K extends 'validate' | 'validateSync' | 'invalidate' | 'markModified' | 'markSelected'
                    ? never // Exclude Mongoose validation methods
                    : K extends 'id' | '__v' | 'errors' | '$locals' | '$isValid' | '$op'
                      ? never // Exclude internal properties (but allow _id elsewhere)
                      : K extends 'equals' | 'toString' | 'valueOf' | 'hasOwnProperty' | 'isPrototypeOf'
                        ? never // Exclude Object.prototype methods
                        : K extends 'ownerDocument' | 'parent' | 'parentArray'
                          ? never // Exclude subdocument methods
                          : K extends 'depopulate' | 'populated' | '$populated' | '$isEmpty'
                            ? never // Exclude population-related methods
                            : K extends
                                    | 'baseModelName'
                                    | 'directModifiedPaths'
                                    | 'getChanges'
                                    | 'isInit'
                                    | 'managed'
                                    | 'modifiedPaths'
                                    | 'overwrite'
                                    | 'replaceOne'
                                    | 'unmarkModified'
                              ? never // Exclude DocumentArray specific methods
                              : K extends 'addToSet' | 'create' | 'indexOf' | 'sort' | 'nonAtomicPush'
                                ? never // Exclude more DocumentArray methods
                                : K extends 'splice' | 'unshift' | 'shift' | 'slice' | 'concat' | 'reverse' | 'includes' | 'flat' | 'flatMap'
                                  ? never // Exclude Array.prototype methods
                                  : K extends 'filter' | 'map' | 'forEach' | 'find' | 'every' | 'some' | 'reduce' | 'reduceRight' | 'join'
                                    ? never // Exclude more Array.prototype methods
                                    : K extends 'length' | 'entries' | 'keys' | 'values' | 'at' | 'copyWithin' | 'fill' | 'findIndex' | 'lastIndexOf'
                                      ? never // Exclude remaining Array.prototype methods
                                      : K extends 'pop' | 'from' | 'of' | 'isArray'
                                        ? never // Exclude final Array methods
                                        : K extends 'deleteOne' | 'updateOne' | 'init'
                                          ? never // Exclude remaining Mongoose static/instance methods
                                          : K
    : never;

// Simple autocomplete paths - these show up in intellisense with filtering
type AutocompletePaths<T> = {
    [K in FilteredKeys<T> & string]: T[K] extends readonly any[]
        ? ArrayElement<T[K]> extends object
            ? K | `${K}.${FilteredKeys<ArrayElement<T[K]>> & string}`
            : K
        : T[K] extends object
          ? K | `${K}.${FilteredKeys<T[K]> & string}`
          : K;
}[FilteredKeys<T> & string];

// The projection type that provides good autocomplete
type Projection<T> =
    // Explicit autocomplete-friendly paths (filtered)
    { [K in AutocompletePaths<T> | '_id']?: 0 | 1 | true | false } & { [path: string]: 0 | 1 | true | false }; // Allow any string for deep paths or unknown fields

// Helper to determine if it's an inclusion projection
type IsInclusionProjection<P> = {
    [K in keyof P]: P[K] extends 1 | true ? true : never;
}[keyof P] extends never
    ? false
    : true;

// Helper to extract the top-level field name from a dotted path
type TopLevelField<P extends string> = P extends `${infer Field}.${string}` ? Field : P;

// Get all fields that should be included (use filtered keys for return types)
type IncludedFields<T, P> = {
    [K in keyof P]: P[K] extends 1 | true
        ? K extends FilteredKeys<T>
            ? K
            : K extends string
              ? TopLevelField<K> extends FilteredKeys<T>
                  ? TopLevelField<K>
                  : never
              : never
        : never;
}[keyof P];

// Check if a field has any nested projections
type HasNestedProjection<T, P, Field extends FilteredKeys<T>> = {
    [K in keyof P]: K extends `${Field & string}.${string}` ? true : never;
}[keyof P] extends never
    ? false
    : true;

// Project array elements based on the projection - improved version with deep filtering
type ProjectArray<T, P, Field extends FilteredKeys<T>> = T[Field] extends readonly (infer U)[]
    ? HasNestedProjection<T, P, Field> extends true
        ? U extends object
            ? Array<{
                  [K in FilteredKeys<U> as `${Field & string}.${K & string}` extends keyof P
                      ? P[`${Field & string}.${K & string}`] extends 1 | true
                          ? K
                          : never
                      : never]: U[K];
              }>
            : T[Field] // If array elements are primitives, return the original array
        : DeepFilter<T[Field]> // Apply deep filtering when projecting entire array
    : T[Field];

// Result for inclusion projection
type InclusionResult<T, P> = {
    [K in FilteredKeys<T> as K extends IncludedFields<T, P> ? K : never]: T[K] extends readonly any[] ? ProjectArray<T, P, K> : DeepFilter<T[K]>;
} & {
    _id: P extends { _id: 0 | false } ? never : Types.ObjectId;
};

// Result for exclusion projection
type ExclusionResult<T, P> = {
    [K in FilteredKeys<T> as K extends keyof P ? (P[K] extends 0 | false ? never : K) : K]: DeepFilter<T[K]>;
} & {
    _id: Types.ObjectId;
};

// Final projected result
type ProjectedResult<T, P extends Projection<T>> = IsInclusionProjection<P> extends true ? InclusionResult<T, P> : ExclusionResult<T, P>;

export function findOneWithProjectionPlugin(schema: Schema) {
    schema.statics.findOneWithProjection = async function <TDoc, P extends Projection<TDoc>>(
        this: Model<TDoc>,
        filter: Partial<TDoc>,
        projection: P,
    ): Promise<ProjectedResult<TDoc, P> | null> {
        const result = await this.findOne(filter, projection).lean();
        return result as unknown as ProjectedResult<TDoc, P> | null;
    };

    schema.statics.findWithProjection = async function <TDoc, P extends Projection<TDoc>>(
        this: Model<TDoc>,
        filter: Partial<TDoc>,
        projection: P,
    ): Promise<ProjectedResult<TDoc, P>[]> {
        const results = await this.find(filter, projection).lean();
        return results as unknown as ProjectedResult<TDoc, P>[];
    };
}

export interface FindWithProjectionModel<T> extends Model<T> {
    findOneWithProjection<P extends Projection<T>>(filter: Partial<T & { _id: Types.ObjectId | string }>, projection: P): Promise<ProjectedResult<T, P> | null>;

    findWithProjection<P extends Projection<T>>(filter: Partial<T & { _id: Types.ObjectId | string }>, projection: P): Promise<ProjectedResult<T, P>[]>;
}

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementThis issue is a user-facing general improvement that doesn't fix a bug or add a new featurenew featureThis change adds new functionality, like a new method or class

    Type

    No type

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions