Guide

Compiler Markers

The compile-time markers that tell RunTypes what to generate and inject.

A marker is a special type you place in your code, almost always as a trailing parameter, that the compiler reads at build time and acts on. You write the type, the build fills in the rest, then the marker is erased. Every createX factory works because of one, and the same markers are open for you to use in your own helpers.

What each marker does

Each marker triggers a different build-time behaviour: You almost certainly will not type most of these (the createX factories use them for you), but here is the whole cast, so nothing is a mystery.

Factory injection

id?: InjectTypeFnArgs<T, Fn>

Validates the call site's compile-time arguments, then injects the one generated function you asked for. Every createX already declares it; you never write it.

Reflection id

id?: InjectRunTypeId<T>

Injects a short, stable id for T at each call site. The one marker you are likely to type, for wrapping your own helpers.

Compile-time literals

options: CompTimeArgs<T>

Brands a parameter as "must be a literal at the call site" (or a const of literals). It is how options bags get read at build time. Static check only.

Variant selection

options: CompTimeFnArgs<T>

Like CompTimeArgs, but the literal also selects which variant of a factory you get, such as the JSON encoder strategy. Static check only.

Pure functions

fn: PureFunction<F>

Brands a pure function argument that passes the purity rules. The direct form: the argument is the helper itself. Used for pure functions.

Pure function factories

fn: PureFunctionFactory<F>

The factory form of the above, for a helper that needs one-time setup or composes another pure fn. Used for pure functions.

Anonymous pure-fn id

hash?: InjectPureFnHash<F>

Injects a content hash of a pure helper's body, so a library can wrap registerAnonymousPureFn. Used for pure functions.

Seven markers in total: one you will type, and six that quietly do their job.

None of them survive to runtime. The build reads each one, does its job, and emits ordinary JavaScript.

.

Arguments read at compile time

Some factory arguments are not ordinary runtime values, such as the ValidateOptions bag or the JSON encoder strategy. The build reads them to choose the exact specialized function it emits, so each one has to be a literal the build can see at compile time. You can write it inline at the call site, or factor it into a const, including one imported from another module. A value computed at runtime will not do, because the build cannot read it.

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

type Flag = {kind: 'on' | 'off'};

// These options are read by the BUILD, so they must be a literal written right
// at the call site — the build picks the specialized function from what it sees.
const isFlag = createValidateFn<Flag>(undefined, {noLiterals: true});
const encode = createJsonEncoderFn<Flag>(undefined, {strategy: 'direct'});

// A computed value is NOT a literal, so the build can't read it — this line
// fails compilation with a CTA diagnostic.
const looseAtNight = new Date().getHours() < 6;
createValidateFn<Flag>(undefined, {noLiterals: looseAtNight});

export {isFlag, encode};

That rule is a marker, too. CompTimeArgs<T> brands a parameter as "must be a literal here", and CompTimeFnArgs<T> does the same for the literal that selects a variant (like the strategy above). The factories already declare them, so you just pass a literal and the build rejects anything else at compile time.

When you factor an options bag (or any literal value) into a const, declare it as const. Without it, TypeScript widens {strategy: 'mutate'} to {strategy: string}, and the build rejects the widened value, so the type the compiler resolves and the value the build reads can never disagree. The same shared const then works whether it lives beside the call or in another module.

// shared-config.ts
export const strict = {noLiterals: true} as const; // `as const` keeps the values literal

// anywhere.ts
createValidateFn<User>(undefined, strict);            // ok: a shared const, even imported
createValidateFn<User>(undefined, {noLiterals: true}); // ok: inline literal

Inject an id into your own helpers

InjectRunTypeId<T> is the one marker you are likely to type yourself. Add a trailing id?: InjectRunTypeId<T> to a generic function and it opts in: the build injects the type's id at every call site, so you never pass it by hand.

import {getRunType, type InjectRunTypeId} from '@ts-runtypes/core';

// Wrap ts-runtypes into your OWN helper. Declare a trailing
// `id?: InjectRunTypeId<T>` parameter and the build fills it in at every
// call site — you never pass the id yourself.
function describe<T>(id?: InjectRunTypeId<T>): string {
  // The injected `id` is an OPAQUE handle, not a plain string. Resolve it by
  // forwarding it to a public resolver as the trailing argument: getRunType
  // registers T's type graph and returns the node (getRunTypeId returns the id
  // string). The build leaves this forwarded call untouched.
  const runType = getRunType<T>(undefined, id);
  return `type #${runType.id} (kind ${runType.kind})`;
}

// Call it like any generic function — no id argument in sight.
describe<{id: number; name: string}>();
describe<string[]>();

export {describe};

The call looks like any ordinary generic call. Inside the helper the injected id is an opaque handle, not a plain string, so forward it to getRunType or getRunTypeId (as the trailing argument) to register the type and read back its node or id string.

The marker only fires where T is concrete, at a real call site, not inside a generic body. To pass it through your own generic function, declare id?: InjectRunTypeId<T> and let the build fill it at each call site.

Wrapping your own helpers

The real payoff is composing the generated functions into higher-level helpers, say one call that parses JSON and validates it against your type in a single step.

import {createValidateFn, type ValidateFn} from '@ts-runtypes/core';

// A realistic wrapper: parse JSON and validate it against your type in one call.
// A createX<T>() factory needs a CONCRETE type at its OWN call site, so build
// the validator where the type is known and pass it in. Calling
// `createValidateFn<T>()` inside this generic body would use the wrapper's free
// `T` (unknown at build time), which the build reports as MKR003.
function parseChecked<T>(raw: string, isValid: ValidateFn<T>): T {
  const data: unknown = JSON.parse(raw);
  if (!isValid(data)) throw new Error('payload does not match the expected type');
  return data as T;
}

type User = {id: number; name: string};

// createValidateFn<User>() runs at a concrete call site — the build injects here.
const user = parseChecked('{"id":1,"name":"Ada"}', createValidateFn<User>());

export {parseChecked, user};

Build the validator where the type is concrete and pass it in. A createX<T>() factory injects at its own call site, so it cannot run inside a generic body where T is still abstract (the build reports that as MKR003).

The marker is the public extension point. Whenever you wonder "can RunTypes do X?", the answer is often "wrap it yourself". It is exactly how the built-in factories are built.

Asking for several functions at once

One InjectTypeFnArgs marker can name more than one function family. A route wrapper that validates a request, decodes it from JSON, and encodes the response asks for all three in a single marker:

import {createGetValidationErrorsFn, createJsonDecoderFn, createJsonEncoderFn, type InjectTypeFnArgs} from '@ts-runtypes/core';

// A single marker can ask for SEVERAL generated functions at once. A route
// wrapper wants to validate a request, decode it from JSON, and encode the
// response, so it names all three families in one trailing marker. The build
// injects an array of handles, one per family, in the order you listed them.
type Handler = (...args: any[]) => unknown;

function route<H extends Handler>(handler: H, fns?: InjectTypeFnArgs<Parameters<H>, 'verr', 'jsonDecoder', 'jsonEncoder'>) {
  const getErrors = createGetValidationErrorsFn(undefined, undefined, fns?.[0] as never);
  const decodeParams = createJsonDecoderFn(undefined, undefined, fns?.[1] as never);
  const encodeParams = createJsonEncoderFn(undefined, undefined, fns?.[2] as never);
  return {handler, getErrors, decodeParams, encodeParams};
}

// route() runs at a concrete call site, so the build injects the three handles
// for this handler's parameters here.
const greet = route((name: string, times: number) => name.repeat(times));

export {route, greet};

The build injects one handle per family, as an array in the order you listed them, and the wrapper forwards each element (fns?.[0], fns?.[1], fns?.[2]) to its factory. There is no fixed limit on how many families you list. Naming the same family twice is a mistake (the second handle would be identical and go unused), so the build reports it as MKR006. List each family at most once.

Recovering a function that has no factory

The string encoder and decoder are built on smaller value-level pieces, one prepareForJson and one restoreFromJson per strategy. Those pieces have no createX factory of their own, but you can still ask for them by name in a marker and recover the injected handle with getRTFunction. It is the generic counterpart of a createX factory: hand it the injected handle and it gives you back the callable function for your type.

import {getRTFunction, type InjectTypeFnArgs} from '@ts-runtypes/core';

// Some functions the generated code is built from, like the per-strategy
// prepareForJson and restoreFromJson, have no createX factory of their own. You
// still reach them from a marker: name the primitive and recover the injected
// handle with getRTFunction, which turns it into the callable function for T.
// You pass the same key you named in the marker, so getRTFunction knows the
// function's type.
//
// 'pjs' is the clone prepare (a fresh JSON-safe value with undeclared keys
// dropped) and 'rj' is the matching restore. A framework that owns its own JSON
// envelope uses this pair to transform values without a string round-trip.
function jsonValueCodec<T>(fns?: InjectTypeFnArgs<T, 'pjs', 'rj'>) {
  const prepare = getRTFunction<'pjs'>(fns?.[0]);
  const restore = getRTFunction<'rj'>(fns?.[1]);
  return {prepare, restore};
}

type Message = {id: bigint; sentAt: Date; body: string};

// A concrete call site: the build injects the clone-prepare and restore handles
// for Message here.
const messageCodec = jsonValueCodec<Message>();

// prepare turns a typed value into a JSON-safe one and restore turns it back.
// The caller owns the JSON.stringify and JSON.parse, so many values can share
// one envelope with a single stringify and a single parse.
const message: Message = {id: 42n, sentAt: new Date('2020-01-02T03:04:05.000Z'), body: 'hi'};
const wire = JSON.stringify(messageCodec.prepare(message));
const restored = messageCodec.restore(JSON.parse(wire));

export {jsonValueCodec, messageCodec, restored};

The keys mirror the encoder and decoder strategies: pjs and pj are the clone and mutate prepares, rj is the restore, cj and cjr are the compact encode and decode, and sj is the single-pass stringify. This is what a framework reaches for when it owns its own JSON envelope: parse one request body once and restore each value, prepare each value and stringify one response once, with no extra string round-trip in between.

Rebuilding a storage key from a type id

The two ways above hand you a function through an injected marker. A framework sometimes needs the other direction: it already knows a type's id (from getRunTypeId) and wants the storage key for one of that type's functions, without a call site to inject a handle. getFnHash gives you the function half of that key for a family, and you join it with the type id yourself.

import {getFnHash, getRunTypeId} from '@ts-runtypes/core';

// Every generated function is stored under a key with two parts: a short id for
// the FUNCTION (which family it belongs to, plus any compile-time options) and
// the id for the TYPE it works on, joined as `functionId_typeId`. A framework
// that already holds a type's id can rebuild that key itself with getFnHash. The
// ids getFnHash returns are stable across releases.

// The function id for the default validator.
const validateId = getFnHash('val');

// Options that change the generated function change its id too, and getFnHash
// follows them: a validator that skips literal checks is a different function.
const looseValidateId = getFnHash('val', {noLiterals: true});

// The JSON encoder has several strategies, and each one is its own function.
const encodeMutateId = getFnHash('jsonEncoder', {strategy: 'mutate'});

// Join the function id with a type id to get the full storage key. The type id
// is the same value getRunTypeId returns for that type.
type User = {id: number; name: string};
const userTypeId = getRunTypeId<User>();
const userValidatorKey = `${validateId}_${userTypeId}`;

export {validateId, looseValidateId, encodeMutateId, userValidatorKey};

The value it returns is stable across releases, so it stays valid when you upgrade. It also follows compile-time options, so getFnHash('val', {noLiterals: true}) and getFnHash('jsonEncoder', {strategy: 'mutate'}) return the ids for those specific variants, not just for the family.

Copyright © 2026