Binary Serialization
The round trip
createBinaryEncoderFn and createBinaryDecoderFn are the JSON pair with a different output: compact bytes instead of text. Everything else is the same. The codec is generated from your type, so a Date comes back as a Date, and a Map as a Map.
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.
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.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.
Circular references
A value that points back at itself makes the encoder recurse until the stack overflows, so the binary encoder arms the same per-call rejectCircularRefs guard as the JSON one, with the same pay-for-use rule.
// 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
}
What it shares with JSON
Everything below the wire format itself is the same, and lives on the JSON serialization page:
- Custom class serializers: one
registerClassSerializercall covers both codecs. - Decoders return
DataOnly<T>: bytes can only carry data, so methods are left out of the type that comes back. - One contract: serializable data only: functions, methods and symbol keys are dropped, with a build-time Warning.