Pure Functions
registerFormatPattern
Got a string shape you reuse, like a slug, a SKU, or an internal id? Register the regex once and reference it from any number of TF.String types.
import type * as TF from '@ts-runtypes/core/formats';
import {createValidateFn, registerFormatPattern} from '@ts-runtypes/core';
// Register a reusable string pattern once. `mockSamples` are required —
// they double as canonical values the mock generator draws from, and each
// is checked against the regex at registration (a bad sample throws loudly).
const slug = registerFormatPattern({
source: '^[a-z0-9]+(?:-[a-z0-9]+)*$',
mockSamples: ['my-post', 'hello-world-2'],
message: 'must be a kebab-case slug',
});
// Reference it by `typeof` in a TF.String. Build-time validation + mocks
// both pick it up.
type Slug = TF.String<{pattern: typeof slug}>;
type Post = {slug: Slug; title: string};
const isPost = createValidateFn<Post>();
isPost({slug: 'my-first-post', title: 'Hi'}); // true
isPost({slug: 'Not A Slug!', title: 'Hi'}); // false
export {slug, isPost};
export type {Slug, Post};
mockSamples are required and do double duty: the mock generator draws from them, and each one is checked against the regex at registration. A sample that doesn't match throws immediately, so a broken pattern fails loud and early.
registerMockingFunction
By default the mock generator invents something valid for each kind. Want mocked values to read a particular way? Register a mock fn for a RunTypeKind: return a value to override, or undefined to fall back to the default.
import {registerMockingFunction, RunTypeKind, type FormatAnnotation} from '@ts-runtypes/core';
// Want mock data to look a certain way for a kind? Register a mock fn for
// that ReflectionKind. Return `undefined` to fall back to the default mock.
// Here: make every mocked string format spit out a friendlier value.
registerMockingFunction(RunTypeKind.string, (annotation: FormatAnnotation) => {
if (annotation.name === 'email') return 'someone@example.com';
return undefined; // defer to the built-in mock for everything else
});
// From now on createMockDataFn<T>() uses this when it mocks a string format.
// (createMockDataFn itself is covered in the Mocking guide.)
export {};
You get the format annotation (its name and params), so you can branch: friendly emails for email, something else for the rest.
Pure functions
Register a pure helper with registerPureFnFactory("namespace::name", factory). The single "namespace::name" id keeps the helper easy to find, the factory returns the real function, and the build inlines it into the generated code.
import {registerPureFnFactory} from '@ts-runtypes/core';
// Pure functions are tiny, self-contained helpers the build can inline into
// the generated (JIT) code. The factory returns the real function; it must
// be self-contained — no outer-scope captures, no `this`, no await/yield.
export const slugify = registerPureFnFactory('app::slugify', function () {
// Anything declared INSIDE the factory is fine — it ships with the helper.
const NON_WORD = /[^a-z0-9]+/g;
return function _slugify(input: string): string {
return input.toLowerCase().replace(NON_WORD, '-').replace(/^-|-$/g, '');
};
});
The factory exists so the helper can do one-time setup (a compiled regex, a lookup table) or compose another pure fn. When the helper needs neither, the factory is just ceremony, so there is a direct twin: registerPureFn("namespace::name", fn) takes the function itself and the compiler wraps it for you.
import {registerPureFn} from '@ts-runtypes/core';
// The direct form is the ergonomic twin of registerPureFnFactory: you pass the
// pure function itself, no factory wrapper, and the compiler wraps it for you.
// Reach for it when the helper needs no one-time setup and composes no siblings.
// The same "namespace::name" id keeps it easy to find and reference by name.
export const double = registerPureFn('app::double', (input: number): number => input * 2);
The rules are strict because the helper gets lifted out of its surroundings: it must be self-contained, with no outer-scope captures, no this, no await/yield, and no dynamic import. Anything it needs goes inside the function (like the regex above). Break a rule and you get a build-time diagnostic.
Standard built-ins are the exception, so you rarely need to reach outside in the first place. Alongside Math and JSON you get the binary toolbox (ArrayBuffer, DataView, the typed arrays like Uint8Array, plus TextEncoder, TextDecoder, btoa, and atob), which is enough to port a hash, a binary codec, or a text-encoding routine straight into a factory. What stays off limits is I/O and anything async, because the helper is inlined into synchronous code: no fetch or network, no timers, no reaching into the host (process, globalThis), and nothing that must be awaited. That last point is why a promise-returning API like crypto.subtle.digest does not fit (implement the hash over the typed arrays instead), though plain synchronous calls like crypto.randomUUID are fine.
The helper also has to be written inline. The compiler lifts it out and compiles its own copy, and that copy is the only one allowed to run. So the literal must have no name anything else could reach: not imported, not exported, and not even pulled from a named local const or function. Write the function right where you register it.
// Rejected: a name something else could reach
import {validate} from './validators'; // imported
export const validate = (v) => /* … */ true; // exported
const validate = (v) => /* … */ true; // a named local binding
registerValidator(validate);
// Fine: inline at the call site
registerValidator((v: unknown) => typeof v === 'string');
Need to reuse one pure helper from another? Don't reference it by name. Look it up through the factory's utilities by its registered id, and the build tracks the dependency for you.
Pure functions from a library
The named registrars want the "namespace::name" id written as a literal, which is perfect when you own the call site. It does mean a library cannot wrap them: the moment a wrapper builds the id from a user-supplied name, that id is no longer a literal and the build rejects it.
registerAnonymousPureFn is the wrappable twin. You pass the pure function itself and nothing else. The identity comes from a content hash of the function body, which the compiler fills in for you at build time, so two identical helpers always resolve to the same entry (register the same helper in two places and you get one shared copy).
import {registerAnonymousPureFn} from '@ts-runtypes/core';
// The anonymous lane takes the pure function itself; the compiler wraps it into
// the zero-arg factory the runtime stores. It derives a stable identity from the
// function body and injects it for you, so there is no "namespace::name" literal
// to write. The same rules apply: the helper must be self-contained, with
// everything it needs declared inside the function.
export const compiledDouble = registerAnonymousPureFn((n: number): number => n * 2);
It mirrors the named lane's two forms. registerAnonymousPureFn takes the function directly, which is the shape a single-callback API like serverMapFrom(t => t.id) wants. When the helper needs one-time setup or wants to compose another pure fn, reach for registerAnonymousPureFnFactory and pass a factory instead.
import {registerAnonymousPureFnFactory} from '@ts-runtypes/core';
// The factory twin of the anonymous lane. Use it when the helper needs one-time
// setup (a compiled regex, a lookup table) or wants to compose another pure fn
// through the factory utilities. You pass the factory; the compiler still
// derives and injects a stable identity from the factory body, so it stays
// wrappable and content addressed just like the direct form.
export const compiledSlug = registerAnonymousPureFnFactory(function () {
const NON_WORD = /[^a-z0-9]+/g;
return function _slug(input: string): string {
return input.toLowerCase().replace(NON_WORD, '-').replace(/^-|-$/g, '');
};
});
Because the identity rides an injected marker in the signature, the primitive flows through a wrapper. A framework can expose its own ergonomic registerXPureFn and every call site of that wrapper gets the same treatment, with no diagnostics and no configuration.
import {registerAnonymousPureFn, type PureFunction, type InjectPureFnHash} from '@ts-runtypes/core';
// A library wraps the anonymous lane behind its own register API. The two
// markers ride the signature (the argument carries PureFunction, the trailing
// slot carries InjectPureFnHash), so the compiler injects the content hash at
// every call site of the wrapper, wherever it is used, with no diagnostics.
export function registerAcmePureFn<F extends (...args: any[]) => any>(fn: PureFunction<F>, hash?: InjectPureFnHash<F>) {
if (!hash) throw new Error('ts-runtypes plugin did not run');
return registerAnonymousPureFn(fn, hash);
}
// A consumer of the library calls the wrapper with just the pure function.
export const compiledUpper = registerAcmePureFn((s: string): string => s.toUpperCase());
Looking one up later is the other half. The tracked lookups (usePureFn and friends) want a literal id so the build can follow the dependency. When the id only exists at runtime, for example a hash that arrives in a request, reach for the untracked accessor instead:
import {getRTUtils} from '@ts-runtypes/core';
// `bodyHash` came in over the wire, so it is a plain runtime string.
const fn = getRTUtils().getPureFnByKey(bodyHash);
if (getRTUtils().hasPureFnByKey(bodyHash)) {
// dispatch on it
}
getPureFnByKey and hasPureFnByKey take a plain string and are deliberately not build-tracked. Use them for framework dispatch on a runtime id, and keep usePureFn for the literal references you want the build to check.
Shipping compiled functions across bundles
A validator or encoder that the build generates in one bundle can run in another. A server generates the functions for its types, sends them to a browser client, and the client rebuilds and runs them without ever running the compiler itself. mion's router does exactly this on the way from server to client.
The pieces are all public on @ts-runtypes/core. On the sending side you serialize the closure-free form of an entry, its CompiledFnData, whose code field carries the function body as text. On the receiving side you restore the factory from that text with buildFactoryFromCode, write it back into the runtime cache with addToRTCache, then look it up by its hash and call it.
import {getRTUtils, buildFactoryFromCode, type CompiledFnData, type CompiledTypeFn} from '@ts-runtypes/core';
// `wire` arrived over the network as plain data.
function ingest(wire: CompiledFnData) {
const restored: CompiledTypeFn = {...wire, createRTFn: buildFactoryFromCode(wire.code!)};
getRTUtils().addToRTCache(restored);
}
// Later, on the client, look one up by its hash and call it.
const isUser = getRTUtils().getRTFn(rtFnHash);
Pure functions travel the same way, with buildPureFnFactoryFromCode and addPureFn in place of the two type-function helpers. What ts-runtypes does not decide for you is which entries a given payload depends on, or how you version the envelope you send them in. Those stay your framework's call.
Overriding compiler output
Sometimes you know a better function than anything the compiler can generate. A hand-tuned JSON encoder for a payload you send on every reply, a wire format that is not the plain shape of your type, or a quick workaround for a one-off bug. The overrideX helpers let you register your own pure function for one type, and every matching createX call returns it instead of the generated body.
import {overrideJsonEncoder, createJsonEncoderFn} from '@ts-runtypes/core';
// Declared once, near the type. The function must be pure, same rules as above.
overrideJsonEncoder<User>((user) => `{"id":${user.id}}`);
// Anywhere else, nothing changes at the call site:
const encode = createJsonEncoderFn<User>();
There is one override twin for every factory (overrideValidate, overrideJsonEncoder, overrideBinaryEncoder, and so on). Your function has to match the signature the family uses internally, so a validation-errors override receives the value, the path, and the errors list, exactly like the generated one.
An override applies to its type everywhere the type appears. If you override the encoder for a field type, every object that contains that field picks it up too. You can register exactly one override per type and function, so a second one (with any body) is a build error. Overriding validation is worth a second thought, because decoders use it to tell union branches apart, so a looser validator makes those decoders looser as well. The build warns you when that happens.
One thing to keep in mind: an override is matched by the shape of the type, not its name. So overrideValidate<string> applies to every string in the build, including string fields inside other objects. That is often what you want for a wire format, but when you mean one specific type, give it a brand so it has its own shape:
type UserId = string & {readonly __brand: 'UserId'};
// Only UserId, not every string.
overrideValidate<UserId>((v) => typeof v === 'string' && (v as string).startsWith('u_'));