Guide

Validation

Type guards and error reports generated from your type at build time.

Every createX<T>() factory turns a type into a function the build writes for you, with no schema object and no runtime reflection. This page covers validation: the type guards, the error reports and unknown-key handling. JSON round trip, mocking and reflection have their own pages.

Validators check serializable data.createValidateFn, createGetValidationErrorsFn and the JSON codecs work on DataOnly<T>, the JSON-shaped part of your value. Functions, methods, getters, symbols, promises, RegExp and built-ins with no JSON form (typed arrays, Blob) are skipped. So are platform classes. The build names each one in an Info message, shown with levels: 'all'. If the root type is not serializable, the generated function throws and the build reports a RuntimeError. Decoders also return DataOnly<T>, not the full type.

Three Ways to Call a Factory

Every factory takes your type three different ways, and all three resolve to the same generated function.

// 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. Run-type: pass the run-type an RT.* builder returned; T is inferred from it.
const pointRunType = RT.object({x: TF.number(), y: TF.number()});
const isPointC = createValidateFn(pointRunType);

Type Guards

createValidateFn<T>() gives you a type guard: pass a value, get a boolean.

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')[]
}

Types from Other Libraries

any and unknown count as data, so types from other libraries keep working. The validator accepts any value there, and a required property only has to be present. A root type of any or unknown adds an Info message, and an any from an unresolved name or import is a RuntimeError.

Platform Types

A class the platform declares is not data: it is skipped as a property and refused at the root, like a function. What counts as platform depends on your tsconfig types list:

{"compilerOptions": {"types": ["node"]}}
Where the class is declaredNot data?
The TypeScript lib (Headers, Blob, Error)Yes
A package in your types list (@types/node, bun-types), and the files it loadsYes
A package another package loads with /// <reference types> (@types/express loads @types/node)Yes
A declare module "x" block in a package from your types list (EventEmitter from events)Yes
Any @types package, when your types list is ["*"]Yes
A package your code imports, but your types list does not nameNo, it stays data
Your own filesNo, it stays data
Your own file that adds a member to a platform classNo, the class becomes yours
With no types list, only the TypeScript lib and what other packages load count, and a type that nothing loads fails the build with marker-any-from-unresolved-name. In the editor, DataOnly<T> still shows the shape of a platform class.

Validation Errors

Same checks, but you get an array of what failed instead of a boolean. Each entry is {path, expected}, and a format failure adds a format detail.

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'"},
// ]

Format Error Types

Some formats fail in more than one way, and set an errorType on the format detail naming the mode. The modes are stable strings, exported as union types from the formats subpath (CreditCardErrorType, EmailErrorType, DomainErrorType, IpErrorType).

const errors = getErrors(card);
switch (errors[0]?.format?.errorType) {
  case 'format': // not shaped like a card number at all
  case 'checksum': // right shape, the digits do not add up (a typo)
  case 'network': // a good card, just not one this field takes
}
FormatModesWhen
CreditCardformat, checksum, networknetwork only when the field sets networks
EmailAddress, IdnEmailformat, localPart, domain, addressLiteral, lengthwhich part of the address is wrong (format means no @ at all)
EmailPartsformat, localPartthe domain half reports under the domain format with its own modes
Hostname, IdnHostnamelabel, punycode, bidi, lengthwhich rule the name broke
DomainPartslabel, tldwhich label failed
IPv4WithPort, IPv6WithPort, IPWithPortaddress, portonly the port-accepting presets
Every format not in the table (StringUrl, UUIDv4, the plain Email and Domain presets, the number and date formats) has one failure mode and leaves errorType unset. Each of its constraints reports its own error, and the formatPath on it says which one. In every format, a bound on the whole value (like maxParts on DomainParts) also leaves errorType unset.

Typed Error Modes

The format field is typed from your type: the errors a validator returns list only the formats the type contains.

const signUpErrors = createGetValidationErrorsFn<SignUp>();

export function explain(input: unknown): string[] {
  const messages: string[] = [];
  for (const error of signUpErrors(input)) {
    // `format.name` narrows `errorType` to that format's own modes, so a typo
    // in a mode name is a compile error.
    switch (error.format?.name) {
      case 'email':
        messages.push(
          error.format.errorType === 'localPart'
            ? 'Check the part before the @'
            : 'Check the email address'
        );
        break;
      case 'creditCard':
        messages.push(
          error.format.errorType === 'checksum'
            ? 'Check the card digits'
            : 'That is not a card we take'
        );
        break;
      default:
        messages.push(`${error.path.join('.')}: expected ${error.expected}`);
    }
  }
  return messages;
}

Validation Options

Both validators take an options object as a second argument.

// accepts NaN and Infinity
const isFlagTypeofNumbers = createValidateFn<Flag>(undefined, {
  numberMode: 'typeof',
});
OptionWhat it does
numberModeChooses 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.
checkUnknownsRejects a property your type does not declare, anywhere in the value. See Checking for Unknown Keys.
checkUnionUnknownsRejects an undeclared property on a union member only. See Unions.
rejectCircularRefsRejects a value that contains a reference cycle. The errors form reports it as {expected: 'circular'}.
Pass the options as an object literal, or a const declared as const. They are read at build time, so a value made at runtime is reported as an error.

A Project-Wide Number Mode

Set numberMode once under a validate object in the plugin (or the tsconfig mion plugin entry) and every validator uses it.

runtypes({validate: {numberMode: 'typeof'}})
A per-call numberMode wins over the project default for that validator, so numberMode: 'isFinite' at a call opts it back out.

Checking for Unknown Keys

Unknown keys are properties your declared type does not have. Pass checkUnknowns to check the type and the extra keys in a single pass.

import {createGetValidationErrorsFn, createValidateFn} from '@mionjs/run-types';

type Address = {street: string; city: string};
type User = {id: number; name: string; address: Address};

// one function, one pass: matches User and carries no extra property, at every level
const isUserStrict = createValidateFn<User>(undefined, {checkUnknowns: true});

isUserStrict({id: 1, name: 'Ada', address: {street: 'Main', city: 'Rome'}}); // true
isUserStrict({
  id: 1,
  name: 'Ada',
  address: {street: 'Main', city: 'Rome'},
  admin: true,
}); // false
isUserStrict({
  id: 1,
  name: 'Ada',
  address: {street: 'Main', city: 'Rome', zip: '00184'},
}); // false, the extra is nested

// same option on the error report: one entry per undeclared key, `expected: 'never'`
const userErrors = createGetValidationErrorsFn<User>(undefined, {
  checkUnknowns: true,
});

userErrors({
  id: 1,
  name: 'Ada',
  address: {street: 'Main', city: 'Rome', zip: '00184'},
});
// [{path: ['address', 'zip'], expected: 'never'}]

// a union needs its own option: its members share one list of property names
type Pet = {kind: 'cat'; meows: boolean} | {kind: 'dog'; barks: number};
type Something = {a: string} | Record<string, number>;

const isPetUnionStrict = createValidateFn<Pet>(undefined, {
  checkUnionUnknowns: true,
});
isPetUnionStrict({kind: 'cat', meows: true}); // true
isPetUnionStrict({kind: 'cat', meows: true, barks: 3}); // false, barks belongs to Dog

const isSomething = createValidateFn<Something>(undefined, {
  checkUnionUnknowns: true,
});
isSomething({a: 'x'}); // true
isSomething({p: 1, q: 2}); // true, it is a record
isSomething({a: 'x', evil: 'garbage'}); // false, it matches neither member

export {isUserStrict, userErrors, isPetUnionStrict, isSomething};
Errors come out in the order the value is walked, so a nested object's errors arrive together rather than grouped by kind.

Unions

A union needs its own option: its members share one list of property names, and a member with an index signature declares every property. checkUnionUnknowns checks the property list of the member that actually matched. It does nothing outside a union, so pair it with a decoder that already dropped the rest.

type Pet = {kind: 'cat'; meows: boolean} | {kind: 'dog'; barks: number};
// {kind: 'cat', meows: true, barks: 3}  ->  rejected, barks belongs to Dog, not to Cat

type Something = {a: string} | Record<string, number>;
// {a: 'x', evil: 'garbage'}  ->  rejected, it matches neither member

type Loose = {a: string} | Record<string, string>;
// {a: 'x', evil: 'garbage'}  ->  accepted, every value is a string
The check runs only when the union has two or more members that carry properties by name, so {a: string} | number answers as plain validate does. Set both options and checkUnknowns wins.

Removing Unknown Keys

checkUnknowns rejects extra keys, createRemoveUnknownKeysFn drops them. The copy keeps every member your type declares, or the build fails.

import {createRemoveUnknownKeysFn, createValidateFn} from '@mionjs/run-types';

type User = {id: number; name: string};

// never changes the input, so frozen inputs work
const removeUnknownKeys = createRemoveUnknownKeysFn<User>();

const dirty = {id: 1, name: 'Ada', admin: true, token: 'secret'};
const clean = removeUnknownKeys(dirty as User); // {id: 1, name: 'Ada'}, new object

// validate untrusted data first, then drop its extra keys
const isUser = createValidateFn<User>();
export function parseUser(data: unknown): User {
  if (!isUser(data)) throw new Error('not a User');
  return removeUnknownKeys(data);
}

// onClick is shared with the input: 'share' hides the warning, 'refuse' makes it a build error
type Button = {label: string; onClick: () => void};
const removeButtonKeys = createRemoveUnknownKeysFn<Button>(undefined, {
  sharedValues: 'share',
});

export {removeUnknownKeys, clean, removeButtonKeys};
Declared memberOn the copy
Data, Dates, Maps and SetsA new copy
Class instanceA new object with the same prototype. The constructor does not run.
Class method or getterStill works, from the prototype
Function, Promise, RegExp or a built-in like Int8ArrayThe same value as the input, with a warning
Symbol-keyed property, or a class with #private fieldsBuild error, the function always throws

Standard Schema Support

createStandardSchema<T>() wraps the validators above in a Standard Schema object that tRPC, TanStack Form and Router, Hono, React Hook Form and many more read directly, with no per-library adapter. It takes the same three call forms as every other factory and the same ValidateOptions.

// a Standard Schema v1 object: a single `~standard` property
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']}]}
validate is synchronous and never throws on invalid input; it reports through issues. The result is typed as DataOnly<T>, the serializable projection covered above.
Copyright © 2026