Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How to type the MongoDB projection results

When I query with projections then I expect the type to be different. For example, my collection has documents of type User.

interface User {
    _id?: ObjectId
    username: string
    foo: number
}

And when I query it, then I expect the result to be of type UserView

interface UserView {
    _id: ObjectId
    username: string
}

My Repository looks something like this.

class UserRepository {
    private collection: Collection<User>
    
    constructor(db: Db) {
        this.collection = db.collection<User>('User')
    }

    public async getUserById(id: ObjectId): Promise<UserView | null> {
        // Result has type User
        const result = await this.collection
            .findOne({ _id: ${ eq: id } }, { projection: { _id: 1, username: 1 })
    }
}

Current solutions I can think of is that I create a new collection with each type like this.

this.collection = db.collection<User>('User')
this.viewCollection = db.collection<UserView>('User')

or leave the typing out and return the function like this:

    public async getUserById(id: ObjectId): Promise<UserView | null> {
        // Result has type Document
        const result = await this.collection
            .findOne({ _id: ${ eq: id } }, { projection: { _id: 1, username: 1 })
        if (result) return result as Userview
        return null
    }

What's the best approach to have proper typing in my MongoDB queries with projections?

like image 413
Maroben Avatar asked Oct 24 '25 04:10

Maroben


1 Answers

First of all you don't need to create custom types for every combination of projections you may be using. Instead of declaring UserView you can use the builtin Pick utility type: Pick<User,'_id'|'username'>.

Then as for the type-safe retrieval of the user projection itself, instead of the more error-prone type casting with as, you can take full advantage of Generics with type argument inference and the keyof operator to reach something like this:

public async getUserById<Field extends keyof User>(
    id: mongodb.ObjectId,
    fields: Field[]
): Promise<Pick<User, Field> | null> {   
    const projection: mongodb.FindOptions['projection'] = {};
    fields.forEach((field) => { projection[field] = 1; });
    const result = await this.db.collection<User>('users').findOne(
      { _id: id },
      { projection }
    );
    return result;
}

// Usage:
const user = await this.getUserById('myId', ['_id', 'username']);
console.log(user); // user is fully type-safe with only the _id and username fields

Long explanation:

I reached the previous solution iteratively, you can see my progress with the various versions of getUserById methods bellow, it may be useful if you want to understand it better:

import * as mongodb from 'mongodb';

interface User {
  _id: string,
  username: string,
  foo: string
}

export default abstract class Test {

  private static db: mongodb.Db;

  public static async init() {
    const client = new mongodb.MongoClient('YOUR_MONGO_URL');
    await client.connect();
    this.db = client.db();
  }

  public static async test() {
    const user: User = { _id: 'myId', username: 'chris', foo: 'bar' };
    console.log(user);

    // Iteration 1: Using hardcoded union type and projection (not reusable)
    const user1 = await this.getUserById1('myId');
    console.log(user1);

    // Iteration 2: Using hardcoded projection (not reusable)
    const user2 = await this.getUserById2<'_id' | 'username'>('myId');
    console.log(user2);

    // Interation 3: Using dynamic union type and projection, but duplicated (not ideal) 
    const user3 = await this.getUserById3<'_id' | 'username'>('myId', ['_id', 'username']);
    console.log(user3);

    // Iteration 4: Using dynamic projection with generic type argument inference, the best solution!
    const user4 = await this.getUserById4('myId', ['_id', 'username']);
    console.log(user4);
  }

  // Iteration 1: Using hardcoded union type and projection (not reusable)
  public static async getUserById1(
    id: string
  ): Promise<Pick<User, '_id' | 'username'> | null> {
    const result = await this.db.collection<User>('users').findOne(
      { _id: id },
      { projection: { _id: true, username: true } }
    );
    return result;
  }

  // Iteration 2: Using hardcoded projection (not reusable)
  public static async getUserById2<Field extends keyof User>(
    id: string
  ): Promise<Pick<User, Field> | null> {
    const result = await this.db.collection<User>('users').findOne(
      { _id: id },
      { projection: { _id: true, username: true } }
    );
    return result;
  }

  // Interation 3: Using dynamic union type and projection, but duplicated (not ideal) 
  public static async getUserById3<Field extends keyof User>(
    id: string,
    fields: (keyof User)[]
  ): Promise<Pick<User, Field> | null> {   
    const projection: mongodb.FindOptions['projection'] = {};
    fields.forEach((field) => { projection[field] = true; });
    const result = await this.db.collection<User>('users').findOne(
      { _id: id },
      { projection }
    );
    return result;
  }

  // Iteration 4: Using dynamic projection with generic type argument inference, the best solution!
  public static async getUserById4<Field extends keyof User>(
    id: string,
    fields: Field[]
  ): Promise<Pick<User, Field> | null> {   
    const projection: mongodb.FindOptions['projection'] = {};
    fields.forEach((field) => { projection[field] = true; });
    const result = await this.db.collection<User>('users').findOne(
      { _id: id },
      { projection }
    );
    return result;
  }

}
like image 66
cprcrack Avatar answered Oct 26 '25 18:10

cprcrack



Donate For Us

If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!