pwd > ninjaPixel/v2/man-drawer/typescript-and-mongodb

TypeScript and MongoDB

Published

The return types of the query functions in the MongoDB library are not very accurate; after succumbing to a silly bug due to the lack of proper typings, I decided to make some type-safe code to wrap around the MongoDB library and let TypeScript do it's thing: protect me with static types.

The library's findOne and find functions accept a type variable, describing the shape of the collection's documents. The problem is that the return type just matches whatever you supplied as the type variable and no notice is taken of the projection. Take the following example where we select a single document and project just a single field. The return type of result is incorrect, typescript believes that all of the document's fields have been returned, when in reality only a single field exists on the object:

type Document = {
  _id: string
  name: string
  age: number
}

async function noTypeSafety() {
  const connection = await databaseConnection()
  const result = await connection.db
    .collection("myCollection")
    .findOne<Document>({}, { projection: { name: 1 } })
  if (result) {
    result._id // ❌ this *should* cause a type error
    result.name // this is fine
    result.age // ❌ this *should* cause a type error
  }
}

That's not the only issue, however. You can also pass in a bogus projection and it will not cause a type error:

async function queryNotTyped() {
  const connection = await databaseConnection()
  const result = await connection.db
    .collection("myCollection")
    .findOne<Document>({}, {
      shoe: 1, // this should cause a type error as `shoe` doesn't exist on Document.
    })
}

We can fix these issues by creating new TypeScript types which describe valid projections and valid return types. There are other cases where the types need overhauling (e.g. the query and sort parameters), but for the purpose of this blog post I am just going to concentrate on calling findOne with a strongly typed projection parameter, returning a correctly typed object.

First-up, I create a class:

import { Filter } from "mongodb";

export class TypeSafeMongo<Document extends Record<string, any>> {
  readonly collectionName: string

  constructor(collectionName: string) {
    this.collectionName = collectionName
  }

  async findOne<
    _F extends Filter<Document>,
    _P extends Projection<Document>
  >(
    filter: _F,
    options: { projection: _P; }
  ) {
   // ... todo 
  }
}

Ultimately I will wrap many of the MongoDB library's functions with better types (e.g. .find, .delete etc.) and using a class like this allows me to instantiate a TypeSafeMongo instance once – declaring the document type – and then reusing that instance throughout my code, without having to worry about passing document types around again. I now need to implement the return type and Projection.

Starting with the easiest one, Projection, what do I want to do? Well, the projection parameter is an object which consists of any combination of fields in the document, assigned a value of 1 or 0. In TypeScript terms:

type UserDocument = {
  _id: string
  handle: string
  name: {
    first: string
    last: string
  }
}

// both of these are valid projections for the UserDocument
const projection1 = {
  name: 1,
}
const projection2 = {
  name: {
    first: 1,
  },
}

It'd be very easy to create a type that describes projection1 (just map each key to the value 1 | 0), however, projection2 is more complicated because we specify a sub field. The Projection type needs to be a Partial of the UserDocument, but not just a Partial (which comes for free with TypeScript), it needs to be a deep partial. Thankfully the type-fest utility library has such a type we can just use. The other thing we need to do is only allow 1 | 0 to be specified for the document's fields, again this is made easy with type-fest and their Schema type which allows us to apply a blanket type definition to all the fields. In fact, I'm going to be even stricter and only allow 1 to be specified:

export type Projection<D> = Schema<PartialDeep<D>, 1>

// these work
const projection1: Projection<UserDocument> = {
  handle: 1, // ✅
}
const projection2: Projection<UserDocument> = {
  name: {
    first: 1, // ✅
  },
}

// ts correctly errors on these
const projection3: Projection<UserDocument> = {
  name: 0, // ts error because you can't supply 0
}
const projection4: Projection<UserDocument> = {
  name: {
    middle: 1, // ts error because this field does not exist
  },
}

Side note: MongoDB projections will allow you to specify 0 if you want to exclude a field; I prefer to have to manually opt-in to each field, this way if I add some sensitive fields to the collection in the future then I won't accidentally dox someone with an overlay keen projection.

So, now for the more complicated return type, Query. This type will need to accept both the Document and the Projection so that it can correctly deduce what will be returned. Enough chat, here is the beast:

import { RemoveNeverDeep } from "./foo"

type DefinedPrimitive = null | string | number | boolean | symbol | bigint

type _DeepFix<
  Doc extends Record<any, any>,
  Proj extends Schema<PartialDeep<Doc>, any>
> = {
  [K in keyof Doc]: Proj[K] extends Record<any, any> // is user requesting a parts of a nested field?
    ? _DeepFix<Doc[K], Proj[K]> // this is an object. We need to recurse
    : Proj[K] extends DefinedPrimitive // not an object, has the user specified this key?
    ? Doc[K] // key has been requested, return it
    : never
}

export type Query<
  Doc extends Record<any, any>,
  Proj extends Schema<PartialDeep<Doc>, any>
> = RemoveNeverDeep<_DeepFix<Doc, Proj>>
// foo.ts
import { EmptyObject } from "type-fest"

/**
 * In some of my utility functions I need to differentiate between a simple object used to store
 * type data (e.g. `{ ... }`) and a real "thing" that is used to get stuff done type of object.
 */
export type UsefulObjects =
  | Date
  | Class<any>
  | BSON.ObjectId
  | ObjectId
  | RegExp

type RemoveEmptyObjectsDeep<B extends object> = {
  [K in keyof B as EmptyObject extends B[K]
    ? never
    : K]: B[K] extends UsefulObjects
    ? B[K]
    : B[K] extends any[]
    ? B[K]
    : B[K] extends object
    ? RemoveEmptyObjectsDeep<B[K]>
    : B[K]
}

type _RemoveNeverDeep<B extends object> = {
  [K in keyof B as B[K] extends never ? never : K]: B[K] extends UsefulObjects
    ? B[K]
    : B[K] extends object
    ? _RemoveNeverDeep<B[K]>
    : B[K]
}

export type RemoveNeverDeep<B extends object> = B extends Date
  ? B
  : RemoveEmptyObjectsDeep<_RemoveNeverDeep<B>>

Alright, there's a lot going on there, the crux of it is:

  • _DeepFix is doing the heavy-lifting. It recurses through the doc parameter and picks it if that field is also present in the projection. Doing it this way round (rather than recursing through the projection and replacing its type – 1 – with the type from the doc) means that we keep the information of whether the field is optional or not (and less importantly whether the type is readonly or not).
  • We end up with a load of never types, these are removed with RemoveNeverDeep
  • We need to take some care around types like Date – which look like objects – and not recurse into them!

So with all of that in place, we can now finish off our class:

import { Filter } from "mongodb";
import { Query } from "./query.ts";

export class TypeSafeMongo<Document extends Record<string, any>> {
  readonly collectionName: string

  constructor(collectionName: string) {
    this.collectionName = collectionName
  }

  private validateProjection(projection: Projection<Document>) {
    if (Object.keys(projection).length === 0) {
      throw new Error("Projection must not be empty", {
        cause: "The projection parameter in the props is empty.",
      })
    }
  }

  async findOne<
    _F extends Filter<Document>,
    _P extends Projection<Document>,
  >(
    filter: _F,
    options: { projection: _P; }
  ): Promise<Query<Document, _P> | null> {
    this.validateProjection(options.projection)
    const connection = await databaseConnection()
    const result = await connection.db
      .collection<Document>(this.collectionName)
      .findOne(filter, {
        projection: options.projection,
      })

    if (!result) return null
    return result as any as Query<Document, _P>
  }
}

I've included a little runtime helper function to validate the projection, and that's it. Type safe mongo queries.