Skip to content

Nested data

mongoose-guard walks into every level of the body and checks each nested key against the schema. Allowing a parent field ("address") allows everything inside it. Allowing a child ("address.city") allows only that child. Unknown keys, forbidden keys and wrong types are reported with their full path, such as address.hacked or addresses.1.city.

The examples on this page use this schema:

const addressSchema = new Schema(
{
city: { type: String, required: true },
zip: { type: String, match: /^\d{6}$/ },
},
{ _id: false },
);
const userSchema = new Schema({
name: String,
profile: {
bio: { type: String, maxLength: 160 },
website: String,
},
address: addressSchema,
addresses: [addressSchema],
tags: [String],
scores: { type: Map, of: Number },
preferences: Schema.Types.Mixed,
});

profile above is a plain nested object: Mongoose stores it inline, with no schema of its own.

guard(User, { allow: ["profile"] }); // profile.bio and profile.website
guard(User, { allow: ["profile.bio"] }); // only profile.bio

With allow: ["profile.bio"], sending { "profile": { "bio": "hi", "website": "a.dev" } } returns forbidden_field at profile.website.

A plain nested object must be an object. "profile": null and "profile": "hi" are both invalid_type.

address is a sub-schema: a separate Schema used as a field type. It behaves the same way for allow lists:

guard(User, { allow: ["address"] }); // address.city and address.zip
guard(User, { allow: ["address.city"] }); // only address.city

Real output for { "address": { "city": "Delhi", "hacked": true } } with allow: ["address"]:

{
"ok": false,
"issues": [{ "code": "unknown_field", "path": "address.hacked", "message": "Unknown field" }]
}

And for { "address": { "zip": "12" } } with allow: ["address"] and partial: true:

{
"ok": false,
"issues": [
{ "code": "invalid_value", "path": "address.zip", "message": "Path `zip` is invalid (12).", "rule": "regexp" }
]
}

Unlike a plain nested object, a sub-schema may be null when it isn’t required.

addresses is a document array. Each element is checked like a sub-schema, and paths include the element’s position:

guard(User, { allow: ["addresses"] }); // every field of every element
guard(User, { allow: ["addresses.city"] }); // only city, in every element
BodyResult
{ "addresses": [{ "city": "Delhi" }, { "zip": "411001" }] }invalid_value at addresses.1.city (required)
{ "addresses": [{ "city": "Pune", "hacked": true }] }unknown_field at addresses.0.hacked
{ "addresses": ["Delhi"] }invalid_type at addresses.0
{ "addresses": { "city": "Delhi" } }invalid_type at addresses (expected array)

tags: [String] holds plain values. Every element’s type is checked:

{
"ok": false,
"issues": [{ "code": "invalid_type", "path": "tags.1", "message": "Expected string, received number" }]
}

That’s the real output for { "tags": ["js", 42] }. Nested arrays such as matrix: [[Number]] work the same way, with paths like matrix.0.1. Array-level validators, such as a custom “at most 5 tags” rule, run too.

You can’t allow a single position ("tags.0"). Allow the whole array.

scores: { type: Map, of: Number } arrives in JSON as a plain object. Each value is checked against the Map’s of type, and paths use the key:

BodyResult
{ "scores": { "math": 90 } }passes
{ "scores": { "math": "90" } }invalid_type at scores.math
{ "scores": { "$where": 1 } }unsafe_key at scores.$where

Maps of sub-schemas ({ type: Map, of: addressSchema }) are walked like any other sub-schema, so contacts.home.hacked is reported as unknown.

Map keys can’t start with $ or contain a dot. Mongoose refuses those keys in Maps anyway, and mongoose-guard reports them as unsafe_key before Mongoose gets that far.

Schema.Types.Mixed accepts any JSON value. mongoose-guard can’t check its shape, but it still scans every key at every level for unsafe keys. Real output for { "preferences": { "filter": { "$where": "sleep(1000)" } } }:

{
"ok": false,
"issues": [
{ "code": "unsafe_key", "path": "preferences.filter.$where", "message": "Keys starting with $ are not allowed" }
]
}

To keep this scan cheap, values nested more than 32 levels deep inside a Mixed field are rejected with invalid_body.

FieldExampleAllow the whole fieldAllow part of it
plain nested objectprofile: { bio: String }"profile""profile.bio"
sub-schemaaddress: addressSchema"address""address.city"
array of sub-schemasaddresses: [addressSchema]"addresses""addresses.city"
array of primitivestags: [String]"tags"not possible
Mapscores: { type: Map, of: Number }"scores"not possible
Mixedpreferences: Mixed"preferences"not possible