MongoZod
Guides

Virtuals

Compute fields from the stored ones when you read, without storing them.

A virtual is a field that reads compute from other fields of the document, and that the database does not store. Each one is a Zod schema in the virtuals option of mongozod(): a pick of the fields it reads, and a transform that computes its value.

import { ObjectId } from 'mongodb'
import { mongozod } from 'mongozod'
import * as mz from 'mongozod/mzod'
import * as z from 'zod'

const postSchema = z.object({

  _id: mz.objectId()
    .default(() => new ObjectId()),

  title: z.string(),

  body: z.string(),

  status: z.enum(['draft', 'published'])
    .default('draft'),

  tags: z.string()
    .array()
    .default([]),

  authorId: mz.objectId()
})

const PostsModel = mongozod({

  collection: 'posts',

  db,

  schema: postSchema,

  virtuals: {

    readingMinutes: postSchema
      .pick({ body: true })
      .transform(({ body }) => Math.max(1, Math.round(body.split(/\s+/).length / 200))),

    path: postSchema
      .pick({ _id: true })
      .transform(({ _id }) => `/posts/${_id.toHexString()}`)
  }
})

const posts = await PostsModel.find(
  { status: 'published' },
  { projection: { title: 1, readingMinutes: 1 } }
)
// [{ _id: ObjectId('…'), title: 'Hello, MongoDB', readingMinutes: 4 }]

The projection asks for readingMinutes, so MongoZod asks the server for the body it reads, computes it and returns only what the projection asked for.

Declare a virtual

The pick declares the fields the virtual reads, and the transform receives those fields only, typed by the schema. A virtual is a plain Zod schema, without a helper from MongoZod, so it can live next to the schema of the document and run in a client too.

With Zod Mini, the same virtual is a z.pipe of a z.pick and a z.transform. MongoZod reads the fields from the object at the start of the pipes:

import { mongozod } from 'mongozod'
import * as z from 'zod/mini'

const postSchema = z.object({

  title: z.string(),

  body: z.string()
})

const PostsModel = mongozod({

  collection: 'posts',

  db,

  schema: postSchema,

  virtuals: {

    readingMinutes: z.pipe(
      z.pick(postSchema, { body: true }),
      z.transform(({ body }) => Math.max(1, Math.round(body.split(/\s+/).length / 200)))
    )
  }
})

mongozod() checks each virtual against the schema:

  • Its name is not a field of the schema: one that is does not compile, and mongozod() throws an Error. A name with a . or that starts with $ throws too.
  • The fields it reads are fields of the document, with the types the schema stores: a field the document does not have does not compile and throws an Error, and one with another type does not compile.
  • It takes an object, the one its pick gives: a virtual that does not, such as z.string().transform(…), does not compile and throws an Error that says to pick the fields it reads.
  • With several types of document, the fields it reads are in every type, so the virtual exists in every one and has one type.

The model exposes them in PostsModel.virtuals.

Where they are computed

Virtuals are computed in the reads that return documents: find, findOne, findOneAndUpdate, findOneAndReplace and findOneAndDelete. A cursor read with for await computes them batch by batch. They are part of the type of each read:

const post = await PostsModel.findOne({ status: 'published' })
// post?.readingMinutes: number, post?.path: string

Writes return what was stored: the document of insertOne and the documents of insertMany have no virtuals. aggregate does not compute them either: a pipeline computes the same value with $addFields.

Projections

  • An inclusion returns the virtuals it asks for, with 1 or true, and no other. MongoZod asks the server for the fields they read and removes the ones the projection did not ask for: { readingMinutes: 1 } gives { _id, readingMinutes }, and { _id: 0, path: 1 }, whose virtual reads only the _id, gives { path }.
  • An exclusion keeps every virtual, except the ones it excludes, such as { path: 0 }, and the ones whose fields it leaves out or changes: { body: 0 } also leaves out readingMinutes.
  • A virtual takes 0, 1, true or false: any other value throws a ZodError.
  • A virtual reads its fields as stored, so a projection that asks for it and changes one of them, with a subfield, $slice, $elemMatch or a $meta such as the text score, does not compile and throws a ZodError.
const [card] = await PostsModel.find({}, { projection: { _id: 0, title: 1, path: 1 } })
// { title: 'Hello, MongoDB', path: '/posts/…' }: the `_id` is read for `path`, and removed.

Through populate

populate reads the referenced documents through their model, so their virtuals apply and their projection can ask for them: with a fullName virtual on the users, populate: { authorId: { projection: { fullName: 1 } } } gives authorId: { _id, fullName }. See Populate.

A whitelist of mz.projection or mz.populate adds the virtual as one more field, because the schemas of the inputs do not import models: postSchema.pick({ _id: true, title: true }).extend({ readingMinutes: z.number() }). When the router passes the input to the model, its types check that each key is a field or a virtual of the model.

Stored data that does not fit

A virtual validates the fields it reads with its pick. When a stored document does not pass it, such as an old one written before the schema changed, the read throws a ZodError with the path of the field. Leaving the virtual out instead would type every virtual as optional. Check the stored documents before you add a virtual, then migrate them, or give the field a .default(), which the pick applies to a document without the field: see Schema migrations.

What a virtual is not

Like a Mongoose virtual, a virtual is not stored, so a filter or a sort on it does not compile, and neither does an update that writes it. There are no setters. Unlike Mongoose, where a lean() read has no virtuals and toJSON() leaves them out by default, a read returns plain objects with the virtuals in them.

Common mistakes

  • Expecting a virtual in an inclusion that does not name it: { title: 1 } returns no virtual. Add it to the projection: { title: 1, readingMinutes: 1 }.
  • Expecting the virtuals in the result of an insert: the document of insertOne is what was stored. Read the document to get them.
  • Filtering or sorting by a virtual: it does not compile, because the database does not have it. Filter by the fields it reads, or store the value in a field.
  • Picking a field whose transform changes its type, such as a string the schema turns into a number: the pick parses the stored value again, with the input type, so it does not compile. Declare the virtual with a schema that takes the field as it is stored.
  • Expecting aggregate to compute them: it does not. Compute the value with $addFields in the pipeline.
  • Reading old documents that do not fit the pick, such as ones without a field the schema now requires: the read throws a ZodError. Migrate the documents, or give the field a .default(), before you deploy the virtual.

Last updated on

MongoZod is an independent open-source project. It is not affiliated with, endorsed by or sponsored by MongoDB, Inc. MongoDB® is a registered trademark of MongoDB, Inc.

On this page