io-ts, joi, runtypes, and zod are libraries designed to validate data at runtime, ensuring that external inputs (like API responses or form data) match expected structures. While TypeScript provides compile-time type safety, it disappears during execution; these libraries fill that gap by enforcing rules when the code actually runs. joi is a mature, schema-based validator often used in backend environments. io-ts leverages TypeScript's type system to generate runtime validators directly from type definitions, heavily favored in functional programming circles. runtypes offers a fluent, composable API for defining types that exist both at compile-time and runtime. zod has emerged as a modern favorite, combining zero external dependencies, excellent TypeScript inference, and a developer-friendly API that works seamlessly in both Node.js and browser environments.
When building robust applications, trusting data from the outside world is a risk. TypeScript stops errors before you ship, but it cannot stop a user from sending a string where a number is expected in a live API call. This is where runtime validation libraries step in. They act as a security guard at the door, checking every piece of incoming data against a strict set of rules.
Let's look at how io-ts, joi, runtypes, and zod solve this problem, focusing on how they feel to use, how they integrate with TypeScript, and where they shine in real engineering scenarios.
The first thing you notice is how you write the rules. Some libraries make you describe the shape twice (once for TS, once for the validator), while others generate one from the other.
joi uses a chained, imperative API. It feels very natural if you come from a backend background, but it exists separately from your TypeScript types.
// joi: Define schema independently
const Joi = require('joi');
const userSchema = Joi.object({
username: Joi.string().alphanum().min(3).required(),
age: Joi.number().integer().min(0),
email: Joi.string().email()
});
// You must manually define a TS interface to match this
interface User {
username: string;
age?: number;
email?: string;
}
io-ts takes a functional approach. You define the runtime type, and it attempts to infer the static TypeScript type from it. This reduces duplication but can lead to complex type signatures.
// io-ts: Define runtime type, infer static type
import * as t from 'io-ts';
const User = t.type({
username: t.string,
age: t.union([t.number, t.undefined]),
email: t.union([t.string, t.undefined])
});
// TypeScript infers the type automatically
type User = t.TypeOf<typeof User>;
runtypes uses a fluent, object-oriented style that is very readable. Like io-ts, it infers static types from the runtime definition, but the syntax often feels more intuitive for complex unions.
// runtypes: Fluent API with inference
import { Record, String, Number, Optional } from 'runtypes';
const User = Record({
username: String,
age: Optional(Number),
email: Optional(String)
});
type User = typeof User.Static;
zod combines the best of both worlds. It uses a concise, chained syntax similar to joi but is built in TypeScript from the ground up to provide perfect type inference without extra boilerplate.
// zod: Concise schema with perfect inference
import { z } from 'zod';
const userSchema = z.object({
username: z.string().min(3),
age: z.number().int().min(0).optional(),
email: z.string().email().optional()
});
type User = z.infer<typeof userSchema>;
Once you have a schema, you need to check data against it. The way these libraries handle failure is critical for debugging and user feedback.
joi returns an object containing either the validated value or a detailed error array. You have to check the error property explicitly.
// joi: Explicit error checking
const { error, value } = userSchema.validate({ username: 'ab', age: 'not-a-number' });
if (error) {
console.log(error.details[0].message); // "username" length must be at least 3 characters long
} else {
// use value
}
io-ts returns an Either type (a functional programming concept). It forces you to handle both the success (Right) and failure (Left) cases explicitly, which prevents ignoring errors but adds verbosity.
// io-ts: Functional Either handling
import { isRight } from 'fp-ts/Either';
import { PathReporter } from 'io-ts/PathReporter';
const result = User.decode({ username: 'ab', age: 'nan' });
if (isRight(result)) {
const user = result.right;
// use user
} else {
console.log(PathReporter.report(result)); // Prints detailed path errors
}
runtypes throws an exception if validation fails. This is clean for try/catch blocks but can be noisy if you prefer functional error handling.
// runtypes: Try/Catch pattern
try {
const user = User.check({ username: 'ab', age: 'nan' });
// use user
} catch (error) {
console.log(error.message); // Detailed failure message
}
zod offers two methods: .parse() which throws, and .safeParse() which returns a result object. This gives you the flexibility to choose your error handling style per use case.
// zod: Flexible error handling
const result = userSchema.safeParse({ username: 'ab', age: 'nan' });
if (!result.success) {
console.log(result.error.errors[0].message); // "String must contain at least 3 character(s)"
} else {
const user = result.data;
// use user
}
Where you run your code matters. Some libraries are heavy and designed for Node.js, while others are lightweight enough for the browser.
joi was built for servers. It relies on Node.js internals and is quite large. While it can run in the browser with bundlers, it significantly increases your bundle size, making it a poor choice for client-side validation.
// joi: Heavy bundle, primarily for Node.js
// Not recommended for client-side bundles due to size
const schema = Joi.object({ /* ... */ });
io-ts depends heavily on fp-ts. This brings in a lot of functional utility code. While it works in the browser, the bundle size can become a concern for performance-critical frontend apps unless you have advanced tree-shaking setup.
// io-ts: Functional dependencies increase bundle size
import * as t from 'io-ts';
// Brings in fp-ts dependencies
runtypes is lighter than joi and io-ts but still carries some overhead. It is viable for the browser but not as optimized as the newest contenders.
// runtypes: Moderate bundle size
import { String } from 'runtypes';
zod has zero dependencies. It is incredibly small and tree-shakable. This makes it the clear winner for frontend applications, serverless functions, and edge computing where every kilobyte counts.
// zod: Zero dependencies, tiny bundle
import { z } from 'zod';
// Imports only what you need
Real-world data is messy. You often need to tweak data (like converting strings to numbers) or apply complex custom rules.
joi excels at coercion. It can automatically convert a string "123" to the number 123 if you configure it, which is powerful but sometimes dangerous if you aren't careful.
// joi: Automatic coercion
const schema = Joi.object({
count: Joi.number() // Accepts "123" and converts to 123
});
io-ts handles refinement through custom types, but it requires writing boilerplate code to define what constitutes a valid value. It is very strict and does not coerce data by default.
// io-ts: Custom refinement requires boilerplate
const PositiveNumber = new t.Type<number, number, unknown>(
'PositiveNumber',
(input): input is number => typeof input === 'number' && input > 0,
(input, context) => {
if (typeof input === 'string') {
// Manual coercion logic needed here
}
return t.number.validate(input, context);
},
t.identity
);
runtypes allows for custom constraints using .withConstraint(), which is readable but limited to validation only (no transformation).
// runtypes: Constraints
const PositiveNumber = Number.withConstraint(n => n > 0 || 'Must be positive');
zod makes refinement and transformation first-class citizens. You can easily validate and then transform data (e.g., trim a string, convert to date) in a single chain.
// zod: Refinement and Transformation
const schema = z.string()
.trim() // Transform: remove whitespace
.min(1)
.transform(val => parseInt(val, 10)) // Transform: string to number
.refine(val => val > 0, 'Must be positive'); // Refine: custom rule
Despite their differences, all four libraries solve the same core problem with some shared capabilities.
All libraries handle deeply nested structures effortlessly, allowing you to validate complex JSON payloads.
// zod
const nested = z.object({ user: z.object({ id: z.number() }) });
// io-ts
const nested = t.type({ user: t.type({ id: t.number }) });
// runtypes
const nested = Record({ user: Record({ id: Number }) });
// joi
const nested = Joi.object({ user: Joi.object({ id: Joi.number() }) });
Handling data that can be one of several types (e.g., a ID that is either a string or a number) is standard across all tools.
// zod
const id = z.union([z.string(), z.number()]);
// io-ts
const id = t.union([t.string, t.number]);
// runtypes
const id = Union(String, Number);
// joi
const id = Joi.alternatives().try(Joi.string(), Joi.number());
Every library provides mechanisms to tell you exactly where validation failed, including the path to the bad data.
// All provide path information
// zod: result.error.errors[0].path
// io-ts: PathReporter.report(result)
// runtypes: error.message includes path
// joi: error.details[0].path
| Feature | zod | io-ts | joi | runtypes |
|---|---|---|---|---|
| Primary Focus | DX & Bundle Size | Functional Purity | Maturity & Features | Readability |
| Type Inference | βββββ (Perfect) | ββββ (Good) | β (Manual) | ββββ (Good) |
| Bundle Size | π Tiny (0 deps) | π Large (fp-ts) | π Large | π Medium |
| Error Style | Throw or Result | Either (Functional) | Error Object | Throw |
| Data Coercion | Explicit Transform | Manual | Automatic | No |
| Ecosystem | π Rapidly Growing | π§ Niche (FP) | ποΈ Legacy/Backend | π οΈ Steady |
joi is the seasoned veteran. It is incredibly powerful and battle-tested for Node.js backends, especially in legacy systems. However, its size and lack of TypeScript integration make it hard to recommend for new frontend or full-stack TypeScript projects.
io-ts is the academic choice. If your team lives and breathes functional programming and uses fp-ts extensively, it offers a level of type safety that feels magical. For everyone else, the complexity and boilerplate might feel like overkill.
runtypes is the balanced middle ground. It offers great readability and solid type inference without the heavy functional baggage of io-ts. It is a reliable choice for teams that want clarity above all else.
zod is the modern standard. It hits the sweet spot for most developers: tiny bundle, zero dependencies, amazing TypeScript inference, and a flexible API that handles both validation and transformation gracefully. Unless you have a specific need for functional programming patterns or are stuck maintaining a joi codebase, zod is usually the best tool for the job in 2024 and beyond.
Final Thought: Validation is not just about catching errors; it is about defining the contract of your application. The right library makes that contract clear, safe, and easy to maintain.
Choose zod for most modern TypeScript projects, especially those running in the browser or requiring serverless deployment, due to its zero dependencies and small bundle size. It offers the best balance of developer experience, powerful TypeScript inference, and ease of use, making it the default choice for validating API inputs, form data, and environment variables. Its ecosystem is rapidly growing, with first-class support for frameworks like Next.js, Remix, and tRPC.
Choose joi if you are maintaining a legacy Node.js backend or need a highly mature, feature-rich validator with extensive community plugins for complex business rules. It is suitable for server-side validation where bundle size is not a concern. However, do not choose joi for new frontend-heavy projects or browser-based bundles due to its large size and lack of native TypeScript inference compared to modern alternatives.
Choose io-ts if your team is deeply invested in functional programming patterns (like fp-ts) and you want runtime types that are automatically derived from static TypeScript types without duplication. It is ideal for complex domains where type safety is critical and you are comfortable with a steeper learning curve and heavier dependency tree. Avoid it if you prefer simple, imperative code or need a lightweight solution for the browser.
Choose runtypes if you value a highly readable, fluent API that makes defining complex union types and recursive structures feel natural and explicit. It is a strong fit for teams that want a balance between the magic of automatic inference and the clarity of manual schema definition. It works well in full-stack TypeScript projects but has a smaller ecosystem than zod.
TypeScript-first schema validation with static type inference
by @colinhacks
Zod is a TypeScript-first validation library. Define a schema and parse some data with it. You'll get back a strongly typed, validated result.
import * as z from "zod";
const User = z.object({
name: z.string(),
});
// some untrusted data...
const input = {
/* stuff */
};
// the parsed result is validated and type safe!
const data = User.parse(input);
// so you can use it with confidence :)
console.log(data.name);
2kb core bundle (gzipped)npm install zod
Before you can do anything else, you need to define a schema. For the purposes of this guide, we'll use a simple object schema.
import * as z from "zod";
const Player = z.object({
username: z.string(),
xp: z.number(),
});
Given any Zod schema, use .parse to validate an input. If it's valid, Zod returns a strongly-typed deep clone of the input.
Player.parse({ username: "billie", xp: 100 });
// => returns { username: "billie", xp: 100 }
Note β If your schema uses certain asynchronous APIs like async refinements or transforms, you'll need to use the .parseAsync() method instead.
const schema = z.string().refine(async (val) => val.length <= 8);
await schema.parseAsync("hello");
// => "hello"
When validation fails, the .parse() method will throw a ZodError instance with granular information about the validation issues.
try {
Player.parse({ username: 42, xp: "100" });
} catch (err) {
if (err instanceof z.ZodError) {
err.issues;
/* [
{
expected: 'string',
code: 'invalid_type',
path: [ 'username' ],
message: 'Invalid input: expected string'
},
{
expected: 'number',
code: 'invalid_type',
path: [ 'xp' ],
message: 'Invalid input: expected number'
}
] */
}
}
To avoid a try/catch block, you can use the .safeParse() method to get back a plain result object containing either the successfully parsed data or a ZodError. The result type is a discriminated union, so you can handle both cases conveniently.
const result = Player.safeParse({ username: 42, xp: "100" });
if (!result.success) {
result.error; // ZodError instance
} else {
result.data; // { username: string; xp: number }
}
Note β If your schema uses certain asynchronous APIs like async refinements or transforms, you'll need to use the .safeParseAsync() method instead.
const schema = z.string().refine(async (val) => val.length <= 8);
await schema.safeParseAsync("hello");
// => { success: true; data: "hello" }
Zod infers a static type from your schema definitions. You can extract this type with the z.infer<> utility and use it however you like.
const Player = z.object({
username: z.string(),
xp: z.number(),
});
// extract the inferred type
type Player = z.infer<typeof Player>;
// use it in your code
const player: Player = { username: "billie", xp: 100 };
In some cases, the input & output types of a schema can diverge. For instance, the .transform() API can convert the input from one type to another. In these cases, you can extract the input and output types independently:
const mySchema = z.string().transform((val) => val.length);
type MySchemaIn = z.input<typeof mySchema>;
// => string
type MySchemaOut = z.output<typeof mySchema>; // equivalent to z.infer<typeof mySchema>
// number