io-ts, joi, superstruct, and yup are all JavaScript libraries for runtime validation and parsing of data structures. They help ensure that data conforms to expected shapes and types at runtime—critical when working with external inputs like API responses, user forms, or configuration files. While TypeScript provides compile-time type checking, these libraries add a safety net that persists after compilation, enabling robust error handling, data sanitization, and type coercion in real applications.
When building modern web applications, validating data at runtime is essential—especially when dealing with API responses, user input, or configuration files. While TypeScript gives you compile-time safety, it vanishes at runtime. That’s where libraries like io-ts, joi, superstruct, and yup come in. They let you define schemas that validate and often transform data as it flows through your app.
But they don’t all work the same way. Some are built for functional purity, others for developer ergonomics, and some for minimal bundle size. Let’s compare them head-to-head on real-world concerns.
io-ts is designed first and foremost for TypeScript users who want runtime types that perfectly mirror their static types. It uses a functional approach with codecs that both validate and decode data into well-typed structures. If you’re deep in the FP (functional programming) world or need guaranteed type alignment, this is your tool.
// io-ts: Define a codec that doubles as a TypeScript type
import * as t from 'io-ts';
const User = t.type({
id: t.number,
name: t.string
});
type User = t.TypeOf<typeof User>;
// Validate and decode
const result = User.decode({ id: 1, name: 'Alice' });
if (result._tag === 'Right') {
const user: User = result.right; // ✅ Fully typed
}
joi (from the Sideway ecosystem) prioritizes expressiveness and rich validation rules, especially for server-side use. Originally built for Hapi.js, it’s mature, battle-tested, and supports complex validations like conditional logic, custom error messages, and references between fields.
// joi: Rich validation with custom messages
const Joi = require('joi');
const schema = Joi.object({
id: Joi.number().required(),
name: Joi.string().min(2).required()
});
const { error, value } = schema.validate({ id: 1, name: 'A' });
// error.details[0].message → "\"name\" length must be at least 2 characters long"
superstruct takes a minimalist, zero-dependency approach with a focus on clarity and performance. Its API is deliberately small and avoids magic—what you write is what runs. It’s ideal for frontend apps where bundle size matters and you want straightforward validation without hidden abstractions.
// superstruct: Simple, explicit validation
import { object, number, string, assert } from 'superstruct';
const User = object({
id: number(),
name: string()
});
const data = { id: 1, name: 'Alice' };
assert(data, User); // throws if invalid
// No decoding needed — data is already valid
yup emphasizes developer experience and form validation, with a fluent, chainable API that feels natural for defining nested and conditional rules. It’s widely used in React form libraries like Formik because it integrates smoothly with async validation and UI feedback.
// yup: Fluent API with async support
import * as yup from 'yup';
const schema = yup.object({
id: yup.number().required(),
name: yup.string().min(2).required()
});
try {
await schema.validate({ id: 1, name: 'A' });
} catch (err) {
console.log(err.message); // "name must be at least 2 characters"
}
Not all validators just say “valid” or “invalid.” Some also transform data during validation.
io-ts always decodes: it returns a new, validated object (or an error). This ensures the output strictly matches your schema, even if the input had extra fields.// io-ts strips unknown properties by default
const CleanUser = t.type({ id: t.number });
const result = CleanUser.decode({ id: 1, email: 'x@y.com' });
// result.right → { id: 1 } (email removed)
joi can strip unknown keys ({ stripUnknown: true }) or transform values (e.g., convert strings to numbers).const schema = Joi.object({ id: Joi.number() }).options({ stripUnknown: true });
const { value } = schema.validate({ id: '1', extra: 'field' });
// value → { id: 1 } (string converted, extra removed)
superstruct does not transform by default. It validates the input as-is. If you pass an object with extra keys, it stays intact—unless you explicitly use assign or other utilities.// superstruct leaves extra keys
const User = object({ id: number() });
assert({ id: 1, email: 'x@y.com' }, User); // passes, no mutation
yup supports casting and transformation via .cast() or during .validate(). It can coerce types (e.g., string → number) if the schema allows.const schema = yup.number();
const num = schema.cast('42'); // → 42 (number)
While exact numbers aren’t included per instructions, the architectural differences matter:
io-ts has no runtime dependencies but relies heavily on TypeScript. Its functional style can lead to larger call stacks.joi is the heaviest of the four—it includes extensive validation logic, error formatting, and internationalization support. Not ideal for tight frontend budgets.superstruct is tiny and dependency-free, designed for inclusion in browsers or edge environments.yup is moderate in size, with dependencies like toposort for circular reference handling, but optimized for common frontend use cases.How each library reports errors affects debugging and UX.
io-ts returns a structured error tree (via PathReporter or custom reporters), which is great for logging but requires extra steps to get human-readable messages.import { PathReporter } from 'io-ts/PathReporter';
const errors = PathReporter.report(result); // array of strings
joi provides detailed, customizable error objects with context, path, and message. You can override messages globally or per rule.
superstruct throws plain Error objects with a value, type, and path. Easy to catch and inspect, but not localized out of the box.
yup throws ValidationError instances with a message, path, and errors array. Perfect for mapping to form field errors.
If your team treats TypeScript as the source of truth and wants runtime guarantees that match compile-time types, io-ts is unmatched. It eliminates the gap between your types and runtime behavior.
When validating API payloads, environment variables, or config files with complex rules (e.g., “if A is present, B must be a URL”), joi offers the richest rule set and mature error handling.
If you’re building a React app and need fast, simple validation without bloat, superstruct gives you clarity and speed. Great for validating API responses before passing them to components.
For interactive forms with real-time validation, async checks (e.g., username availability), and smooth UX integration, yup’s fluent API and ecosystem support (like Formik) make it the go-to choice.
All four support composing schemas:
io-ts: Compose with t.intersection, t.union, or custom codecs.joi: Use .concat(), .when(), or reference other schemas.superstruct: Combine structs with union, intersection, or custom structs.yup: Chain with .when(), .test(), or reuse partial schemas.But io-ts stands out for type-safe composition: when you merge two codecs, TypeScript knows the resulting type without extra annotations.
| Feature | io-ts | joi | superstruct | yup |
|---|---|---|---|---|
| Primary Audience | TypeScript + FP devs | Node.js / backend | Frontend / minimalists | React / form builders |
| Validation Style | Decode + type inference | Assert + transform | Assert only | Validate + cast |
| Bundle Impact | Low (no deps) | High | Very low | Moderate |
| Error Detail | Structured (needs reporter) | Rich, customizable | Simple, path-based | Form-friendly ValidationError |
| Extra Fields | Stripped by default | Configurable | Kept | Kept (unless strict mode) |
| Async Support | ❌ (sync only) | ✅ | ❌ | ✅ |
Choose based on your project’s constraints:
io-tsjoisuperstructyupNone of these libraries are deprecated. All are actively maintained as of 2024. The right choice depends less on features and more on how well the library’s philosophy matches your team’s workflow and priorities.
Choose joi if you're working on server-side applications (like Node.js APIs or config validation) and need rich, expressive validation rules with detailed error messages and transformation capabilities. It’s overkill for simple frontend use cases due to its larger footprint.
Choose yup if you're building forms in React (or similar UI frameworks) and need a fluent, chainable API with strong support for asynchronous validation, conditional logic, and integration with form libraries like Formik. It strikes a balance between expressiveness and usability for user-facing validation.
Choose superstruct if you want a tiny, zero-dependency validator with a clear, minimal API that doesn’t mutate your data. It’s great for frontend apps where bundle size matters and you prefer explicit, synchronous validation without hidden transformations.
Choose io-ts if you're using TypeScript and need runtime validation that perfectly aligns with your static types. It's ideal for functional programming styles and scenarios where you want decoded, strictly conforming objects with no extraneous properties. Avoid it if you need asynchronous validation or prefer a more imperative API.
npm install joi