Types vs Schemas
Side by side
Same shape, two spellings. Both 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 productSchema = 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 from the schema whenever you need it.
type ProductFromSchema = InferType<typeof productSchema>;
const isProductB = createValidateFn(productSchema);
The pure type is the fast path. There's nothing to write but the type you already have. The schema is 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 schema is a value, but sometimes you want the TypeScript type it stands for. InferType<typeof schema> 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/schema';
// Build a schema 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 schema> 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 schema and InferType your way to the type. Either direction gives you one source of truth.
Mix them freely
They're the same thing, so you can use both in the same file: a plain type here, a schema 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/schema';
// Mix both in one file — a pure type nested inside a schema and back again.
type Money = {amount: number; currency: 'USD' | 'EUR'};
// A schema 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.
Reuse shared fields
Schemas are plain values, so you can keep a set of shared fields in one place and spread it into each schema that needs them. The merge happens at build time, so every schema 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.