When implementing an endpoint that creates a new document we need to ensure that the data received in a request to create a document is complete and contains valid data. Mongoose can help with that.
Below we discuss how Mongoose handles document validation, how to use built-in and custom validators, and define custom error messages. Please see the mongoose validation documentation for additional examples and details.
Casting
Suppose we have a schema with a path named age that is mapped to a Number.
const userSchema = new Schema({
age: {
type: Number
}
})
When we create a document by calling a model’s constructor, Mongoose attempts to cast the values passed to the constructor to the respective SchemaTypes specified in the model’s schema.
If casting for a path fails (like trying to cast “joe” to a Number for the age path) then Mongoose sets the value of the path to null. In all case, whether casting fails or not a document object is created.
If casting fails and we then try to save the document in a database, Mongoose will throw a ValidationError.
A ValidationError object contains an array named errors that contains an object for each validation error that occurred. If validation fails because of casting we’ll see an object in the errors array that has its name property set to “CastError”.
All Mongoose validation errors have a default message property. We can customize the message by setting the cast configuration object for a path in a schema. Below we set the message to ‘value must be a number.’
const userSchema = new Schema({
age: {
type: Number,
cast: 'value must be a number.'
}
})
In the catch-block below, we process the errors that are caught. If the error that is caught has its name property set to ValidationError then we remove some unnecessary/redundant data from the objects in errors that are a result from casting and send the error object in the response with a status code of 400 (Bad Request – the client should have prevented the errors). Otherwise we return only the error’s name and message properties with a status code of 500 (Internal Server Error).
catch (error) {
if (error.name == 'ValidationError') {
for (let field in error.errors) {
if (error.errors[field].name === 'CastError') {
delete error.errors[field].reason
delete error.errors[field].stringValue
delete error.errors[field].valueType
}
}
return res.status(400).send({ name: error.name, errors: error.errors });
}
res.status(500).send({ name: error.name, message: error.message })
}
Below is the output for an Error that was thrown because the client sent “Joe” for a path (age) whose SchemaType is Number.
{
"name": "ValidationError",
"errors": {
"age": {
"kind": "Number",
"value": "joe",
"path": "age",
"name": "CastError",
"message": "value must be a number"
}
},
}
Built-in Validators
A validator is a function that ensures the value of a field satisfies some constraint. Note that validators are only run on fields that have non-null values.
Mongoose provides a set of built-in validators that can be applied to paths in a schema. For example, suppose we need to ensure that every document in a collection contains a firstName field. Then when we define the schema we can include the required: true configuration option for the firstName path as shown below.
const userSchema = new Schema({
firstName: {
type: String,
required: true
}
})
If we attempt to save a document that does not have a firstName field then Mongoose throws a ValidationError where the objects in the errors array have the name property set to ‘ValidatorError’.
Below is an updated catch-block that cleans up ValidatorErrors.
catch (error) {
const result = error.toJSON()
console.log(result)
if (error.name == 'ValidationError') {
for (let field in error.errors) {
if (error.errors[field].name === 'CastError') {
delete error.errors[field].reason
delete error.errors[field].stringValue
delete error.errors[field].valueType
}
if (error.errors[field].name === 'ValidatorError') {
delete error.errors[field].properties
}
}
return res.status(400).send({ name: error.name, errors: error.errors });
}
res.status(500).send({ name: error.name, message: error.message })
}
Below is the data returned in a response when a casting error occurs and a validation error occurs. This data could be parsed and used by a client to display error messages to the user. Ideally, however a client will validate all of the data before sending a request to the API server thus avoiding unnecessary network traffic and wasted CPU time by the API server.
{
"name": "ValidationError",
"errors": {
"age": {
"kind": "Number",
"value": "joe",
"path": "age",
"name": "CastError",
"message": "value must be a number."
},
"firstName": {
"name": "ValidatorError",
"message": "Path `firstName` is required.",
"kind": "required",
"path": "firstName"
}
}
}
Custom Error Messages
We can define custom error messages in the schema for built-in validators as well. For most built-in validators we set the option equal to an array where the first element in the array is the value of the option and the second element in the array is the error message. Below we set the minimum length to 5 and the validation error message to “Minimum length is 5”.
Aconst userSchema = new Schema({
firstName: {
type: String,
minLength: [5, 'Minimum length is 5' ]
}
})
We can use the above pattern to set custom error messages for all built-in validators except enum. For enum we have to use an object rather than an array, like in the example below.
const userSchema = new Schema({
firstName: {
type: String,
enum: {
values: ['Joe'],
message: 'firstName must be Joe'
}
}
})
Custom Validators
We can also define custom validators for schema properties using the validate property. In the example below we ensure the value of the username field is not “admin” by defining a validator function. The validator function takes the value of the field as an argument and should return true or false. The validation fails if the validator function returns a falsy value or undefined.
const userSchema = new Schema({
username: {
type: String,
validate: {
validator: (value) => value !== 'admin',
message: 'value cannot be admin'
}
}
})
We can also use the validator npm module in a custom validator, like in the example below.
import validator from 'validator'
const userSchema = new Schema({
phone: {
type: String,
trim: true,
validate: {
validator: (value) => validator.isMobilePhone(value, ['en-US']),
message: 'Invalid phone number.'
}
}
})
this
A validator (that is not set to an lambda function) may use this keyword (which references the document being validated) to query or modify fields in the document.
In the example below if the document includes a value for the studentId field then the bucks field is set to 100.
const userSchema = new Schema({
studentId: {
type: Number,
validate(value) {
this.bucks = 100
return true
}
},
bucks: {
type: Number,
default: 0
}
})
Global SchemaType Options (including validate)
Suppose we want to ensure that all strings in the database are trimmed and that no empty strings (“”) are allowed in the database. We can do this by enforcing configuration options on all instances of the String SchemaType.
To do so we have to set the options on the String SchemaType before any model is created. To reiterate, global SchemaType options are only imposed in models that are created after the SchemaType options have been set.
Recall that src/db/mongoose.js is the first file imported in src/app.js. Since mongoose.js is run before anything else in app.js, this seems like a sensible place to set the options. Below is code to sets the trim and validate configuration options on all paths that have a String type.
import { connect, Schema } from 'mongoose'
console.log("Setting global SchemaType options.")
Schema.Types.String.set('validate', {
validator: (value) => value == null || value.length > 0,
message: "String must be null or non-empty."
})
Schema.Types.String.set('trim', true)
console.log(`Connecting to Atlas`)
...