Validating input is one of the most important parts of building robust applications. Whether you're working on frontend forms, backend APIs, or full-stack projects, reliable validation ensures that your inputs are clean, consistent, and secure.
The JavaScript ecosystem today offers many powerful libraries that make validation easier and more expressive. In this tutorial, we'll explore the top 6 validation libraries for JavaScript in 2025 and what makes each one stand out.
Note: The libraries in this tutorial aren’t ranked. We prefer to let you decide which one you prefer by providing a good overview for each one. Also, keep in mind that if the bundle size isn’t listed in the official docs, I’ll use Bundlephobia as a reference. In that case, the reported size may be larger, since it doesn’t account for tree-shaking optimizations.
Before we get started, here is a quick overview of the 6 libraries we’ll cover in this tutorial:
Library | First Release | Standout Feature | Bundle Size |
---|---|---|---|
Zod | 2020 | Built for TypeScript; infers types directly from schemas for runtime + static safety | 265.6 kB (minified), 52.3 kB (gzipped) |
Yup | 2017 | Declarative, chainable API; great with React and widely used in form libraries like Formik | 167.8 kB (minified), 52.6 kB (gzipped) |
Joi | 2012 | Battle-tested; pioneered schema-as-object validation, core to Hapi.js ecosystem | 167.8 kB (minified), 52.6 kB (gzipped) |
Superstruct | 2018 | Minimal, functional API inspired by TypeScript/GraphQL; lightweight and composable | 11.7 kB (minified), 3.4 kB (gzipped) |
Vest | 2020 | “Validation as tests”; TDD-style assertions make validation read like unit tests | 35.5 kB (minified), 11.6 kB (gzipped) |
Valibot | 2023 | Ultra-lightweight; functional pipe + check API with strong TypeScript support |
1.37 kB |
Now let’s dig in!
Use Case Setup
First, we’re going to set up a simple project in which we’ll integrate each library. This way, you can follow along (if you want to) and see how each library can be integrated in real code. We believe that this will allow you to quickly see which library syntax feels more natural for you.
We’ll be using Express for simplicity, and TypeScript to catch mistakes before they hit runtime:
mkdir top-6-validation && cd top-6-validation
npm init -y
# core server deps
npm i express
npm i -D typescript ts-node-dev @types/express
# validation libs (install now or as you go)
npm i zod yup joi superstruct vest valibot
# (optional) Node types
npm i -D @types/node
tsconfig.json
{
"compilerOptions": {
"target": "ES2020",
"module": "CommonJS",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"outDir": "dist"
},
"include": ["src"]
}
Add a dev script in package.json
:
{
"scripts": {
"dev": "ts-node-dev --respawn --transpile-only src/server.ts"
}
}
Define a global type
src/types.ts
import { Request } from 'express';
export type Party = {
name: string;
email: string;
dob: string;
};
export interface ValidatedRequest extends Request {
validatedData?: any;
}
export type ValidatorFunction = (body: unknown) => {
ok: boolean;
errors?: any;
data?: any
};
Add a helper function
This function ensures all participants are adults (and shows how validators can handle custom logic).
src/utils.ts
export function isAdult(dob: string) {
const d = new Date(dob);
if (Number.isNaN(d.getTime())) return false;
const today = new Date();
const age = today.getFullYear() - d.getFullYear();
const hasBirthdayPassed =
today.getMonth() > d.getMonth() ||
(today.getMonth() === d.getMonth() && today.getDate() >= d.getDate());
const realAge = hasBirthdayPassed ? age : age - 1;
return realAge >= 18;
}
Baseline Express app
src/server.ts
import express from 'express';
import type { NextFunction, Response } from 'express';
import { ValidatedRequest, ValidatorFunction } from './types';
const app = express();
app.use(express.json());
// we will change this value to test different validators
const LIB: 'zod' | 'yup' | 'joi' | 'superstruct' | 'vest' | 'valibot' = 'zod';
import {validateParty as zodValidateParty} from './validators/zod';
// import {validateParty as yupValidateParty} from './validators/yup';
// import {validateParty as joiValidateParty} from './validators/joi';
// import {validateParty as superstructValidateParty} from './validators/superstruct';
// import {validateParty as vestValidateParty} from './validators/vest';
// import {validateParty as valibotValidateParty} from './validators/valibot';
function pickValidator(): ValidatorFunction {
switch (LIB) {
case 'zod': return zodValidateParty;
// case 'yup': return yupValidateParty;
// case 'joi': return joiValidateParty;
// case 'superstruct': return superstructValidateParty;
// case 'vest': return vestValidateParty;
// case 'valibot': return valibotValidateParty;
}
}
function validateBody(validator: ValidatorFunction) {
return (req: ValidatedRequest, res: Response, next: NextFunction) => {
const result = validator(req.body);
if (!result.ok) {
return res.status(400).json({ errors: result.errors });
}
req.validatedData = result.data;
next();
};
}
app.post('/api/parties', validateBody(pickValidator()), (req: ValidatedRequest, res: Response) => {
return res.status(200).json({ message: 'OK', data: req.validatedData });
})
app.listen(3000, () => {
console.log('Server on http://localhost:3000 (library =', LIB, ')');
});
At this point, you’ll probably see some red squiggly lines, but don’t worry, we’ll fix them right now by implementing our first validator.
1. Zod
- Release year: 2021
- Maintainer/Repo: https://github.com/colinhacks
- Bundle size: 265.6 kB (minified), 52.3 kB (gzipped)
Zod might be the “new kid on the block”, but it has quickly gained massive popularity. If you work with TypeScript, you’ve probably already heard of it. Zod is built from the ground up for TypeScript. Instead of just validating, it infers TypeScript types directly from schemas, giving you both runtime and compile-time safety.
For each validator, we’ll define:
- The validation schema: how we want our data to look like.
- A validation function: how the validation schema is used to validate an input, and how validation errors should be handled.
Here is our Zod validator, with comments explaining how each section works.
src/validators/zod.ts
import { z } from "zod";
import type { Party } from "../types";
import { isAdult } from "../utils";
// This is our zod schema for validation
const PartySchema = z.object({
name: z.string().min(5, "Name must be at least 5 characters long"),
email: z.email("Invalid email"),
dob: z
.string()
.refine((value) => !Number.isNaN(Date.parse(value)), "Invalid date")
.refine(isAdult, 'Must be 18 or older'),
});
// This is our zod validator function
// It takes a body of unknown type and returns a validation result
export function validateParty(
body: unknown
):
| { ok: true; data: Party }
| { ok: false; errors: Array<{ field?: string; message: string }> } {
// this is where we validate the body using zod
const parsed = PartySchema.safeParse(body);
if (!parsed.success) {
// if validation fails, we return the issues
const issues = parsed.error.issues.map((i) => ({
field: i.path.join("."),
message: i.message,
}));
return { ok: false, errors: issues };
}
// if validation passes, we return the data
return { ok: true, data: parsed.data };
}
Since Zod is our default validator, we don’t need to change server.ts
. Let’s test it:
# ✅ valid
curl -s -X POST <http://localhost:3000/api/parties> \\
-H "Content-Type: application/json" \\
-d '{"name":"Alice","email":"alice@example.com","dob":"2000-05-20"}' | jq .
# ❌ invalid
curl -s -X POST <http://localhost:3000/api/parties> \\
-H "Content-Type: application/json" \\
-d '{"name":"A","email":"not-an-email","dob":"not-a-date"}' | jq .
You’ll see something like:
{
"message": "OK",
"data": {
"name": "Alice",
"email": "alice@example.com",
"dob": "2000-05-20"
}
}
Or, for invalid requests:
{
"errors": [
{
"field": "name",
"message": "Name must be at least 5 characters long"
},
{
"field": "email",
"message": "Invalid email"
},
{
"field": "dob",
"message": "Invalid date"
},
{
"field": "dob",
"message": "Must be 18 or older"
}
]
}
Note: I won’t show the
curl
commands and results for every library, as it’s too verbose. You can jot down the commands or use a client like HTTPie, Thunder Client, or Postman to fire off requests. The results will follow the same pattern for each validator.
2. Yup
- Release year: 2015
- Maintainer/Repo: https://github.com/jquense
- Bundle size: 167.8 kB (minified), 52.6 kB (gzipped)
Yup is another schema-based validator and is extremely popular for frontend validation. Its declarative, chain-able API is inspired by Joi, but comes with a friendlier syntax for React apps. Often the default in form libraries like Formik. Even though this tutorial is backend-focused, Yup still works just fine here.
src/validators/yup.ts
import * as Yup from 'yup';
import type { Party } from "../types";
import { isAdult } from "../utils";
// This is our yup schema for validation
const PartySchema = Yup.object({
name: Yup.string().min(5, "Name must be at least 5 characters long").required(),
email: Yup.string().email("Invalid email").required(),
dob: Yup.string()
.test('valid-date', 'Invalid date', v => !!v && !Number.isNaN(Date.parse(v)))
.test('adult', 'Must be 18 or older', v => !!v && isAdult(v))
.required(),
});
// This is our yup validator function
// It takes a body of unknown type and returns a validation result
export function validateParty(body: unknown) {
try {
// this is where we validate the body using yup
const data = PartySchema.validateSync(body, { abortEarly: false });
return { ok: true, data: data as Party };
} catch (err: any) {
// if validation fails, we return the issues
const errors = (err.inner ?? []).map((e: any) => ({
field: e.path,
message: e.message,
}));
return { ok: false, errors };
}
}
Now update server.ts
by uncommenting the import and switching the active validator:
// change LIB to 'yup'
const LIB: 'zod' | 'yup' | 'joi' | 'superstruct' | 'vest' | 'valibot' = 'yup';
// uncomment yup import statement
import {validateParty as zodValidateParty} from './validators/zod';
import {validateParty as yupValidateParty} from './validators/yup';
// import {validateParty as joiValidateParty} from './validators/joi';
// ... the rest of the validators
function pickValidator() {
switch (LIB) {
case 'zod': return zodValidateParty;
case 'yup': return yupValidateParty; // <- uncomment this line
// case 'joi': return joiValidateParty;
// case 'superstruct': return superstructValidateParty;
// .. the rest of the validators
}
}
And then we can test our API again. Now that you know the flow, we’ll go through the remaining four libraries.
3. Joi
- Release year: 2012
- Maintainer/Repo: https://github.com/hapijs/joi
- Bundle size: 167.8 kB (minified), 52.6 kB (gzipped)
Joi has been around for a long time and is one of the most widely used validation libraries in backend development. If you’re building APIs with Express, Hapi, or any Node.js framework, chances are you’ve seen Joi in action before. It has been battle-tested for years, especially in the Hapi.js ecosystem. It pioneered the “schema as object” approach many others followed.
src/validators/joi.ts
import Joi from "joi";
import type { Party } from "../types";
import { isAdult } from "../utils";
// This is our joi schema for validation
const PartySchema = Joi.object({
name: Joi.string().min(5).required().messages({
"string.min": "Name must be at least 5 characters long",
"string.empty": "Name is required",
"any.required": "Name is required",
}),
email: Joi.string().email().required().messages({
"string.email": "Invalid email",
"string.empty": "Email is required",
"any.required": "Email is required",
}),
dob: Joi.string()
.custom((v, helpers) => {
if (Number.isNaN(Date.parse(v))) {
return helpers.error("dob.invalidDate"); // use a custom error code
}
if (!isAdult(v)) {
return helpers.error("dob.notAdult"); // use another custom error code
}
return v;
})
.required()
.messages({
"dob.invalidDate": "Invalid date", // map your custom code → message
"dob.notAdult": "Must be 18 or older",
"any.required": "Date of birth is required",
}),
});
// This is our joi validator function
export function validateParty(body: unknown) {
const { error, value } = PartySchema.validate(body, { abortEarly: false });
if (error) {
return {
ok: false,
errors: error.details.map((d) => ({
field: d.path.join("."),
message: d.message,
})),
};
}
return { ok: true, data: value as Party };
}
4. Superstruct
- Release year: 2017
- Maintainer/Repo: https://github.com/ianstormtaylor
- Bundle size: 11.7 kB (minified), 3.4 kB (gzipped)
Superstruct takes a slightly different approach with a lightweight, functional style. Inspired by TypeScript, Flow, and GraphQL, its minimal API makes it easy to define small, composable structs. Perfect when you want validation without heavy dependencies.
src/validators/superstruct.ts
import {
object,
string,
refine,
create,
} from "superstruct";
import type { Party } from "../types";
import { isAdult } from "../utils";
const EmailRegex = /^\\S+@\\S+\\.\\S+$/;
const validateEmail = (value: string) => {
return EmailRegex.test(value);
};
const PartyStruct = object({
name: refine(
string(),
"Name must be at least 5 characters long",
(v) => v.length >= 5
),
email: refine(string(), "Invalid email", (v) => validateEmail(v)),
dob: refine(
string(),
"Must be a valid date and 18+",
(v) => !Number.isNaN(Date.parse(v)) && isAdult(v)
),
});
export function validateParty(body: unknown) {
try {
const data = create(body, PartyStruct) as Party;
return { ok: true, data };
} catch (err: any) {
const failures = err?.failures?.() ?? [];
return {
ok: false,
errors: failures.map((f: any) => {
console.log(f);
return {
field: f.path.join("."),
message: f.refinement,
};
}),
};
}
}
5. Vest
- Release year: 2019
- Maintainer/Repo: https://github.com/ealush
- Bundle size: 35.5 kB (minified), 11.6 kB (gzipped)
Vest is probably the most unique one on this list. Instead of looking like a schema definition, it feels like you’re writing unit tests. Its “validation as tests” approach, inspired by TDD, turns rules into assertions and makes validation fun for developers who enjoy test-driven workflows.
src/validators/vest.ts
import vest, { test, enforce } from 'vest';
import type { Party } from '../types';
import { isAdult } from '../utils';
const isEmail = (s: string) => /^\\S+@\\S+\\.\\S+$/.test(s);
const isDate = (s: string) => !Number.isNaN(Date.parse(s));
// This is our vest test suite for validation
const validateSuite = vest.create('party', (data: any) => {
test('name', 'Name must be at least 5 characters long', () => {
enforce(data.name).isNotEmpty();
enforce(data.name.length).greaterThanOrEquals(5);
});
test('email', 'Invalid email', () => {
enforce(isEmail(data.email)).isTruthy();
});
test('dob', 'Must be a valid date and 18+', () => {
enforce(isDate(data.dob)).isTruthy();
enforce(isAdult(data.dob)).isTruthy();
});
});
// This is our vest validator function
// It takes a body of unknown type and returns a validation result
export function validateParty(body: any) {
// this is where we validate the body using vest
const res = validateSuite(body);
if (res.hasErrors()) {
// if validation fails, we return the issues
const errs = res.getErrors();
const flat = Object.entries(errs).flatMap(([field, messages]) =>
(messages as string[]).map(m => ({ field, message: m }))
);
return { ok: false, errors: flat };
}
// if validation passes, we return the data
return { ok: true, data: body as Party };
}
6. Valibot
- Release year: 2023
- Maintainer/Repo: https://github.com/fabian-hiller
- Bundle size: 1.37 kB
Valibot is a modern schema-based library with a clean, chain-able API. It’s ultra-lightweight and optimized for performance. Its pipe
and check
functions encourage a functional, composable approach to validation.
src/validators/valibot.ts
import * as v from 'valibot';
import type { Party } from '../types';
import { isAdult } from '../utils';
// This is our valibot schema for validation
const PartySchema = v.object({
name: v.pipe(v.string(), v.minLength(5, 'Name must be at least 5 characters long')),
email: v.pipe(v.string(), v.email('Invalid email')),
dob: v.pipe(
v.string(),
v.check((value) => !isNaN(Date.parse(value)), 'Invalid date'),
v.check((value) => isAdult(value), 'Must be 18 or older')
),
});
// This is our valibot validator function
// It takes a body of unknown type and returns a validation result
export function validateParty(body: unknown) {
// this is where we validate the body using valibot
const result = v.safeParse(PartySchema, body);
if (!result.success) {
// if validation fails, we return the issues
return {
ok: false,
errors: result.issues.map(i => ({
field: i.path?.map(p => p.key).join('.') || undefined,
message: i.message,
})),
};
}
// if validation passes, we return the data
return { ok: true, data: result.output as Party };
}
Wrap-up
We covered a lot here, but this is really just the surface of what these libraries can do. The main goal was to give you a taste of their syntax and philosophy so you can choose the one that feels right for your next project.
Personally, I reach for Zod most often, but Vest’s test-like approach always feels like a fun change of pace.
How about you ? Do you already have a favorite, or will you try one of these in your next build? Let me know in the comments!