JSON Serialization
The round trip
createJsonEncoderFn and createJsonDecoderFn understand the types JSON.stringify can't. Because the pair is generated from your type, it knows all the types that requires transform or coercion to survive the round trip.
// A type with members JSON.stringify quietly mangles: a Date and a Set.
type Session = {
id: string;
startedAt: Date;
roles: Set<string>;
};
const session: Session = {
id: 's-1',
startedAt: new Date('2026-01-01T00:00:00Z'),
roles: new Set(['admin', 'editor']),
};
const encode = createJsonEncoderFn<Session>();
const decode = createJsonDecoderFn<Session>();
const wire = encode(session)!; // a JSON string: Date and Set survive
const back = decode(wire); // Date is a Date again, Set is a Set again
back.startedAt instanceof Date; // true
back.roles instanceof Set; // 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 Set turns into {} on the way out, and never comes back.
JSON.stringify(session); // {"id":"s-1","startedAt":"2026-01-01T...","roles":{}}
JSON.stringify and JSON.parse.Using the two factories
createJsonEncoderFn<T>() returns a function that turns a T into a JSON string. Call the factory once per type, next to the type itself, and reuse the function you get back.
// One call per type, at module level: the encoder is compiled at build time.
const encodeUser = createJsonEncoderFn<User>();
const json = encodeUser(user); // a JSON string, or undefined if you pass undefined
// The second argument is the options bag. `strategy` picks how the value is
// walked, `mutate` being the fastest when you don't mind the input changing.
const encodeFast = createJsonEncoderFn<User>(undefined, {strategy: 'mutate'});
encodeFast(user);
createJsonDecoderFn<T>() is the other half. It takes the JSON string and gives you the value back, typed as DataOnly<T>.
// The other half, built from the same type.
const decodeUser = createJsonDecoderFn<User>();
const back = decodeUser(json!); // signedUpAt is a Date again, typed as DataOnly<User>
// `strategy: 'preserve'` keeps properties your type doesn't declare,
// the default `strip` drops them.
const decodeLoose = createJsonDecoderFn<User>(undefined, {strategy: 'preserve'});
decodeLoose(json!);
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.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. createJsonEncoderFn accepts 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 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 the binary encoder, on createValidateFn and on createGetValidationErrorsFn. In validation, 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.
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.
// prepareForJson and restoreFromJson have no factory of their own, so you name
// the pair you want in a marker and recover the handles with getRTFunction.
// 'pjs' is the clone prepare, 'rj' the matching restore.
function jsonValueCodec<T>(fns?: InjectTypeFnArgs<T, 'pjs', 'rj'>) {
return {
prepare: getRTFunction<'pjs'>(fns?.[0]),
restore: getRTFunction<'rj'>(fns?.[1]),
};
}
// A concrete call site: the build injects both handles for Message here.
const messageCodec = jsonValueCodec<Message>();
const message: Message = {id: 42n, sentAt: new Date('2020-01-02T03:04:05.000Z'), body: 'hi'};
const safe = messageCodec.prepare(message); // JSON-safe value, no string yet
const back = messageCodec.restore(safe); // typed shape again, bigint and Date included
Your code owns the JSON.stringify and the JSON.parse, so many values can share one envelope with a single stringify and a single parse.
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.