Guide

Binary Serialization

The same round trip as JSON, written as compact bytes instead of text.

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 a RangeError.
  • intoBuffer, (val, into) => Uint8Array, lets you pass the ArrayBuffer to 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
}
You only reach for 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:

Copyright © 2026