Validation
Every createX<T>() factory turns a type into a purpose-built function the build writes out for you, with no schema object and no runtime reflection. They all share one calling convention and one contract, so once you've seen one you've seen them all.
This page covers validation: the type guards, the error reports, and unknown-key handling. (Serialization, mocking and reflection get their own pages.)
createValidateFn, createGetValidationErrorsFn, and the JSON and binary codecs all work on the data-only projection of your type, DataOnly<T>: the JSON-shaped data your value carries. Non-serializable members (functions, methods, getters, symbols) are silently skipped, since they never survive a round trip through JSON or the wire anyway. A value that is not serializable at a root position has nothing to validate, so the build reports it. This is why a validator can flag a type as non-serializable, and why a decoder returns DataOnly<T> rather than the full type.Three ways to call any factory
Every factory takes your type three different ways. They all resolve to the same generated function, so use whichever fits the call site.
// 1. Type-first — you supply the type, no value needed.
const isPointA = createValidateFn<Point>();
// 2. Value-first — T is inferred from a value you already have.
const origin: Point = {x: 0, y: 0};
const isPointB = createValidateFn(origin);
// 3. Schema-first — pass an RT.* schema; T is inferred from the schema.
const pointSchema = RT.object({x: TF.number(), y: TF.number()});
const isPointC = createValidateFn(pointSchema);
- Type-first: you supply
<T>, no value needed. - Value-first:
Tis inferred from a value you already have. - Schema-first: pass an
RT.*schema;Tcomes from the schema.
The rest of these guide pages use whichever form reads best for the example, but every one of them accepts all three.
A fast yes/no with createValidateFn
createValidateFn<T>() gives you a type guard: pass a value, get a boolean, and TypeScript narrows the value inside the if.
// createValidateFn -> a type guard. Fast yes/no.
const isUser = createValidateFn<User>();
isUser({id: 1, name: 'Ada', roles: ['admin']}); // true
isUser({id: '1', name: 'Ada', roles: ['admin']}); // false — id is not a number
// It narrows too: inside the `if`, `data` is typed.
function handle(data: unknown) {
if (isUser(data)) data.roles; // ('admin' | 'user')[]
}
The full story with createGetValidationErrorsFn
Same checks, but instead of a boolean you get an array of what failed. Each entry is {path, expected}: where it broke and what was expected. An empty array means valid; format failures add a format detail.
// createGetValidationErrorsFn -> the same checks, but it tells you what broke.
const userErrors = createGetValidationErrorsFn<User>();
userErrors({id: 1, name: 'Ada', roles: ['admin']}); // [] — all good
userErrors({id: '1', name: 42, roles: ['boss']});
// [
// {path: ['id'], expected: 'number'},
// {path: ['name'], expected: 'string'},
// {path: ['roles', 0], expected: "'admin' | 'user'"},
// ]
Tuning with ValidateOptions
Both validators take an options object as a literal at the call site. The build reads the literal and routes you to a specialized variant, so nothing is parsed at runtime.
// Pass an OBJECT LITERAL of options. The build reads the literal and routes
// the call to a specialized variant of the validator — nothing is read at runtime.
// noLiterals: a literal check degrades to its base type
// (here 'on' | 'off' becomes "any string").
const isFlagLoose = createValidateFn<Flag>(undefined, {noLiterals: true});
// noIsArrayCheck: skip the leading Array.isArray() guard on array validators
// (handy when you've already proven the value is an array upstream).
const isFlagFast = createValidateFn<Flag>(undefined, {noIsArrayCheck: true});
// numberMode: choose how a `number` is checked, to line up with another
// library when migrating. 'typeof' accepts NaN and Infinity (like ajv, typia,
// and JSON Schema); the default 'isFinite' rejects them; 'notNaN' rejects NaN
// but keeps Infinity.
const isFlagTypeofNumbers = createValidateFn<Flag>(undefined, {numberMode: 'typeof'});
| Option | What it does |
|---|---|
noLiterals | Literal checks degrade to their base type, so literal 'a' accepts any string and literal 2 accepts any finite number. |
noIsArrayCheck | Skips the leading Array.isArray() guard on array validators, for when you've already proven the value is an array upstream. |
numberMode | Chooses how a number is checked. isFinite (the default) rejects NaN and Infinity; typeof accepts them (matching ajv, typia, and JSON Schema); notNaN rejects NaN but keeps Infinity. |
A project-wide default for numberMode
numberMode is handy when migrating onto RunTypes from a library that accepts NaN and Infinity, so you usually want the same behaviour everywhere rather than per call. Set it once under a validate object in the plugin (or the tsconfig ts-runtypes plugin entry) and every validator picks it up:
runtypes({validate: {numberMode: 'typeof'}})
A per-call numberMode still wins over the project default for that one validator, and setting numberMode: 'isFinite' at a call opts it back out.
Unknown keys
Three tools for properties that aren't in your declared type. These check for extra keys. To check that the declared keys hold the right types, that's validation above.
| Factory | What it does |
|---|---|
createHasUnknownKeysFn | Predicate that returns true if the value carries any undeclared key. Takes a compile-time {runsAfterValidation: true} option for a much faster variant when the value already passed validate. |
createCloneExactShapeFn | A proper deep clone of the declared shape — undeclared keys are dropped by construction, the input is never mutated, and clone(x) !== x for every object-typed value (a class keeps its prototype; only primitives and opaque handles pass through). |
createUnknownKeyErrorsFn | One {path, expected: 'never'} per undeclared key, the same shape as createGetValidationErrorsFn. |
import {createHasUnknownKeysFn} from '@ts-runtypes/core';
type User = {id: number; name: string};
// createHasUnknownKeysFn -> true if the value carries any key the type didn't declare.
const hasExtra = createHasUnknownKeysFn<User>();
hasExtra({id: 1, name: 'Ada'}); // false
hasExtra({id: 1, name: 'Ada', admin: true}); // true — `admin` isn't in User
export {hasExtra};
After validate, opt the predicate into the key-count fast path:
import {createHasUnknownKeysFn, createValidateFn} from '@ts-runtypes/core';
type User = {id: number; name: string};
// The compile-time `runsAfterValidation` option declares a precondition: every
// value passed to this predicate has already PASSED validate for the same
// type. The emitter then swaps the key-array scan for a key-count compare on
// all-required shapes (~3x on small objects, ~44x at 30 props) and drops the
// per-object typeof guards. Calling it on non-validated input is undefined
// behavior — keep it behind a validate like the strict guard below.
const isUser = createValidateFn<User>();
const hasExtraFast = createHasUnknownKeysFn<User>(undefined, {runsAfterValidation: true});
export function isUserStrict(data: unknown): data is User {
return isUser(data) && !hasExtraFast(data);
}
isUserStrict({id: 1, name: 'Ada'}); // true
isUserStrict({id: 1, name: 'Ada', admin: true}); // false — `admin` isn't in User
To remove the extras, clone to the exact declared shape (this replaced the old mutating createStripUnknownKeys / createUnknownKeysToUndefined — the clone is 3–24x faster and never triggers V8's delete deopt), or report them as errors:
import {createCloneExactShapeFn, createValidateFn} from '@ts-runtypes/core';
type User = {id: number; name: string};
// createCloneExactShapeFn -> a NEW value of exactly the declared shape.
// Undeclared keys are dropped by construction (the clone is built FROM the
// type, never `{...v}`); the input is never mutated — frozen inputs work.
const cloneUser = createCloneExactShapeFn<User>();
const dirty = {id: 1, name: 'Ada', admin: true, token: 'secret'};
const clean = cloneUser(dirty as User); // {id: 1, name: 'Ada'} — fresh object
// `dirty` still has admin/token; `clean` never did.
// The intended pipeline: validate untrusted data, then clone to the exact
// declared shape so nothing undeclared flows downstream.
const isUser = createValidateFn<User>();
export function parseUser(data: unknown): User {
if (!isUser(data)) throw new Error('not a User');
return cloneUser(data);
}
export {cloneUser};
import {createUnknownKeyErrorsFn} from '@ts-runtypes/core';
type User = {id: number; name: string};
// createUnknownKeyErrorsFn -> one {path, expected: 'never'} entry per undeclared key.
const unknownKeyErrors = createUnknownKeyErrorsFn<User>();
unknownKeyErrors({id: 1, name: 'Ada'}); // []
unknownKeyErrors({id: 1, name: 'Ada', admin: true});
// [{path: ['admin'], expected: 'never'}]
export {unknownKeyErrors};
Standard Schema
createStandardSchema<T>() wraps the validators above into a Standard Schema object: the shared ~standard contract that tRPC, TanStack Form and Router, Hono, React Hook Form and many more accept directly. One call and your type works anywhere the spec is understood, with no per-library adapter to write.
// createStandardSchema -> a Standard Schema v1 object: a single `~standard`
// property that tRPC, TanStack Form/Router, Hono and others accept directly.
const userSchema = createStandardSchema<User>();
// Valid input comes back under `value`.
userSchema['~standard'].validate({id: 1, name: 'Ada', roles: ['admin']});
// {value: {id: 1, name: 'Ada', roles: ['admin']}}
// Invalid input comes back as a flat list of issues, each with a message + path.
userSchema['~standard'].validate({id: '1', name: 'Ada', roles: ['admin']});
// {issues: [{message: 'Expected number', path: ['id']}]}
The object exposes a single validate(value). It returns {value} when the value is valid, or {issues} when it is not, where each issue carries a message and a path locating the field that failed. Under the hood it runs the fast type guard first and only builds the issue list on a failure, so the valid path stays cheap.
It takes the same three call forms as every other factory (type-first, value-first, schema-first) and the same ValidateOptions.
validate is synchronous and never throws on invalid input. Instead it reports through issues. The result is typed as DataOnly<T> (the serializable projection covered above), so what a consumer reads back matches what the validator actually checks.