Back to Tutorials
Tutorial9/24/2025

Top 6 Validation libraries for JavaScript in 2025

javascript
Top 6 Validation libraries for JavaScript in 2025

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

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

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

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

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

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

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!

Comments

Loading comments...

Level Up Your Dev Skills & Income 💰💻

Learn how to sharpen your programming skills, monetize your expertise, and build a future-proof career — through freelancing, SaaS, digital products, or high-paying jobs.

Join 3,000+ developers learning how to earn more, improve their skills, and future-proof their careers.

Top 6 Validation libraries for JavaScript in 2025 | Devmystify