Joi is widely considered as the most powerful library for describing schemas and validating data in JavaScript. When it comes to Nodejs applications, especially those built with Express, Joi offers a simple yet flexible API for defining and validating different types of data like HTTP request parameters, query parameters, request bodies, and more.
Personally, I've utilized Joi to define validation rules for various data types and effortlessly validate incoming data against them. Joi also offers a diverse range of validation methods that can be customized and combined to cater to specific validation needs. Moreover, it comes with error handling and reporting mechanisms that aid developers in identifying and handling validation errors in a concise and clear manner.
This isn't a comprehensive article on all things Joi validators. Instead, it focuses on how to utilize it like an expert in a Node + Express application.
For instance, when dealing with a small application, one can validate the request body in a Node/Express application with Joi by creating a validation schema and utilizing it to validate the request body inside the route handler. All the schemas can be stored in a schemas.ts
file and then imported to the page where request body validation is required.
const Joi = require('joi');
const express = require('express');
const app = express();
// Define a validation schema for the request body
const schema = Joi.object({
username: Joi.string().alphanum().min(3).max(30).required(),
password: Joi.string().pattern(new RegExp('^[a-zA-Z0-9]{3,30}$')).required(),
});
// Add a route handler to validate the request body
app.post('/login', (req, res, next) => {
const { error, value } = schema.validate(req.body);
if (error) {
return res.status(400).json({ error: error.details[0].message });
} else {
// Valid data, continue with login logic
// ...
}
});
While it may seem like an easy way to do things, importing validation schemas and validating request bodies inside request handlers is not the best approach, especially when working on medium to large projects with other developers. Over time, this can lead to code that is cluttered and repetitive.
But don't worry, there is a better approach that I'll show you. To follow along, you can use this starter template I've created. It's a simple setup for a Node + Express + Typescript application that is ready for us to implement the Joi validator.
Grab the starter template here
After downloading the files, the first step is to run the npm install
command to install all the dependencies. Once that is complete, run the npm run dev
command to start the server. Before proceeding, please verify that everything is working properly. You should see the following message when you open localhost:5000
in your browser:
Now that we have everything set up, we can move on to implementing validation with Joi in our application. To get started, install the Joi npm package by running the command npm install joi
in your terminal.
🚧 Our Approach 🚧
Before we start implementing validation with Joi, let's take a moment to discuss the approach we'll be using. We will create a file called schemas.ts
to store all the schemas that our entire application will use. Inside this file, we will export an object where each key/value pair will be the route/path and the corresponding Joi schema to be validated.
Next, we will create a middleware called SchemaValidator
to validate our request bodies. The SchemaValidator
middleware will accept two arguments. The first argument will be the path (route), and the second argument will be an option to use a custom error message. With the path, we can get the corresponding schema and validate the request body against it.
Create the schemas.ts
file inside the src
folder and paste this code.
schemas.ts
import Joi, { ObjectSchema } from "joi";
const PASSWORD_REGEX = new RegExp(
"^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[!.@#$%^&*])(?=.{8,})"
);
const authSignup = Joi.object().keys({
firstname: Joi.string().required(),
lastname: Joi.string().required(),
email: Joi.string().email().required(),
password: Joi.string().pattern(PASSWORD_REGEX).min(8).required(),
});
const authSignin = Joi.object().keys({
email: Joi.string().required(),
password: Joi.string().required(),
});
export default {
"/auth/signin": authSignin,
"/auth/signup": authSignup,
} as { [key: string]: ObjectSchema };
Here, we define two validation schemas, authSignup
and authSignin
. These schemas ensure that the input data conforms to specific rules, such as requiring certain fields, ensuring they are of a certain type, and/or conform to specific patterns. The exported object containing the schemas will be used in our SchemaValidator
middleware.
If you'd like to learn more about Joi, here's a link to the documentation: https://joi.dev/api/?v=17.9.1
Next, create a new folder inside the src
folder and call it middleware
. Inside the middleware
folder, create schemaValidator.ts
file and paste the code below:
import { RequestHandler } from "express";
import schemas from "../schemas";
interface ValidationError {
message: string;
type: string;
}
interface JoiError {
status: string;
error: {
original: unknown;
details: ValidationError[];
};
}
interface CustomError {
status: string;
error: string;
}
const supportedMethods = ["post", "put", "patch", "delete"];
const validationOptions = {
abortEarly: false,
allowUnknown: false,
stripUnknown: false,
};
const schemaValidator = (path: string, useJoiError = true): RequestHandler => {
const schema = schemas[path];
if (!schema) {
throw new Error(`Schema not found for path: ${path}`);
}
return (req, res, next) => {
const method = req.method.toLowerCase();
if (!supportedMethods.includes(method)) {
return next();
}
const { error, value } = schema.validate(req.body, validationOptions);
if (error) {
const customError: CustomError = {
status: "failed",
error: "Invalid request. Please review request and try again.",
};
const joiError: JoiError = {
status: "failed",
error: {
original: error._original,
details: error.details.map(({ message, type }: ValidationError) => ({
message: message.replace(/['"]/g, ""),
type,
})),
},
};
return res.status(422).json(useJoiError ? joiError : customError);
}
// validation successful
req.body = value;
return next();
};
};
export default schemaValidator;
The SchemaValidator
function takes in a path string and a boolean flag as parameters and returns an Express middleware function.
The middleware function validates the request body against a predefined schema (the schemas in our schemas.ts file) using the Joi library. If the validation fails, it returns a 422 HTTP status code along with a custom error object that contains either the Joi error or a generic error message, depending on the value of the useJoiError
flag.
If the validation succeeds, it sets the request body to the validated value and calls the next() function to pass control to the next middleware. Pretty simple.
Now that we have set up our approach, we can use the SchemaValidator middleware in our route definitions. For example, here's how we can use it to validate the auth routes.
First, create a new folder called auth
inside the routes
folder. Inside the auth
folder, create an index.ts
file where we will define the two authentication routes: the signup
and signin
routes.
import { Router, Request, Response } from "express";
const router = Router();
router.post("/signin", (req: Request, res: Response) => {});
router.post("/signup", (req: Request, res: Response) => {});
export default router;
Then import the auth routes in the main routes/index.ts
file.
import { Router, Request, Response } from "express";
import authRoutes from "./auth";
const router = Router();
router.get("/api/v1", (req: Request, res: Response) => {
res.send("Hello Dev Community!");
});
//? Import other routes here
router.use("/api/v1/auth", authRoutes);
//* eg: router.use("/api/v1/user", userRoutes);
export default router;
Back to the auth routes file, uur route handler will require some data from the user to process, this is where we first validate the user input before it gets to our handler.
To use the SchemaValidator
middleware, import it into the file, and apply it as a middleware function just before the route handlers for the "signin" and "signup" routes. Use the schemaValidator
function and pass the path associated with the correct schema, similar to what was done in the schemas.ts file.
The updated code will look something like this:
import { Router, Request, Response } from "express";
import schemaValidator from "../../middleware/schemaValidator";
const router = Router();
router.post(
"/signin",
schemaValidator("/auth/signin"),
(req: Request, res: Response) => {
return res.send("You've successfully logged in ✔");
}
);
router.post(
"/signup",
schemaValidator("/auth/signup"),
(req: Request, res: Response) => {
return res.send("Sign up complete ✔");
}
);
export default router;
Now that we have defined our authentication routes, we can test them using Postman. Start your dev server by running npm run dev
. To test the signup
route, enter localhost:5000/api/v1/auth/signup
in Postman and set the request method to POST
and the request body type to raw/JSON
. If we make the request without entering anything, we will get an error response like this:
And if we enter all the required fields correctly, we should receive a response with the message "Sign up complete ✔".
You can also test the signin
route and experiment with different payloads to see the different error messages that can be generated.
With this implementation, we can easily create more routes and schemas in our application without worrying about validating each schema. All we need to do is create the schema in the schemas.ts file and apply the SchemaValidator middleware wherever we want to validate the schema. This approach helps to keep our code organized and avoids code repetition.
How exciting right?
It is important to follow standard conventions and organize your application logic in the service and controller folders. In this example, however, we did not follow that convention because it was not the focus of this tutorial.
Summary
The approach we discussed here makes the process of validating data in our Node.js applications easier and more efficient. By using a centralized schema validation approach, we avoid repetitive code and ensure that our code remains clean and easy to understand. Additionally, this approach makes it easy for other developers to pick up our code and understand it.
It is worth noting that this approach can also be applied to other validation libraries in Node.js, such as Zod. Overall, this approach can save us a lot of time and make our codebase more maintainable. Happy coding, and feel free to leave any thoughts or questions in the comments section.
That's the end of the guide! You can access the complete code by visiting https://github.com/JeffreyChix/joi-node-schema-validation.
For more content like this, follow me on Twitter @JeffreySunny1. My DM is open.
Top comments (0)