io-ts vs joi vs runtypes vs superstruct vs yup vs zod
Runtime Type Validation and Schema Parsing in TypeScript
io-tsjoiruntypessuperstructyupzodSimilar Packages:

Runtime Type Validation and Schema Parsing in TypeScript

These libraries provide runtime validation for data, ensuring that inputs match expected shapes before they are processed in your application. While TypeScript handles types at compile time, these tools verify data at runtime — which is critical for API responses, form inputs, and external data sources. They range from simple schema validators to full functional programming type systems, each offering different trade-offs in developer experience, TypeScript integration, and bundle weight.

Npm Package Weekly Downloads Trend

3 Years

Github Stars Ranking

Stat Detail

Package
Downloads
Stars
Size
Issues
Publish
License
io-ts06,814460 kB161a year agoMIT
joi021,189584 kB196a month agoBSD-3-Clause
runtypes02,690312 kB27a year agoMIT
superstruct07,140182 kB1002 years agoMIT
yup023,678270 kB2419 months agoMIT
zod042,8764.56 MB183a month agoMIT

Runtime Type Validation: io-ts vs joi vs runtypes vs superstruct vs yup vs zod

When building professional frontend applications, trusting data from APIs or user inputs is a risk. TypeScript protects you at compile time, but it cannot stop a user from sending a string where a number is expected. This is where runtime validation libraries come in. We will compare six major tools — io-ts, joi, runtypes, superstruct, yup, and zod — to help you decide which fits your architecture.

🛡️ TypeScript Integration: Inference vs Manual Typing

The biggest differentiator in modern development is how well the library integrates with TypeScript. Some libraries let you define a schema once and infer the type automatically, while others require you to maintain types separately.

zod excels here by inferring TypeScript types directly from the schema.

import { z } from "zod";

const UserSchema = z.object({
  id: z.number(),
  name: z.string()
});

// Type is inferred automatically
type User = z.infer<typeof UserSchema>;

io-ts also infers types but requires more boilerplate to extract them.

import * as t from "io-ts";

const UserCodec = t.type({
  id: t.number,
  name: t.string
});

// Type extracted via utility
type User = t.TypeOf<typeof UserCodec>;

runtypes uses a functional approach to define types that double as validators.

import { Record, String, Number } from "runtypes";

const User = Record({
  id: Number,
  name: String
});

// Type inferred from the runtype
type User = typeof User.Static;

yup supports TypeScript but often requires extra plugins or manual type assertions for perfect inference.

import * as yup from "yup";

const UserSchema = yup.object({
  id: yup.number().required(),
  name: yup.string().required()
});

// Type inference can be verbose
type User = yup.InferType<typeof UserSchema>;

joi does not infer TypeScript types natively. You must define interfaces separately.

import Joi from "joi";

// Manual type definition required
interface User {
  id: number;
  name: string;
}

const UserSchema = Joi.object({
  id: Joi.number().required(),
  name: Joi.string().required()
});

superstruct focuses on validation first. TypeScript types are not inferred from the struct definition.

import { object, number, string } from "superstruct";

// Manual type definition required
interface User {
  id: number;
  name: string;
}

const UserStruct = object({
  id: number(),
  name: string()
});

📝 Schema Definition Syntax: Chainable vs Functional

How you write schemas affects readability and maintenance. Some libraries use a chainable object-oriented style, while others use functional composition.

joi uses a chainable API that reads like a sentence.

const schema = Joi.object({
  username: Joi.string().alphanum().min(3).max(30).required()
});

yup is very similar to joi, designed for familiarity.

const schema = yup.object({
  username: yup.string().min(3).max(30).required()
});

zod uses a functional chainable style that is concise.

const schema = z.object({
  username: z.string().min(3).max(30)
});

io-ts uses a functional composition style that can get verbose.

const User = t.type({
  username: t.string
});

runtypes uses a clean functional syntax.

const User = Record({
  username: String
});

superstruct uses a simple functional API.

const User = object({
  username: string()
});

⚠️ Error Handling: Detailed Messages vs Simple Throws

When validation fails, you need to know why. Some libraries provide detailed paths and error codes, while others just throw a generic error.

yup provides rich error messages and is highly customizable, which is why it dominates form validation.

try {
  await schema.validate(data);
} catch (err) {
  console.log(err.path); // "username"
  console.log(err.message); // "username is a required field"
}

joi offers the most detailed error output out of the box.

const { error } = schema.validate(data);
if (error) {
  console.log(error.details[0].message);
}

zod provides structured errors that are easy to parse for UI display.

const result = schema.safeParse(data);
if (!result.success) {
  console.log(result.error.issues); // Array of specific issues
}

io-ts returns a Left with errors using the Either pattern.

import { isLeft } from "fp-ts/Either";

const result = UserCodec.decode(data);
if (isLeft(result)) {
  console.log(PathReporter.report(result));
}

runtypes returns a result object indicating failure.

const result = User.validate(data);
if (!result.success) {
  console.log(result.message);
}

superstruct throws an error object with details.

try {
  assert(data, UserStruct);
} catch (err) {
  console.log(err.failures()); // Iterator of failures
}

🔄 Async Validation: Forms vs API Data

Some libraries handle asynchronous validation rules (like checking if a username is taken) better than others.

yup was built for this. It handles async validation natively.

const schema = yup.object({
  username: yup.string().test(
    "unique",
    "Username taken",
    async (value) => await checkAvailability(value)
  )
});

await schema.validate(data);

zod supports async refinement but it is secondary to its sync use case.

const schema = z.object({
  username: z.string().refine(
    async (value) => await checkAvailability(value),
    { message: "Username taken" }
  )
});

await schema.parseAsync(data);

joi supports async custom rules but it is heavier.

const schema = Joi.object({
  username: Joi.string().custom(async (value, helpers) => {
    const available = await checkAvailability(value);
    if (!available) return helpers.error("any.invalid");
    return value;
  })
});

io-ts, runtypes, and superstruct are primarily designed for synchronous decoding. While you can wrap them, they do not have built-in async primitives as a core feature.

// io-ts example: Sync decode primarily
const result = UserCodec.decode(data);
// Async logic must be handled outside the codec

🌐 Real-World Scenarios

Scenario 1: Validating API Responses

You fetch data from a backend and need to ensure it matches your TypeScript interfaces.

  • Best choice: zod or io-ts
  • Why? You want the type to be derived from the validator to prevent drift.
// zod
const ApiResponse = z.object({ data: z.array(UserSchema) });
type Response = z.infer<typeof ApiResponse>;

Scenario 2: Complex Form Validation

You are building a registration form with async checks and conditional fields.

  • Best choice: yup or zod
  • Why? They integrate best with form libraries like React Hook Form.
// yup
const schema = yup.object({
  password: yup.string().min(8).required()
});

Scenario 3: Lightweight Script or CLI

You need to validate config files in a Node script without heavy dependencies.

  • Best choice: superstruct
  • Why? It is small, simple, and does not require TypeScript setup.
// superstruct
assert(config, ConfigStruct);

Scenario 4: Functional Architecture

Your team uses fp-ts and wants to treat validation as part of the type system.

  • Best choice: io-ts
  • Why? It integrates perfectly with functional error handling patterns.
// io-ts
pipe(decode(data), fold(onError, onSuccess));

📊 Summary Table

Featurezodyupjoiio-tsruntypessuperstruct
TS Inference✅ Excellent⚠️ Good❌ None✅ Excellent✅ Excellent❌ None
Async Validation✅ Yes✅ Native✅ Yes❌ No❌ No❌ No
Bundle Size🟢 Small🟡 Medium🔴 Large🟡 Medium🟢 Small🟢 Small
Learning Curve🟢 Low🟢 Low🟢 Low🔴 High🟡 Medium🟢 Low
Primary UseGeneralFormsBackendFunctionalFunctionalSimple

💡 Final Recommendation

For most modern frontend teams, zod is the default choice. It offers the best balance of TypeScript inference, developer experience, and bundle size. It handles both API validation and form validation well enough for 90% of use cases.

Stick with yup if you are maintaining legacy forms or need deep integration with formik. Choose io-ts only if your team is already committed to functional programming patterns. Use superstruct for lightweight scripts where TypeScript inference is not a priority.

Avoid joi for frontend bundles due to size, and consider runtypes if you want a functional style without the fp-ts ecosystem complexity.

How to Choose: io-ts vs joi vs runtypes vs superstruct vs yup vs zod

  • io-ts:

    Choose io-ts if you are deeply invested in the functional programming ecosystem (specifically fp-ts) and need guaranteed type safety between runtime and compile time. It is ideal for teams comfortable with functional patterns like Either and Monad, but it has a steep learning curve for others. Avoid it for simple projects where the boilerplate outweighs the benefits.

  • joi:

    Choose joi if you are working in a Node.js backend environment where bundle size is not a concern and you need robust, battle-tested validation with highly customizable error messages. It is less suitable for frontend bundles due to its size and lack of native TypeScript inference. Consider modern alternatives if you need tight TypeScript integration.

  • runtypes:

    Choose runtypes if you want a functional style with strong TypeScript inference without the heavy boilerplate of io-ts. It is a good middle ground for teams that value type safety and immutability but find io-ts too complex. It works well for domain-driven design where types are central to the architecture.

  • superstruct:

    Choose superstruct if you need a lightweight, zero-dependency validator that is easy to read and write without heavy TypeScript magic. It is perfect for scripts, small tools, or projects where you want validation without tying your types to the validation logic. It sacrifices static type inference for simplicity and small bundle size.

  • yup:

    Choose yup if you are using formik or react-hook-form and need a validation library that handles async validation and form state out of the box. It has been the industry standard for React forms for years, though it is heavier than newer options. It is a safe choice for legacy projects or teams already familiar with its chainable API.

  • zod:

    Choose zod if you want the best balance of TypeScript inference, developer experience, and feature set for modern frontend applications. It is currently the most popular choice for new projects due to its seamless type inference and concise syntax. It is ideal for validating API responses, form data, and environment variables with minimal setup.

README for io-ts

build status npm downloads

Installation

To install the stable version

npm i io-ts fp-ts

Note. fp-ts is a peer dependency for io-ts

Usage

Stable features

Experimental modules (version 2.2+)

Experimental modules (*) are published in order to get early feedback from the community, see these tracking issues for further discussions and enhancements.

The experimental modules are independent and backward-incompatible with stable ones.

(*) A feature tagged as Experimental is in a high state of flux, you're at risk of it changing without notice.