Guide

Types vs Type Builders

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

Side by side

Same shape, two spellings. isProductA and isProductB are identical validators under the hood.

// Option A: a plain TypeScript type. Fastest path, 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. Used this way
// the schema itself adds nothing to your bundle.
type ProductFromRunType = InferType<typeof productRunType>;

const isProductB = createValidateFn<ProductFromRunType>();

The pure type is the fast path. There's nothing to write but the type you already have. A builder returns a run-type, a real value you can store in a variable, pass to a function, or build up piece by piece.

Get the type back with InferType

A run-type is a value, but sometimes you want the TypeScript type it stands for. InferType<typeof runType> hands it back:

import * as TF from '@ts-runtypes/core/formats';
import {type InferType} from '@ts-runtypes/core';
import * as RT from '@ts-runtypes/core/builders';

// Build a run-type as a value you can pass around, store, or compose.
const address = RT.object({
  street: TF.string(),
  city: TF.string(),
  zip: TF.string(),
});

// InferType<typeof runType> hands you the TypeScript type back.
type Address = InferType<typeof address>;

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

export {address, home};
export type {Address};

So you never write a shape twice. Start from a type and reflect it, or start from a run-type and InferType your way to the type. Either direction gives you one source of truth.

This pattern also keeps your bundle small. A schema you only take the type of ships no runtime description at all: the build sees that the value is never used and generates just the functions you asked for. The moment you use the schema value itself (pass it to a create function, read its properties, or export it) the build keeps its runtime description too. So when other files only need the shape, export the type rather than the schema.

Mix them freely

They're the same thing, so you can use both in the same file: a plain type here, a builder there, even nesting one kind of thinking inside the other.

import * as TF from '@ts-runtypes/core/formats';
import {createValidateFn} from '@ts-runtypes/core';
import * as RT from '@ts-runtypes/core/builders';

// Mix both in one file: a pure type nested inside a run-type and back again.
type Money = {amount: number; currency: 'USD' | 'EUR'};

// A run-type that references the plain type via RT.* leaves.
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};

Either way you end up at the same validator, the same JSON codec, the same mock. Pick whichever reads better to you.

Labeled tuples and named parameters

TypeScript lets a tuple name its slots ([x: number, y: number]) and those names are part of the type. To author them with builders, wrap each element in RT.slot(name, runType):

import * as TF from '@ts-runtypes/core/formats';
import {createValidateFn, type InferType} from '@ts-runtypes/core';
import * as RT from '@ts-runtypes/core/builders';

// Wrap each element in RT.slot to name it. The result is the same type as the
// labeled tuple written by hand, so both spellings share one validator.
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. Each group is named, and you only
// write the groups you need. The rest slot carries its own name.
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[]]

// A tuple with a rest element but no optional elements just leaves out the
// 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, under the params group, and ret
// names the return type.
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};

A tuple names its three groups of elements, and you write only the groups you need: required for the elements that always appear, optional for the trailing ones that may be left out, and rest for a single element type that repeats. A function names its params and its ret. Naming the groups is what keeps a definition unambiguous, since a bare list of elements gives no hint about which of them are optional.

A labeled tuple built this way is the same type as its hand-written twin, so validators, ids and mocks all land on the same cache entry. Plain run-types without slots keep meaning unlabeled tuples and unnamed parameters. Slots go all in or not at all (TypeScript's own rule for tuple labels), and each group is a list, so the order you write is the order the tuple keeps.

Reuse shared fields

Run-types are plain values, so you can keep a set of shared fields in one place and spread it into each builder that needs them. The merge happens at build time, so every run-type still reflects its full, exact type (the same one you would get by writing every field out).

import {object, number, string, boolean} from '@ts-runtypes/core';

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

const User = object({...base, name: string()});      // {id, createdAt, name}
const Post = object({...base, published: boolean()}); // {id, createdAt, published}

The same works for shared option presets. A createValidateFn or createJsonEncoderFn call can spread a preset and override individual keys inline (a key written after the spread wins).

import {createJsonEncoderFn} from '@ts-runtypes/core';

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

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

The fragment you spread has to be a const (or written inline), and it can live in another module and be imported. A value produced at runtime, like the result of a function call, cannot be read at build time, so spreading one is reported as an error.

Copyright © 2026