These libraries provide tools for defining data schemas and validating data at runtime, bridging the gap between static TypeScript types and dynamic JavaScript values. While TypeScript ensures type safety during development (compile-time), these packages ensure that data received from APIs, forms, or databases matches expected structures when the code actually runs. They differ significantly in syntax style, TypeScript integration depth, error handling capabilities, and bundle footprint. Some focus on developer experience with concise syntax, others on functional purity, and some on legacy compatibility or specific ecosystem integration.
In modern frontend development, TypeScript handles type safety while you write code, but it disappears once your code is compiled to JavaScript. This leaves a gap: how do you ensure the data coming from an API, a user form, or a local storage item actually matches your expected types? This is where runtime validation libraries step in. They act as a security guard at the door, checking data before it enters your application logic.
We are comparing seven major players: arktype, io-ts, joi, runtypes, superstruct, yup, and zod. While they all solve the same core problem, their approaches to syntax, type inference, and error handling vary wildly. Let's dive into how they handle real-world scenarios.
The first thing you notice is how you define what your data should look like. Some libraries feel like writing TypeScript; others feel like writing configuration files.
zod uses a chainable, object-based API that feels very natural to JavaScript developers. It reads almost like English.
import { z } from 'zod';
const UserSchema = z.object({
id: z.number(),
name: z.string(),
email: z.string().email(),
role: z.enum(['admin', 'user'])
});
yup is very similar to zod but uses a slightly different chaining style, historically popular in the React form ecosystem.
import * as yup from 'yup';
const UserSchema = yup.object({
id: yup.number().required(),
name: yup.string().required(),
email: yup.string().email().required(),
role: yup.string().oneOf(['admin', 'user']).required()
});
runtypes aims to mirror TypeScript syntax as closely as possible. If you know TS, you basically know runtypes.
import { Record, Number, String, Union } from 'runtypes';
const UserSchema = Record({
id: Number,
name: String,
email: String,
role: Union('admin', 'user')
});
superstruct focuses on simplicity and uses a clean, functional definition style.
import { object, number, string, enums } from 'superstruct';
const UserSchema = object({
id: number(),
name: string(),
email: string(),
role: enums(['admin', 'user'])
});
arktype takes a completely different approach. It uses a string-based DSL (Domain Specific Language) to define types, which results in extremely concise code.
import { type } from 'arktype';
// Defines the same schema in a single string
const UserSchema = type({
id: 'number',
name: 'string',
email: 'string.email',
role: '"admin" | "user"'
});
io-ts leans heavily into functional programming. You compose types using functions, which can feel verbose but offers immense power for complex compositions.
import * as t from 'io-ts';
const UserSchema = t.type({
id: t.number,
name: t.string,
email: t.string,
role: t.union([t.literal('admin'), t.literal('user')])
});
joi uses a fluent interface that is very expressive but exists primarily in the JavaScript world without native TS type inference.
import Joi from 'joi';
const UserSchema = Joi.object({
id: Joi.number().required(),
name: Joi.string().required(),
email: Joi.string().email().required(),
role: Joi.string().valid('admin', 'user').required()
});
Defining the schema is only half the battle. You need to parse incoming data and handle cases where the data is wrong. The way these libraries report errors can make or break your debugging experience.
zod provides a safeParse method that returns an object indicating success or failure. The error object is structured and easy to traverse.
const result = UserSchema.safeParse({ id: 'not-a-number', name: 'Alice' });
if (!result.success) {
// result.error.issues contains a detailed list of problems
console.log(result.error.issues[0].message);
// Output: "Expected number, received string"
}
yup uses an async validate method that throws an error if validation fails. You typically catch this error to get messages.
try {
await UserSchema.validate({ id: 'not-a-number', name: 'Alice' });
} catch (err) {
// err.message contains the first error found
console.log(err.message);
}
superstruct also throws on failure but provides a structured error object with path information.
import { assert } from 'superstruct';
try {
assert({ id: 'not-a-number', name: 'Alice' }, UserSchema);
} catch (err) {
// err.failures() returns an iterable of failure details
console.log(Array.from(err.failures())[0].message);
}
runtypes uses a check or validate method. If it fails, it throws a specific ValidationError.
try {
UserSchema.check({ id: 'not-a-number', name: 'Alice' });
} catch (err) {
// err.message describes the failure
console.log(err.message);
}
arktype returns a result object similar to zod but optimized for performance. It distinguishes between successful data and error details clearly.
const result = UserSchema({ id: 'not-a-number', name: 'Alice' });
if (result.incomplete) {
// result.missing or result.typeErrors provide details
console.log(result.typeErrors[0].message);
}
io-ts returns an Either type (a functional pattern). You must handle both the "Left" (error) and "Right" (success) cases explicitly.
import { decode } from 'io-ts';
import { isLeft } from 'fp-ts/Either';
const result = decode(UserSchema, { id: 'not-a-number', name: 'Alice' });
if (isLeft(result)) {
// result.left contains the validation errors
console.log(result.left[0].message);
}
joi uses an async validate method that returns an object with an error property if things go wrong.
const { error, value } = UserSchema.validate({ id: 'not-a-number', name: 'Alice' });
if (error) {
// error.message gives the description
console.log(error.message);
}
This is the most critical differentiator for TypeScript developers. Some libraries automatically generate TypeScript types from your schema, while others require you to define types twice.
zod, runtypes, and arktype excel here. You define the schema once, and the library infers the TypeScript type automatically.
// Zod Example
const UserSchema = z.object({ name: z.string() });
type User = z.infer<typeof UserSchema>; // Type is { name: string }
// Runtypes Example
const UserSchema = Record({ name: String });
type User = Static<typeof UserSchema>; // Type is { name: string }
// Arktype Example
const UserSchema = type({ name: 'string' });
type User = typeof UserSchema.infer; // Type is { name: string }
superstruct requires a slightly more manual approach using generics to extract the type.
const UserSchema = object({ name: string() });
type User = Infer<typeof UserSchema>; // Requires importing Infer helper
io-ts requires you to use the TypeOf utility from the library to extract the static type.
const UserSchema = t.type({ name: t.string });
type User = t.TypeOf<typeof UserSchema>;
yup can infer types, but it often requires explicit generic arguments or helper functions to work reliably in complex scenarios.
const UserSchema = yup.object({ name: yup.string() });
type User = yup.InferType<typeof UserSchema>;
joi does not infer TypeScript types natively. You must define your interface separately and hope it stays in sync with your schema, or use third-party bridges.
// Manual definition required - risk of drift!
interface User {
name: string;
}
// Schema is defined separately with no automatic link
const UserSchema = Joi.object({ name: Joi.string() });
Real-world data is messy. You often need to validate logic that goes beyond simple types, like ensuring a password is strong or handling different shapes of data based on a "type" field.
zod handles refinements (custom logic) and discriminated unions very elegantly.
// Refinement: Password must be > 8 chars
const PasswordSchema = z.string().refine(val => val.length > 8, 'Too short');
// Discriminated Union: Cat vs Dog
const PetSchema = z.discriminatedUnion('kind', [
z.object({ kind: z.literal('cat'), meow: z.boolean() }),
z.object({ kind: z.literal('dog'), bark: z.boolean() })
]);
yup supports custom tests for refinements but can get verbose with complex unions.
// Refinement
const PasswordSchema = yup.string().test('len', 'Too short', val => val.length > 8);
// Union (less type-safe)
const PetSchema = yup.mixed().oneOf([catSchema, dogSchema]);
superstruct uses the refine function and union for these cases.
// Refinement
const PasswordSchema = refine(string(), 'password', s => s.length > 8);
// Union
const PetSchema = union([CatSchema, DogSchema]);
io-ts uses combinators like refinement and taggedUnion, which are powerful but verbose.
// Refinement
const PasswordSchema = t.refinement(t.string, 'Password', s => s.length > 8);
// Tagged Union
const PetSchema = t.taggedUnion('kind', [CatSchema, DogSchema]);
runtypes uses .withConstraint for refinements and Union for variants.
// Refinement
const PasswordSchema = String.withConstraint(s => s.length > 8 || 'Too short');
// Union
const PetSchema = Union(CatSchema, DogSchema);
arktype handles logic directly within its string syntax or via methods.
// Refinement in syntax
const PasswordSchema = type('string > 8');
// Union
const PetSchema = type({ kind: '"cat"', meow: 'boolean' }).or({ kind: '"dog"', bark: 'boolean' });
joi uses .custom for logic and .alternatives for unions.
// Refinement
const PasswordSchema = Joi.string().custom((val, helpers) => {
if (val.length <= 8) return helpers.error('any.invalid');
return val;
});
// Alternatives
const PetSchema = Joi.alternatives().try(CatSchema, DogSchema);
It is crucial to note the maintenance status of these libraries before investing time.
joi: While still widely used in legacy Node.js backends, it is NOT recommended for new frontend TypeScript projects. It lacks native type inference, which defeats the purpose of using TypeScript. Its bundle size is also significantly larger than modern alternatives.yup: Still maintained and the king of form validation (especially with Formik), but for general-purpose API validation, zod has largely superseded it due to better TS inference and smaller size.io-ts: Actively maintained but niche. Only choose this if you are all-in on functional programming.arktype, zod, runtypes, superstruct: All actively maintained and excellent choices for modern development.For most professional frontend teams starting a new project today, zod is the default winner. It offers the best balance of developer happiness, powerful TypeScript inference, and ecosystem support. It just works.
If you are building highly performance-critical applications where bundle size is the #1 constraint, look closely at arktype. Its string syntax is weird at first, but the speed gains are real.
If you are deep in the React forms world, yup remains a strong contender specifically for form validation, though zod is catching up fast there too with adapters.
Avoid joi for new TypeScript work unless you are forced to by legacy constraints. The lack of automatic type inference creates too much friction and risk of bugs in a strictly typed environment.
Choose the tool that fits your team's mental model, but remember: the goal is to catch errors early, not to fight with your validation library.
Choose arktype if you need the absolute smallest bundle size and fastest validation speed without sacrificing TypeScript inference. It uses a unique string-based syntax that is incredibly concise, making it ideal for high-performance applications or environments where every kilobyte counts. However, be aware that its syntax is non-standard and may require a learning curve for teams used to object-based schemas.
Choose io-ts if your team heavily relies on functional programming patterns and the fp-ts ecosystem. It treats validators as first-class functional combinators, offering rigorous type safety and composability. It is best suited for complex domains where mathematical correctness and immutability are priorities, though it often requires more boilerplate and a deeper understanding of functional concepts than other options.
Choose joi primarily for maintaining legacy Node.js backends or if you specifically need its powerful, fluent API for complex nested object validation outside of TypeScript. Note that joi is generally NOT recommended for new frontend TypeScript projects because it relies on runtime JavaScript logic that does not automatically infer static TypeScript types, requiring extra tools like @hapi/joi-to-typescript to bridge the gap.
Choose runtypes if you value readability and want your validation logic to look exactly like TypeScript type definitions. It uses a very intuitive, object-like syntax that maps directly to TS types, making it easy for developers to read and write. It is a solid middle-ground choice for teams that want strong typing without adopting functional programming paradigms or learning a new DSL.
Choose superstruct if you need a lightweight, zero-dependency library that focuses on simplicity and custom error messages. It strikes a balance between feature richness and ease of use, making it great for applications that need robust validation but don't require the heavy type-level magic of zod or io-ts. Its API is straightforward and predictable, ideal for standard CRUD applications.
Choose yup if you are building forms with formik or react-hook-form, as it has deep, first-class integration with these libraries. It offers a fluent, chainable API that is easy to read and write, making it a favorite for form validation scenarios. While it supports TypeScript, its type inference is generally considered less powerful and robust compared to zod or runtypes for general-purpose data parsing.
Choose zod as the default modern standard for most TypeScript projects requiring schema validation. It offers the best balance of developer experience, powerful TypeScript inference, and a rich ecosystem of plugins. Its API is intuitive, its error messages are helpful, and it handles complex scenarios like discriminated unions and refinements effortlessly. It is the safest bet for new projects unless you have specific constraints requiring another tool.
ArkType is a runtime validation library that parses optimized validators from familiar, type-safe syntax.
It can be used to check external data like JSON payloads or forms at the boundaries of your code (similar to Zod).
See our docs site
We accept and encourage pull requests from outside ArkType. Planned work is tracked in this GitHub project.
Depending on your level of familiarity with type systems and TS generics, some parts of the codebase may be hard to jump into. That said, there's plenty of opportunities for more straightforward contributions. We'd generally recommend starting with one of these issues labeled external-contributor-friendly.
If you're planning on submitting a non-trivial fix or a new feature, please create an issue first so everyone's on the same page. The last thing we want is for you to spend time on a submission we're unable to merge.
When you're ready, check out our guide to get started!
This project is licensed under the terms of the MIT license.
We will not tolerate any form of disrespect toward members of our community. Please refer to our Code of Conduct and reach out to david@arktype.io immediately if you've seen or experienced an interaction that may violate these standards.
We've been working full-time on this project for multiple years and it means a lot to have the community behind us.
If the project has been useful to you and you are in a financial position to do so, please chip in via GitHub Sponsors.
Otherwise, consider sending me an email (david@arktype.io) or message me on Discord to let me know you're a fan of ArkType. Either would make my day!
| mintlify | get-convex | inspatiallabs | sam-goodwin |
|---|---|---|---|
|
|
|
|
|
| tmm | mewhhaha | jahands | drwpwrs | Phalangers |
|---|---|---|---|---|
|
|
|
|
|
|
| WilliamConnatser | JameEnder | tylim88 | ||
|
|
|
|