Guide

Types vs Type Builders

Describe a shape as a TypeScript type or with builders; both compile to the same thing.

Types vs Type Builders

Same shape, two spellings: isProductA and isProductB are identical validators. A builder returns a run-type, a value you can store, pass to a function or build up piece by piece.

// Option A: a plain TypeScript type, nothing extra to write.
type Product = {
  id: number;
  name: string;
  tags: string[];
  status: 'draft' | 'live';
};

const isProductA = createValidateFn<Product>();
// Option B: the RT.* builders, if you like the Zod / TypeBox feel.
const productRunType = RT.object({
  id: TF.number(),
  name: TF.string(),
  tags: RT.array(TF.string()),
  status: RT.union([RT.literal('draft'), RT.literal('live')]),
});

// Recover the TypeScript type, then generate from the type.
type ProductFromRunType = InferType<typeof productRunType>;

const isProductB = createValidateFn<ProductFromRunType>();

Inferring the Type from a Builder

InferType<typeof runType> gives you back the TypeScript type of a run-type. Reflect a type or infer one from a run-type: either way you never write a shape twice.

import * as TF from '@mionjs/run-types/formats';
import {type InferType} from '@mionjs/run-types';
import * as RT from '@mionjs/run-types/builders';

const address = RT.object({
  street: TF.string(),
  city: TF.string(),
  zip: TF.string(),
});

type Address = InferType<typeof address>;

// a normal type, use it anywhere
const home: Address = {
  street: '1 Infinite Loop',
  city: 'Cupertino',
  zip: '95014',
};

export {address, home};
export type {Address};
When other files only need the shape, export the type rather than the schema: a schema you only take the type of adds nothing to your bundle. Pass it to a create function, read its properties or export it, and its runtime description ships too.

Mixing Types and Builders

Use both in one file. Either way you get the same validator, JSON codec and mock.

import * as TF from '@mionjs/run-types/formats';
import {createValidateFn} from '@mionjs/run-types';
import * as RT from '@mionjs/run-types/builders';

type Money = {amount: number; currency: 'USD' | 'EUR'};

// the Money shape again, written with builders
const invoice = RT.object({
  id: TF.string(),
  lines: RT.array(
    RT.object({
      sku: TF.string(),
      total: RT.object({
        amount: TF.number(),
        currency: RT.union([RT.literal('USD'), RT.literal('EUR')]),
      }),
    })
  ),
});

const isMoney = createValidateFn<Money>();
const isInvoice = createValidateFn(invoice);

export {isMoney, isInvoice};

Labeled Tuples and Named Parameters

A tuple label like [x: number, y: number] is part of the type. To write one with builders, wrap each element in RT.slot(name, runType).

import * as TF from '@mionjs/run-types/formats';
import {createValidateFn, type InferType} from '@mionjs/run-types';
import * as RT from '@mionjs/run-types/builders';

// the same type as the labeled tuple written by hand
const pointRunType = RT.tuple({
  required: [RT.slot('x', TF.number()), RT.slot('y', TF.number())],
});
type Point = InferType<typeof pointRunType>; // [x: number, y: number]

const isPoint = createValidateFn(pointRunType);
const samePoint = createValidateFn<[x: number, y: number]>(); // same cached validator

// optional and rest elements take slots too; write only the groups you need
const rowRunType = RT.tuple({
  required: [RT.slot('id', TF.number())],
  optional: [RT.slot('note', TF.string())],
  rest: RT.slot('tags', TF.string()),
});
type Row = InferType<typeof rowRunType>; // [id: number, note?: string, ...tags: string[]]

// no optional elements, so no optional group
const logRunType = RT.tuple({
  required: [RT.slot('level', TF.string())],
  rest: RT.slot('lines', TF.string()),
});
type Log = InferType<typeof logRunType>; // [level: string, ...lines: string[]]

// RT.func names its parameters the same way
const handlerRunType = RT.func({
  params: [RT.slot('event', TF.string()), RT.slot('retries', TF.number())],
  ret: RT.boolean(),
});
type Handler = InferType<typeof handlerRunType>; // (event: string, retries: number) => boolean

export {isPoint, samePoint, rowRunType, logRunType, handlerRunType};
export type {Point, Row, Log, Handler};
BuilderGroupHolds
RT.tuplerequiredThe elements that always appear
RT.tupleoptionalThe trailing elements that may be left out
RT.tuplerestA single element type that repeats
RT.funcparamsThe parameters
RT.funcretThe return type
Use slots on every element or on none, as TypeScript requires for tuple labels. Without slots you get unlabeled tuples and unnamed parameters.

Recursive Shapes

A shape can contain itself, like a comment with replies. Wrap the body in RT.circular and point back at it with RT.self().

import * as TF from '@mionjs/run-types/formats';
import {createValidateFn, type InferType} from '@mionjs/run-types';
import * as RT from '@mionjs/run-types/builders';

const commentRunType = RT.circular(
  RT.object({
    text: TF.string(),
    replies: RT.array(RT.self()), // RT.self() stands for the whole shape
  })
);
type Comment = InferType<typeof commentRunType>; // {text: string; replies: Comment[]}

const isComment = createValidateFn(commentRunType);

// the same shape written as a type
type CommentType = {text: string; replies: CommentType[]};
const isCommentType = createValidateFn<CommentType>(); // same cached validator

export {commentRunType, isComment, isCommentType};
export type {Comment, CommentType};
LimitWhat happens
RT.self() more than 24 levels deep in the bodyThe inferred type keeps RT.self() instead of your shape
The shape points back straight from a tuple slot, like [number, Pair]Builders cannot write it. Put the loop behind an object, array, Map, Set or function, or write the shape as a type
A value nested deeper than the call stackValidation throws RangeError
For two shapes that point at each other, write RT.self() for the shape itself and name the other one by its const.

Reusing Shared Fields Across Builders

Keep shared fields in one object and spread it into each builder that needs them. The spread is read at build time, so every run-type keeps its full, exact type.

import * as RT from '@mionjs/run-types/builders';
import * as TF from '@mionjs/run-types/formats';

const base = {id: TF.number(), createdAt: TF.number()};

const User = RT.object({...base, name: TF.string()});      // {id, createdAt, name}
const Post = RT.object({...base, published: RT.boolean()}); // {id, createdAt, published}
Spread a const, written inline or imported from another module. Spreading a value made at runtime, like a function call's result, is reported as an error.

Reusing Option Presets

A createValidateFn or createJsonEncoderFn call can spread a preset of options and override keys inline. A key written after the spread wins.

import {createJsonEncoderFn} from '@mionjs/run-types';

const preset = {strategy: 'mutate'} as const;

const encodePost = createJsonEncoderFn<Post>(undefined, {...preset});
Copyright © 2026