Types vs Type Builders
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};
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};
| Builder | Group | Holds |
|---|---|---|
RT.tuple | required | The elements that always appear |
RT.tuple | optional | The trailing elements that may be left out |
RT.tuple | rest | A single element type that repeats |
RT.func | params | The parameters |
RT.func | ret | The return type |
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};
| Limit | What happens |
|---|---|
RT.self() more than 24 levels deep in the body | The 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 stack | Validation throws RangeError |
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}
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});