Handling Validation in Node.js with Joi: A Comprehensive Guide

Data validation is a fundamental aspect of any web application. It ensures that the data flowing into your system meets the expected format, type, and constraints. Improper or unchecked data can lead to vulnerabilities, errors, or unexpected behavior. This blog will explore implementing robust validation in a Node.js application using Joi, a powerful and flexible schema description and data validation library.

Prerequisites

Before proceeding, please ensure that you have the following prerequisites installed:

  • Node.js: Go to the official Node.js website (https://nodejs.org) and download and install Node.js.
  • Basic Understanding of Node.js: Familiarity with JavaScript and setting up basic server applications with Node.js will be beneficial.

Why Should We Validate Data on the Backend in Node.js?

As backend developers, never trust data coming from the frontend. While frontend validation helps improve user experience by catching errors early, it can easily be bypassed, whether intentionally or accidentally. This makes backend validation essential to maintain the integrity and security of your application.

Related read: Object Validation With Joi In React Js & Node Js

Key Reasons for Backend Validation

  1. Prevent Inconsistent Data: Without proper validation, incorrect or inconsistent data could make its way into the database, leading to potential errors and bugs down the line.
  2. Eliminate Vulnerabilities: Unvalidated or malformed data can create vulnerabilities that malicious users might exploit, compromising your system’s security.
  3. Clear Error Messaging: Validating data with a robust library like Joi not only ensures that incoming data meets your criteria but also provides clear, human-readable error messages that can be sent back to the client.

Why Joi?

Joi is a comprehensive and powerful validation library for Node.js. It is easy to integrate into your projects, offers a wide range of validation options, and simplifies error handling. With Joi, you can define schemas for your data and ensure that only valid data is processed by your application.

In the next section, we’ll take a real-world example of a backend project and walk through the step-by-step process of integrating Joi into a Node.js application.

Related read: Mastering Data Validation with Joi in Express: A Comprehensive Guide

Installation

To get started with Joi, first, you need to install it in your Node.js project. Open your terminal and run the following command:

npm install joi

We will take the example of the Splitit backend app. To give you an idea, Splitit is a tool to track expenses and split bills easily. Users can:

  • Add trips and log expenses.
  • See how expenses are divided among the participants in a trip.
  • We also have authentication and authorization for this app.

Now, let’s look at how we can use Joi to validate the data when adding a trip or an expense in the backend.

Models for Adding Expenses and Trips

In the Splitit backend, we need two main models: one for Expenses and another for Trips. These models define the structure of data stored in the database and ensure consistency. Below are the Mongoose schemas for these models:

Expense Model

The Expense model is used to store details about expenses added to a trip.

const mongoose = require('mongoose');
const joi = require('joi');

const Expense = mongoose.model(
  'Expenses',
  mongoose.Schema({
     trip: {
       type: mongoose.Schema.Types.ObjectId,
       ref: 'Trips',
       required: true,
     },
     amount: {
       type: Number,
       required: true,
     },
     title: {
       type: String,
       required: true,
     },
     description: {
       type: String,
       required: false,
     },
     expenseType: {
       type: String,
       required: false,
       default: 'other',
       enum: ['food', 'accomodation', 'transportation', 'other'],
     },
     paidBy: {
       type: mongoose.Schema.Types.ObjectId,
       ref: 'Users',
       required: true,
     },
     sharedAmong: [
       {
          type: mongoose.Schema.Types.ObjectId,
          ref: 'Users',
          required: true,
       },
     ],
   })
);

exports.Expense = Expense;

Trip Model

The Trip model is used to store details about the trips created by users.

const mongoose = require('mongoose');
const joi = require('joi');

const Trip = mongoose.model(
  'Trips',
  mongoose.Schema({
    name: {
      type: String,
      required: true,
      minLength: 3,
      maxLength: 50,
    },
    description: {
      type: String,
      required: false,
      minLength: 3,
      maxLength: 500,
    },
    createdAt: {
      type: Date,
      default: Date.now,
    },
    tripCreator: {
      type: mongoose.Schema.Types.ObjectId,
      ref: 'Users',
    },
    tripParticipants: {
      type: [mongoose.Schema.Types.ObjectId],
      ref: 'Users',
    },
    tripDate: {
      type: Date,
      required: true,
    },
    tripStatus: {
      type: String,
      required: false,
      enum: ['pending', 'completed', 'cancelled', 'ongoing'],
      default: 'pending',
    },
    nonPlatformParticipants: {
      type: [String],
      required: false,
      default: [],
    },
    expenses: {
      type: [mongoose.Schema.Types.ObjectId],
      ref: 'Expenses',
    },
    isCompleted: {
      type: Boolean,
      default: false,
    },
  })
);

exports.Trip = Trip;

These schemas define the structure of the data and relationships between the entities in the Splitit application. To break it down:

🔹Expense Model

  • Trip Association: Every expense is tied to a specific trip. This is represented by the trip field, which is a reference to the Trips model. This ensures that an expense cannot exist without being linked to a trip.
  • Amount and Payment Details: The amount field captures the cost of the expense, while the paidBy field indicates the user who paid for it. The shared among fields holds the users who are splitting the cost of that particular expense. This allows you to track both who paid and who is sharing the cost.
  • Expense Types: The expenseType field allows us to categorize the expense (e.g., food, accommodation, transportation, or others). This helps in organizing expenses based on their nature.

🔹Trip Model:

  • Trip Information: The name and tripDate fields store basic details about the trip, including the name of the trip and the date when it will occur.
  • Participants: The tripParticipants field is an array of users who are part of the trip. The tripCreator field refers to the user who created the trip. This makes it easy to identify the owner and participants of any given trip.
  • Expense Tracking: Each trip can have multiple associated expenses, stored in the expenses field. This is an array of references to the Expenses model, linking all expenses to their respective trips. This way, you can view a complete list of expenses tied to a specific trip.

Before diving into the specific validations for expenses and trips, let’s understand the basic syntax and functions Joi provides to validate data effectively. Joi is a flexible and powerful library that allows us to define validation rules for our data.

Importing Joi

First, you need to import Joi into your file:

const Joi = require('joi');

Creating a Joi Schema

A schema defines the structure of the data you want to validate. It specifies the required fields, their types, and additional constraints. For example:

const schema = Joi.object({
  name: Joi.string().min(3).max(50).required(), // Name must be a string, 3-50 characters long, and is required.
  age: Joi.number().integer().min(18).max(65).required(), // Age must be a number between 18 and 65.
  email: Joi.string().email().required(), // Email must follow a valid email format.
  password: Joi.string().min(8).required(), // Password must have a minimum length of 8.
});

Validating Data

To validate data against a schema, use the validate() method:

const data = {
  name: 'John Doe',
  age: 25,
  email: 'john.doe@example.com',
  password: 'password123',
};
const { error } = schema.validate(data);
if (error) {
  console.error('Validation error:', error.details[0].message);
} else {
  console.log('Validation successful!');
}

Start Building Secure Backends Today with Our Node.js Development Services

Common Joi Methods

String Validation

Joi.string().min(3).max(50).required();
  • min(n): Minimum length of the string.
  • max(n): Maximum length of the string.
  • required(): Makes the field mandatory.

Number Validation

Joi.number().integer().min(0).max(100).required();
  • integer(): Ensures the value is an integer.
  • min(n): Minimum value.
  • max(n): Maximum value.

Date Validation

Joi.date().required();
  • .greater(‘now’): Ensures the date is in the future.
  • .less(‘now’): Ensures the date is in the past.

Array Validation

Joi.array().items(Joi.string().email()).min(1).required();
  • items(): Specifies the type of elements in the array.
  • min(n): Minimum number of items in the array.

Custom Messages

Joi allows custom error messages:

Joi.string().min(3).required().messages({
  'string.min': 'Name should have at least 3 characters.',
  'any.required': 'Name is required.',
});

Object ID Validation

If you need to validate MongoDB Object IDs, use a Joi extension like joi-objectid.

const JoiObjectId = require('joi-objectid')(Joi);
const schema = JoiObjectId().required();

With these basics covered, let’s move on to the validation functions for expenses and trips in the Splitit project.

Expense Validation

For validating expense data within the same model file, we can create a validation function and export it from there. Below is the code we have written to validate the expense details:

const joi = require('joi')

const validateExpense = (expense) => {
  const schema = joi.object({
    trip: joi.objectId().required(),
    amount: joi.number().required().min(0),
    title: joi.string().min(3).max(100).required(),
    description: joi.string().optional().max(500).min(0),
    expenseType: joi
      .string()
      .valid('food', 'accomodation', 'transportation', 'other')
      .optional(),
    paidBy: joi.objectId().required(),
    sharedAmong: joi.array().items(joi.objectId().required()).min(2).required(),
  })
  return schema.validate(expense)
}

exports.validateExpense = validateExpense

For validating expense data, we define a Joi schema within the same file where the Expense model is defined. This allows us to keep the validation logic close to the model for better maintainability. The validateExpense function is exported from the file so it can be reused wherever expense validation is needed.

Using Joi, we ensure fields like trip (a required Object ID), amount (a positive number), and title (a string with a specific length) meet the required criteria. Optional fields like description and expenseType are also validated to match expected formats. This approach ensures clean and consistent data before it’s saved to the database.

Trip Validation

For validating trip data within the same model file, we can create a validation function and export it from there. Below is the code we have written to validate the trip details:

const joi = require('joi')

const validateTrip = (trip) => {
  const startofToday = new Date()
  startofToday.setHours(0, 0, 0, 0)
  const schema = joi.object({
    name: joi.string().min(3).max(50).required(),
    description: joi.string().min(3).max(500).required(),
    tripCreator: joi.objectId().required(),
    tripParticipants: joi.array().items(joi.objectId()).required().min(2),
    tripDate: joi
      .date()
      .required()
      .greater(startofToday)
      ?.message('Trip date cannot be a past date'),
      tripStatus: joi
     .string()
.valid('pending', 'completed', 'cancelled', 'ongoing'),
nonPlatformParticipants: joi.array().items(joi.string().email()),
})
return schema.validate(trip)
}

exports.validateTrip = validateTrip

We use the validateTrip function in the same file as the Trip model to validate trip data before saving it. This function leverages Joi to ensure required fields like name, tripCreator, and tripDate meet specific rules, such as tripDate being a future date. Optional fields like tripStatus and nonPlatformParticipants are also checked for correctness. Exporting this function makes it reusable across the application while maintaining data integrity.

Using Joi Validation in Routes

Here’s an example of how we use the validateTrip function in a route to validate incoming data before adding a trip to the database:

const express = require('express')
const router = express.Router()
const { validateTrip, Trip } = require('../models/trip') // imported form Trip model


router.post('/addTrip', auth, async (req, res) => {
  // Validate request body
  const { value, error } = validateTrip(req.body)
  if (error) return res.status(400).send(error.details[0].message)

  // Check for other validation…
  …
  …
  …

  // Create and save the trip
  let trip = new Trip(
   _.pick(req.body, [
     'name',
     'description',
     'tripCreator',
     'tripParticipants',
     'tripDate',
     'tripStatus',
     'nonPlatformParticipants',
   ])
  )
  await trip.save()
  res.send(trip)
})

module.exports = router

This approach ensures that only clean and consistent data is stored in the database while giving meaningful feedback to the user if errors occur.

coma

Conclusion

In this blog, we explored how to effectively use the Joi library for data validation in a Node.js application. We covered the installation process, understood the various functions Joi provides to create robust validation schemas, and learned how to implement meaningful error messages for users. By walking through an example of validating trip and expense data in the Splitit app, we saw how Joi helps ensure data consistency and integrity while enhancing user experience with clear feedback. Joi makes validation straightforward, allowing us to focus on building reliable and maintainable applications.

Nadeem K

Associate Software Engineer

Nadeem is a front-end developer with 1.5+ years of experience. He has experience in web technologies like React.js, Redux, and UI frameworks. His expertise in building interactive and responsive web applications, creating reusable components, and writing efficient, optimized, and DRY code. He enjoys learning about new technologies.

Keep Reading

Keep Reading

  • Service
  • Career
  • Let's create something together!

  • We’re looking for the best. Are you in?