Guide

Serialization

JSON and binary codecs generated from your type, so Date, BigInt, Map and Set survive the round-trip.

JSON encode / decode

createJsonEncoder / createJsonDecoder understand the types JSON.stringify can't. Because the codec is generated from your type, it knows startedAt is a Date and flags is a Map. So Date, BigInt, Map and Set all survive the round-trip.

const encode = createJsonEncoder<Session>();
const decode = createJsonDecoder<Session>();

const wire = encode(session); // a JSON string — Date and Map survive
const back = decode(wire); // Date is a Date again, Map is a Map again

back.startedAt instanceof Date; // true
back.flags instanceof Map; // true

Here's the same data through plain JSON.stringify, for contrast:

// Plain JSON.stringify can't do this — your Date turns into a string and
// your Map turns into {} on the way out, and never comes back.
JSON.stringify(session); // {"id":"s-1","startedAt":"2026-01-01T...","flags":{}}

Three encoder strategies

createJsonEncoder takes a strategy (a call-site literal) that decides how it walks your value and what it does with undeclared keys.

// 'clone' (default) — builds a fresh value from the declared shape, so
// undeclared keys are dropped for free. Never touches your input.
const encodeClean = createJsonEncoder<Profile>({strategy: 'clone'});

// 'mutate' — transforms leaves in place (no clone), and KEEPS undeclared keys
// on the wire. Fastest, but it mutates the object you pass in.
const encodeFast = createJsonEncoder<Profile>({strategy: 'mutate'});

// 'direct' — single pass, no clone, always strips undeclared keys.
const encodeDirect = createJsonEncoder<Profile>({strategy: 'direct'});

Each one compiles to a different specialized function. The JSON that comes out is the same, but the work behind it is very different:

StrategyUnder the hoodMutates input?Undeclared keys
clone (default)Builds a fresh, JSON-safe value from the declared shape, then encodes that. It's safe and never touches your input, but cloning every object costs a little more memory.NoDropped (for free)
mutateRewrites non-serializable leaves (Date, BigInt, …) in place to make the value JSON-ready, then encodes it. Way faster with zero allocation, at the cost of mutating the object you pass in.YesKept on the wire
directA purpose-built JSON.stringify: one pass that iterates every field and writes valid JSON straight out. No clone, so it's lighter on memory than clone.NoDropped
clone is the safe default. It never touches your input and drops stray keys by building a fresh value from the type. Reach for mutate only in a hot path where the allocation shows up in a profile, and you're fine mutating the object you pass in.

Binary encode / decode

createBinaryEncoder / createBinaryDecoder are the same idea, smaller and faster on the wire. The encoder returns an ArrayBuffer; the decoder takes one back. Like JSON, the codec knows your type, so a Date round-trips cleanly.

const encode = createBinaryEncoder<Telemetry>();
const decode = createBinaryDecoder<Telemetry>();

const bytes = encode(sample); // an ArrayBuffer — compact, no field names on the wire
const back = decode(bytes); // typed as DataOnly<Telemetry>

back.recordedAt instanceof Date; // true — Date round-trips, like JSON

Reach for binary when you're moving lots of records and both ends are yours (WebSockets, game state, IoT). Stick with JSON when a human or a third party reads the payload, or you need it curl-able.

Reuse the buffer in hot loops

By default each encode allocates a fresh backing buffer. In a tight loop that adds up, so build a DataViewSerializer once with createDataViewSerializer and hand it to the encoder to reuse the same buffer. createDataViewDeserializer does the same on the way back.

// In a hot loop, build the serializer once and hand it to the encoder so it
// reuses the same backing buffer instead of allocating a fresh one each call.
const ser = createDataViewSerializer('ticks');
const ticks: Tick[] = [
  {symbol: 'TS', price: 7},
  {symbol: 'GO', price: 9},
];

const buffers = ticks.map((tick) => encode(tick, ser)); // writes into the shared serializer

// Same idea on the way back — reuse a deserializer for a known buffer.
const des = createDataViewDeserializer('ticks', buffers[0]);
const firstTick = decode(des);
You only need these helpers for buffer reuse in performance-sensitive code. For everyday encode/decode, the plain createBinaryEncoder / createBinaryDecoder calls handle their own buffers.

Custom class serializers

Plain objects are handled for you, and the built-in classes (Date, Map, Set, RegExp) round-trip for free. Your own class, though? The build can't know how to serialize it, so register a serialize / deserialize pair with registerClassSerializer, keyed by the class name.

import {registerClassSerializer, createJsonEncoder, createJsonDecoder} from 'ts-runtypes';

// Your own class. ts-runtypes can't guess how to put it on the wire, so
// you teach it once: a serialize/deserialize pair keyed by the class name.
class Money {
  constructor(
    public amount: number,
    public currency: string
  ) {}
}

registerClassSerializer<Money>('Money', {
  // instance -> JSON-ready data (the pipeline stringifies this for you)
  serialize: (m) => `${m.amount} ${m.currency}`,
  // parsed data -> rebuilt instance
  deserialize: (data) => {
    const [amount, currency] = String(data).split(' ');
    return new Money(Number(amount), currency);
  },
});

type Invoice = {id: string; total: Money};

const encode = createJsonEncoder<Invoice>();
const decode = createJsonDecoder<Invoice>();

const json = encode({id: 'inv_1', total: new Money(4999, 'USD')});
const back = decode(json); // back.total is a real Money instance again

export {Money, encode, decode, back};
export type {Invoice};

serialize turns an instance into JSON-ready data; deserialize rebuilds it from the parsed value after the round-trip. Register before anything serializes the class.

This only affects JSON and binary. createValidate always checks a class by its structural shape and never routes through the serializer. Without a registered serializer, the build falls back to the structural shape and emits a CLS001 Warning pointing you here.

Circular references

The generated encoders are fast precisely because they don't track which objects they've already seen. A runtime value that points back at itself (a.next = a) makes them recurse until the stack overflows. Both createJsonEncoder and createBinaryEncoder accept a per-call rejectCircularRefs option that guards that encoder. Instead of recursing, it throws a CircularReferenceError carrying the path to the cycle, the same idea as JSON.stringify's own cycle error.

// Arm the guard for THIS encoder only. The encoder throws a
// `CircularReferenceError` instead of recursing forever.
const encode = createJsonEncoder<Node>(undefined, {rejectCircularRefs: true});

try {
  encode(cyclic as Node);
} catch (err) {
  err instanceof CircularReferenceError; // true
  (err as CircularReferenceError).path; // ['next'] — where the back-edge was found
}

Rather than tagging every encoder one by one, you can arm the guard process-wide with setRejectCircularRefs(true). Every guarded factory will check from then on, and any individual call can opt out with {rejectCircularRefs: false}.

// Or arm it once globally — every guarded factory (validate, getValidationErrors,
// JSON encoder, binary encoder) checks unless given `{rejectCircularRefs: false}`.
setRejectCircularRefs(true);

const encodeBin = createBinaryEncoder<Node>(); // armed via the global flag
try {
  encodeBin(cyclic as Node);
} catch (err) {
  err instanceof CircularReferenceError; // true
}

setRejectCircularRefs(false); // disarm — back to the default

The check is pay-for-use: types that can't cycle don't get wrapped at all, even with the global flag on. And only real cycles trigger it. A value shared between two siblings walks through fine, because the guard only watches the current path down, not every object it ever saw.

// Shared-but-acyclic values pass — `shared` is reached twice, but never
// through itself, so the guard stays quiet.
const shared: Node = {name: 'shared'};
const dag: Node[] = [
  {name: 'root', next: shared},
  {name: 'alt', next: shared},
];

const encodeList = createJsonEncoder<Node[]>(undefined, {rejectCircularRefs: true});
encodeList(dag); // encodes normally — no cycle
The same rejectCircularRefs option is available on createValidate and createGetValidationErrors. There, validate returns false and getValidationErrors records a {expected: 'circular'} entry instead of throwing. Decoders need no guard: a serialized payload can't contain a runtime cycle. The whole guard is off by default; arm it only where you process values that might cycle.

Decoders return DataOnly<T>

Both createJsonDecoder<T>() and createBinaryDecoder<T>() return DataOnly<T>, not bare T. A value rebuilt from JSON or bytes can only hold serializable data. Anything that can't ride the wire (methods, for one) was never there, so the return type leaves it out and stops you from reaching for it.

const decode = createJsonDecoder<Cart>();

// The decoder returns DataOnly<Cart>, not Cart — the method is gone from the
// type because it was never on the wire. TS now stops you from calling it.
const cart = decode('{"items":["TS-7"],"total":42}');

cart.items; // string[]  ✅
cart.total; // number    ✅
// cart.checkout();      ❌ TS error — checkout isn't part of DataOnly<Cart>

This isn't a runtime cost. It's the type telling the truth about what came back.

One contract: serializable data only

Validation and both codecs operate on the serializable projection of your type, not every last TypeScript member. Functions, methods and symbol keys are silently dropped. They don't survive JSON anyway, so a {name: string; onClick: () => void} produces a validator that only checks name.

This is on purpose, and the build tells you when it happens with a VL010-family Warning, not an error. (At a position that would throw at runtime, like a union member or array element, it's escalated to an Error and the build fails instead.) The reasoning is in About RunTypes.

Binary payload size

Binary is the strategy that wins big when your types carry format constraints. The codec reserves a worst case width whenever it cannot tell a value's range, so an unconstrained number or bigint rides the wire as a fixed 8 bytes. Once the type pins the value into a known range (a fixed width like int8 or uint16, or a min and max bound), the encoder packs it into far fewer bytes (a uint8 into 1 byte, a {min: 0, max: 1000} number into 2). The serialization formats benchmark pairs each unconstrained value against its constrained twin, so you can read the saving off directly.

Copyright © 2026