The evolution of validation libraries
Why stop at validation?
Your validator already knows the exact shape of your data. RunTypes turns that same knowledge into validation, serialization, mocking and reflection, straight from your TypeScript types.
One Type, Multiple functionality.
The whole toolbelt, in one box
Stop gluing many libraries together. RunTypes shares a single type graph across everything it generates, so the validator and the serializer always agree on what your type means.
// One real-world type, the single source of truth for everything below.
type Order = {
id: TF.UUIDv4;
customer: {name: string; email: TF.Email};
items: {sku: string; qty: number; price: number}[];
total: number;
placedAt: Date;
status: 'pending' | 'paid' | 'shipped';
};
Validation
The job you hire a validator for, with nothing to write: your TypeScript type is the schema. At build time RunTypes compiles it into a plain standalone check, which is how it matches the fastest JIT validators with zero runtime compilation. See the benchmarks →
The same type is also a Standard Schema, the shared ~standard contract that tRPC, TanStack Form and Router, Hono and many more accept directly. One call, no adapter to write.
Type guard and error list
const isOrder = createValidateFn<Order>();
isOrder(order); // true
const orderErrors = createGetValidationErrorsFn<Order>();
orderErrors({...order, total: 'free'}); // [{path: ['total'], expected: 'number'}]
Standard Schema
const orderSchema = createStandardSchema<Order>();
// a Standard Schema v1 object: hand it to any tool that speaks the spec
orderSchema['~standard'].validate(order); // {value: order}
orderSchema['~standard'].validate({}); // {issues: [{message, path}, …]}
JSON roundtrip
Your type compiles into two functions, one that serializes to JSON and one that restores it, so Date, bigint, Temporal and almost any other type that describes data survive the round trip.
Json roundtrip transparently enables RPC function calls in JavaScript, and free devs from hand writing coerce and transform logic.
RunTypes
type Session = {
user: string;
expiresAt: Date;
roles: Set<string>;
};
const toJson = createJsonEncoderFn<Session>();
const fromJson = createJsonDecoderFn<Session>();
const wire = toJson({user: 'ada', expiresAt: new Date(), roles: new Set(['admin'])})!;
const back = fromJson(wire);
const expiresAt: Date = back.expiresAt; // a real Date again
const roles: Set<string> = back.roles; // a real Set again, nothing to revive
Zod (hand maintained coercion and transform)
const sessionSchema = z.object({
user: z.string(),
// manually revive the Date
expiresAt: z.coerce.date(),
// manual Set revive
roles: z.array(z.string()).transform((a) => new Set(a)),
});
// encoding is also yours: JSON.stringify writes a Set as {}
const toJson = (session: Session) =>
JSON.stringify({...session, roles: [...session.roles]});
const wire = toJson({user: 'ada', expiresAt: new Date(), roles: new Set(['admin'])});
const back = sessionSchema.parse(JSON.parse(wire));
// nothing checks toJson and sessionSchema agree:
// Devs must manually keep the two directions in sync
Mocking that conforms to your types
const mockOrder = createMockDataFn<Order>();
const fake = mockOrder(); // a valid, randomized Order for your tests
Binary serialization
const toBytes = createBinaryEncoderFn<Order>();
const fromBytes = createBinaryDecoderFn<Order>();
const bytes = toBytes(order); // a Uint8Array: the compact wire, smaller than JSON
const order2 = fromBytes(bytes); // back to a typed object
Two ways to describe a shape, One source of truth.
We support native TypeScript types (fastest, zero ceremony) or the RT.* type builders if you like the Zod / TypeBox feel. Both compile to the exact same validator, so pick whichever you fancy and mix them in the same file. And when you only take the type back out of a builder, the schema itself adds nothing to your bundle.
import type * as TF from '@ts-runtypes/core/formats';
import {createValidateFn} from '@ts-runtypes/core';
// Your TypeScript type is the single source of truth. Nothing else to write.
type User = {
id: number;
name: string;
email: TF.Email;
roles: ('admin' | 'user')[];
};
// A specialized validator, generated from the type at build time.
const isUser = createValidateFn<User>();
isUser({id: 1, name: 'Ada', email: 'ada@example.com', roles: ['admin']}); // true
isUser({id: '1', name: 'Ada'}); // false
import * as TF from '@ts-runtypes/core/formats';
import {createValidateFn, type InferType} from '@ts-runtypes/core';
import * as RT from '@ts-runtypes/core/builders';
// Prefer builders? Describe the same shape with the RT.* type builders (Zod / TypeBox style).
const userRunType = RT.object({
id: TF.number(),
name: TF.string(),
email: TF.email(),
roles: RT.array(RT.union([RT.literal('admin'), RT.literal('user')])),
});
// Recover the TypeScript type, then generate from the type. Same validator,
// same result. Used this way the builder itself adds zero generated data.
type User = InferType<typeof userRunType>;
const isUser = createValidateFn<User>();
isUser({id: 1, name: 'Ada', email: 'ada@example.com', roles: ['admin']}); // true
Formats baked into your types
TypeFormats
Ensure type safety with formats like:email, uuidv4, ipv4, int32, positive and more.
The validator checks its exact shape, not just its kind. No regex to wire up, no separate schema to keep in sync.
Temporal Support
Full TC39 Temporal (PlainDate, ZonedDateTime, Duration and the rest), validated and serialized like any built-in.
import type * as TF from '@ts-runtypes/core/formats';
import {createValidateFn} from '@ts-runtypes/core';
// A format brands a string or number. The validator checks its exact
// shape, not just "is it a string".
type Account = {
id: TF.UUIDv4;
email: TF.Email;
ip: TF.IPv4;
logins: TF.PositiveInt;
};
const isAccount = createValidateFn<Account>();
isAccount({id: 'nope', email: 'ada@x.com', ip: '10.0.0.1', logins: 3}); // false, id isn't a uuid
import * as TF from '@ts-runtypes/core/formats';
import {createValidateFn, type InferType} from '@ts-runtypes/core';
import * as RT from '@ts-runtypes/core/builders';
// The same formats through the RT.* type builders.
const account = RT.object({
id: TF.uuidv4(),
email: TF.email(),
ip: TF.ipv4(),
logins: TF.positiveInt(),
});
// Recover the TypeScript type from the run-type.
type Account = InferType<typeof account>;
const isAccount = createValidateFn(account);
The reflection TypeScript never shipped
import {getRunType, RunTypeKind} from '@ts-runtypes/core';
// One real type, the single source of truth.
type Order = {
id: string;
total: number;
items: {sku: string; qty: number}[];
};
// Recover the actual RunType node, the traversable type graph TypeScript erased.
const orderRT = getRunType<Order>();
// Walk it like any tree: its kind, property names, nested children…
console.log(orderRT.kind === RunTypeKind.objectLiteral); // true
console.log(orderRT.children?.map((prop) => prop.name)); // ['id', 'total', 'items']
Recover the type graph
Get back a traversable RunType node, the same graph the library walks internally: kind, property names, nested children, format annotations and more. Bring a type or infer it from a runtime value, then read it however you need, to drive codegen, build forms, or power your own tooling.
// Already have a value? Pass it straight in and its type is inferred.
const order: Order = {id: 'A-1', total: 42, items: [{sku: 'WIDGET', qty: 2}]};
// getRunType(order) returns the very same node as getRunType<Order>().
const orderRT = getRunType(order);
console.log(orderRT.children?.map((prop) => prop.name)); // ['id', 'total', 'items']
Infer types from a values
You don't have to write the type out. Hand getRunType any value and it reflects that value's static type, so getRunType(order) returns the same node as getRunType<Order>(). Reach for it when you already hold the data and just want its shape.
High performance compiled code
Every library says they are the fastest, we back it up!
Our performance matches and surpass the fastest validators (AJV, TypeBox, Typia) and we have the most comprehensive benchmark suite to back it up.
Tested to the highest standard
Every transform, cache shape and generated function is covered, on top of an extensive structured suite spanning validation, JSON, binary, mocks and reflection.
Compile time code generation
Ship only what you use
Generated code is demand-driven and every entry is its own module, so bundlers split and tree-shake natively. A file that only reflects an id ships zero validation code, and the Vite plugin adds zero runtime dependencies.
type Order = {
id: string;
name: number;
email: string;
};
const isUser = createValidateFn<User>();
import {__rt_a1b_Xk7} from './__runtypes/types/a1b_Xk7.js';
type Order = {
id: string;
name: number;
email: string;
};
const isUser = createValidateFn<User>(__rt_a1b_Xk7);
// shown as a function for clarity, the real emit is a positional
// tuple: faster to initialise, fewer bytes on the wire
export function __rt_a1b_Xk7(value) {
return typeof value === "object" && value !== null &&
typeof value.id === "number" &&
typeof value.name === "string" &&
typeof value.email === "string";
}
AI Agents meets Deterministic
The compiler writes the code, your agent fills the blanks
Some values the compiler can't invent: a clear field label, a friendly error message, a believable sample name. So it does the hard part: it scaffolds a real, type-accurate source file, your agent fills in the blanks, and the compiler keeps it all in sync.
1The compiler scaffolds
From your type it writes a real source file, every field in place, correctly typed, with each blank marked.
2The AI agent fills the gaps
Guided by the type, the agent writes the labels, messages and sample values into the blanks.
3The compiler checks & keeps in sync
It checks every value against the type and updates the file as your type changes, keeping your edits.
import type * as TF from '@ts-runtypes/core/formats';
// models/user.ts
export interface User {
name: TF.String<{ minLength: 2; maxLength: 60 }>;
age: TF.Number<{ min: 0; max: 120 }>;
email: TF.Email;
}
import type { FriendlyText } from '@ts-runtypes/core';
import type { User } from './user';
// scaffolded by `enrich`: every field in place, each blank marked @todo
export const userFriendly: FriendlyText<User> = {
rt$label: '', // @todo
rt$errors: { type: '' }, // @todo
name: {
rt$label: '', // @todo
rt$errors: {
type: '', // @todo
minLength: '', // @todo
maxLength: '', // @todo
},
},
age: {
rt$label: '', // @todo
rt$errors: {
type: '', // @todo
min: '', // @todo
max: '', // @todo
},
},
email: { rt$label: '', rt$errors: { type: '', pattern: '' } }, // @todo
};
import type { FriendlyText } from '@ts-runtypes/core';
import type { User } from './user';
export const userFriendly: FriendlyText<User> = {
rt$label: 'User account',
rt$errors: { type: '' },
name: {
rt$label: 'Full name',
rt$errors: {
type: '$[label] must be a valid name',
minLength: '$[label] needs at least $[val] characters',
maxLength: '$[label] allows at most $[val] characters',
},
},
age: {
rt$label: 'Age',
rt$errors: {
type: '$[label] must be a number',
min: '$[label] must be at least $[val]',
max: '$[label] must be no more than $[val]',
},
},
email: { rt$label: 'Email', rt$errors: { type: '', pattern: 'Enter a valid email address' } },
};
import type * as TF from '@ts-runtypes/core/formats';
// models/user.ts
export interface User {
name: TF.String<{ minLength: 2; maxLength: 60 }>;
age: TF.Number<{ min: 0; max: 120 }>;
email: TF.Email;
}
import type { MockData } from '@ts-runtypes/core';
import type { User } from './user';
// scaffolded by `enrich`: one entry per field, each blank marked @todo
export const userMock: MockData<User> = {
name: { pool: [] }, // @todo believable names
age: { min: 0, max: 0 }, // @todo realistic range
email: { pool: [] }, // @todo real-looking addresses
};
import type { MockData } from '@ts-runtypes/core';
import type { User } from './user';
export const userMock: MockData<User> = {
name: { pool: ['Alice Martin', 'Liang Wei', 'Fatima Noor', /* …50+ */] },
age: { min: 18, max: 95 },
email: { pool: ['alice@example.com', 'liang@corp.io', /* … */] },
};