Serialization
JSON encode / decode
createJsonEncoderFn / createJsonDecoderFn 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 = createJsonEncoderFn<Session>();
const decode = createJsonDecoderFn<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
createJsonEncoderFn 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 = createJsonEncoderFn<Profile>(undefined, {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 = createJsonEncoderFn<Profile>(undefined, {strategy: 'mutate'});
// 'direct' — single pass, no clone, always strips undeclared keys.
const encodeDirect = createJsonEncoderFn<Profile>(undefined, {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:
| Strategy | Under the hood | Mutates 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. | No | Dropped (for free) |
mutate | Rewrites 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. | Yes | Kept on the wire |
direct | A 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. | No | Dropped |
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.Work at the value level
createJsonEncoderFn and createJsonDecoderFn give you a string. Sometimes you already own the JSON envelope (a framework that parses one request body and stringifies one response), so you want the transform at the value level, without an extra stringify and parse.
The pieces for that are prepareForJson and restoreFromJson: prepare turns a typed value into a JSON-safe value, restore turns a JSON-safe value back into the typed shape. The string encoder and decoder are built on exactly these. They have no factory of their own, so you name the one you want in a marker and recover it with getRTFunction, one per strategy. See recovering a function that has no factory.
A root undefined or void is safe: prepare passes the value through and restore returns undefined, so neither throws. Wrapping the value in your own envelope (an array or object) is what keeps a bare undefined from breaking JSON.stringify.
Binary encode / decode
createBinaryEncoderFn / createBinaryDecoderFn are the same idea, smaller and faster on the wire. The encoder returns the encoded bytes as a zero-copy Uint8Array (its byteLength is the exact size on the wire; call slice() if you want an owned copy). The decoder takes those bytes, or any buffer or view, straight back. Like JSON, the codec knows your type, so a Date round-trips cleanly.
const encode = createBinaryEncoderFn<Telemetry>();
const decode = createBinaryDecoderFn<Telemetry>();
const bytes = encode(sample); // a Uint8Array view of the encoded bytes — compact, no field names on the wire
const back = decode(bytes); // decode reads the bytes directly; 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.
Choose how the buffer is sized
The sizeStrategy option picks how the encoder sizes its buffer, and with it the shape of the function you get back:
dynamic(the default),(val) => Uint8Array, sizes its first buffer from a compile-time estimate of your type, then refines from earlier encodes and grows the buffer in place when a value runs larger. You configure nothing, and it suits almost everyone.precalculate,(val) => Uint8Array, measures the value once to find its exact size, then allocates exactly that. The extra pass makes encoding roughly a quarter slower; in return the buffer is never too large and never has to grow.initialSize,(val, size) => Uint8Array, lets you pass the buffer size each call. It never grows, so a value that does not fit throws aRangeError.intoBuffer,(val, into) => Uint8Array, lets you pass theArrayBufferto write into each call. It writes a zero-copy view into your buffer and throws if the value does not fit.
const encode = createBinaryEncoderFn<User>(undefined, {sizeStrategy: 'intoBuffer'});
const buffer = new ArrayBuffer(4096);
const view = encode(user, buffer); // a Uint8Array view into `buffer`, zero-copy
To pick a safe size for initialSize or intoBuffer, ask createBinarySizerFn for the exact byte count. It runs the same encoding as the encoder but writes nothing, so it allocates no buffer:
const sizeOf = createBinarySizerFn<User>();
const bytes = sizeOf(user); // exact on-wire size, no buffer allocated
Reuse the buffer in hot loops
In a tight loop the per-encode allocation adds up. With sizeStrategy: 'intoBuffer' you allocate one buffer and reuse it: the encoder writes into it and hands back a zero-copy view, so there is no fresh allocation per call. Consume each view before the next encode overwrites the buffer.
// In a hot loop, allocate one buffer and reuse it. With sizeStrategy 'intoBuffer' the
// encoder writes into YOUR buffer and returns a zero-copy view, so there is no
// fresh allocation per call. createBinarySizerFn gives a safe size to allocate.
const encode = createBinaryEncoderFn<Tick>(undefined, {sizeStrategy: 'intoBuffer'});
const decode = createBinaryDecoderFn<Tick>();
const sizeOf = createBinarySizerFn<Tick>();
const ticks: Tick[] = [
{symbol: 'TS', price: 7},
{symbol: 'GO', price: 9},
];
const buffer = new ArrayBuffer(Math.max(...ticks.map(sizeOf)));
for (const tick of ticks) {
const view = encode(tick, buffer); // a Uint8Array view into `buffer`
decode(view); // consume the view before the next encode reuses the buffer
}
intoBuffer when you want to own the buffer in performance-sensitive code. For everyday encode/decode, the default createBinaryEncoderFn / createBinaryDecoderFn 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 is different. A plain object is pure data: everything it carries survives JSON and comes back the same. A class instance is not. Its methods and getters live on the prototype (they are code, so they never ride the wire), its constructor runs real logic, and it may hold values that cannot be serialized at all. So the wire can only carry the data of an instance; turning that data back into a live object needs code the build tool does not have. That is what you register once with registerClassSerializer: you hand it the class itself and (when needed) how to rebuild an instance.
import {registerClassSerializer, createJsonEncoderFn, createJsonDecoderFn, type DataOnly} from '@ts-runtypes/core';
// A class with a non-empty constructor. The data goes on the wire structurally
// (just its declared properties), so you only have to teach ts-runtypes how to
// build a real instance back: pass the class itself and a `deserialize`.
// `serialize` is optional here (the default structural encode is exactly right).
class Money {
constructor(
public amount: number,
public currency: string
) {}
format(): string {
return `${(this.amount / 100).toFixed(2)} ${this.currency}`;
}
}
registerClassSerializer(Money, {
// `data` is the data-only projection (methods already gone) -> a real instance
deserialize: (data: DataOnly<Money>) => new Money(data.amount, data.currency),
});
// A class with a zero-argument constructor. There is nothing else to supply:
// the client just hands over the class. Decode rebuilds it with `new Settings()`
// and copies the decoded properties over.
class Settings {
theme = 'light';
fontSize = 12;
summary(): string {
return `${this.theme}/${this.fontSize}`;
}
}
registerClassSerializer(Settings);
type Account = {id: string; balance: Money; settings: Settings};
const encode = createJsonEncoderFn<Account>();
const decode = createJsonDecoderFn<Account>();
const json = encode({id: 'acc_1', balance: new Money(4999, 'USD'), settings: new Settings()})!;
const back = decode(json); // back.balance is a real Money, back.settings a real Settings
export {Money, Settings, encode, decode, back};
export type {Account};
Both halves are optional:
serializedefaults to the structural encode, exactly what an interface of the same shape would produce. Provide it only when you want a different wire shape (keep it to the declared properties on the JSON path).deserializedefaults tonew YourClass()followed by copying the decoded properties over, so a class with a zero-argument constructor needs nothing but the class. Providedeserializewhen the constructor takes arguments (the type checker requires it there).
Register before anything serializes the class.
Generic classes need nothing extra. Generics only exist at compile time, so every instantiation of a generic class is the same class object at runtime, and one registration covers them all: register RpcError once and both RpcError<'a'> and RpcError<'b', Data> reconstruct from that single call. Registering the same class again just updates its handlers; it never drops coverage of an instantiation that worked before.
Classes also reconstruct inside a union. A field typed as Circle | Square comes back as the right instance, and it works even when the two classes have identical fields, because each member is picked by the actual instance. A plain object sitting next to a class in the same union (Money | {amount: number}) stays a plain object. You rarely think about this; it just round-trips.
Classes extending Error keep their envelope but never leak a stack trace. The required members name and message are always serialized, so the error stays useful on the receiving side. The optional members stack and cause are guarded: they ride the wire only when the value carries them as enumerable own properties (the same test JSON.stringify applies), so a stack trace (which holds server file paths) never leaks by default. To include one, define it as an enumerable property on the value, for example Object.defineProperty(err, 'stack', {value: err.stack, enumerable: true}).
You can opt one of your own properties into the same guard with a @nonEnumerable JSDoc tag. The tag takes effect only on an optional property, because a guarded property has to be one the type already allows to be absent. Tagging a required property does nothing (it still serializes), and a lint rule points that out.
createValidateFn 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. If the automatic new YourClass() fails because the constructor needs arguments, decode throws a CLS002 error telling you to register a deserialize.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 createJsonEncoderFn and createBinaryEncoderFn 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 = createJsonEncoderFn<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
}
The binary encoder arms the same way.
// The binary encoder arms the same way. `rejectCircularRefs` is a compile-time
// option, so the armed encoder is a separate compiled function that bakes the
// cycle check into its body (you only pay for it where you ask for it).
const encodeBin = createBinaryEncoderFn<Node>(undefined, {rejectCircularRefs: true});
try {
encodeBin(cyclic as Node);
} catch (err) {
err instanceof CircularReferenceError; // true
}
The check is pay-for-use. rejectCircularRefs is a compile-time option (like noLiterals), so the armed factory is a separate compiled function that bakes the cycle check into its body. A plain factory for the same type carries none of it, and the walker itself is only bundled into an app that actually arms the guard somewhere. 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 = createJsonEncoderFn<Node[]>(undefined, {rejectCircularRefs: true});
encodeList(dag); // encodes normally — no cycle
rejectCircularRefs option is available on createValidateFn and createGetValidationErrorsFn. 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 createJsonDecoderFn<T>() and createBinaryDecoderFn<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 = createJsonDecoderFn<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.
A property whose serialization is guarded by enumerability (anything optional inherited from Error, or one you tag with @nonEnumerable) is always optional in the type, so DataOnly<T> never claims a value the wire might omit. The type stays honest even for the properties that only sometimes ride the wire.
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.