Using serialize.js
September 4, 2026 · View on GitHub
Everything the library does, by example. The wire format itself is defined by STANDARD.md; this document teaches the JavaScript surface that speaks it.
import {
WriteStream, ReadStream, MeasureStream, SerializeError,
BitWriter, BitReader,
bitsRequired, bitsRequired64, bitsRequired128,
} from './serialize.js/src/index.js';
The library is vendored as source and imported by path — see README.md. The specifier becomes a package name once the npm package is published; the surface below does not change.
One serialize function, three streams
The family's defining pattern: write, read and measure share a single
serialize function. Every operation returns a bool, values travel in
{ value } holder objects (the JavaScript translation of the family's ref
parameters), and the stream direction decides whether the holder is
consumed or filled.
function serializePlayer(stream, player) {
return (
stream.serializeInt(player.health, 0, 100) &&
stream.serializeBool(player.alive) &&
stream.serializeFloat(player.heading)
);
}
// write
const player = {
health: { value: 87 },
alive: { value: true },
heading: { value: Math.fround(1.25) },
};
const writer = new WriteStream(new Uint8Array(64)); // length a multiple of 8
serializePlayer(writer, player); // -> true
writer.flush(); // ALWAYS flush before touching the bytes
const wire = writer.data(); // a 5-byte Uint8Array: 7 + 1 + 32 bits
// read
const reader = new ReadStream(wire); // any length, no slack required
const decoded = { health: {}, alive: {}, heading: {} };
serializePlayer(reader, decoded); // -> true
decoded.health.value; // 87
// measure
const measure = new MeasureStream();
serializePlayer(measure, player); // -> true
measure.bitsProcessed(); // 40
WriteStream needs a buffer whose length is a multiple of 8 (the writer
works in 64-bit words). ReadStream accepts any data length and never
reads outside it. MeasureStream touches no memory at all — it prices a
message so you can size buffers; its bound is conservative (see
Measuring).
All three streams expose bitsProcessed(), bytesProcessed(),
isWriting / isReading, and reset(...) for allocation-free reuse.
Errors are values
The wire is a trust boundary: hostile bytes never throw. Every failure —
a truncated read, a value outside its range, a malformed string — returns
false and latches the first error on the stream, where it sticks; every
later call returns false without disturbing it.
const r = new ReadStream(Uint8Array.of(0x00)); // 8 bits of data
r.serializeBits({}, 32); // -> false: past the end
r.error; // SerializeError.Overflow
r.ok; // false
r.serializeBits({}, 1); // -> false: the error is latched
r.reset(Uint8Array.of(0x00)); // clears state AND the latched error
A refused read leaves its destination exactly as it was: every scalar
read checks before it assigns, so a caller that reads ref.value after a
false sees what was there before the call, never a value the stream did
not carry. The exception is serializeBytes, which fills a caller-owned
buffer whose contents after a refusal are unspecified.
And the failure is terminal: nothing after a failing read has a defined
position, so the stream itself refuses everything that follows rather than
leaving that to your discipline. reset() — pointing the stream at a new
buffer — is what clears it.
The error codes: Overflow (past the end of data or buffer),
ValueOutOfRange (a value outside its declared range, on either side of
the wire), Align (nonzero alignment padding — the serialize functions
don't match), InvalidString (a malformed string payload). A healthy
stream's error is SerializeError.None, which is null, so
if (stream.error) reads naturally.
Broken code is different from hostile data: misusing the API — bits
out of [1,32], a non-BigInt where the domain is BigInt, an invalid
declaration — throws TypeError/RangeError on every stream, in every
state, and writing a value outside its declared range latches
ValueOutOfRange and writes nothing. That is the checked mode, the
default; whether those caller checks exist at all is the mode's choice —
see the next section.
The two modes: checked and production
The family standard makes the caller responsible for well-formed writes.
Writer contracts are assertions in a checked build — the standard's
term for a build with assertions enabled — and the languages that can
compile those out do. JavaScript can't strip code at compile time, so the
write path forks once, at module load, on NODE_ENV:
node app.js # checked: the development default
NODE_ENV=production node app.js # production: the caller-trust release shape
Checked is everything described above: caller misuse throws, invalid values latch — the always-on form of the family's checked-build assertions. Develop and test here.
Production removes the per-operation caller validation from
WriteStream, MeasureStream and BitWriter, exactly as a C/C++ release
build compiles its asserts to nothing. What every write keeps, in both
modes identically: the sticky-error gate and the buffer-end check —
// production mode: a message that does not fit is still a latched VALUE
const w = new WriteStream(new Uint8Array(8));
const big = { value: 1n };
w.serializeUint64(big); // -> true: 64 bits, the buffer is full
w.serializeUint64(big); // -> false: SerializeError.Overflow, latched
w.ok; // false, and every later call keeps returning false
— because overflow is a runtime condition, not a caller bug. Everything
else on the write side is your contract in production: a value outside its
declared range goes out as deterministic garbage (which conforming readers
refuse), a lone surrogate reaches the wide-string wire (where conforming
readers refuse it), and nothing is validated for you. Misuse never
corrupts memory — JavaScript's own bounds semantics are the backstop — it
corrupts your message. The wire for conforming writes is byte identical
in both modes: npm run test:production re-runs the golden pins and the
property sweep under the production variants to prove it.
Reads are untouched by the mode: the wire is a trust boundary, and every read-side refusal — bounds, ranges, alignment padding, string content — binds in every mode.
The selection is frozen: the environment is read once, and whole classes
are chosen at export time (ReadStream has no variants). Changing
NODE_ENV after load has no effect.
Raw bits
serializeBits moves the low bits of a non-negative Number, 1 to 32.
serializeBits64 is its BigInt twin, 1 to 64 bits. Values wider than 32
bits go low 32-bit dword first — the family's group rule.
const w = new WriteStream(new Uint8Array(16));
w.serializeBits({ value: 5 }, 3); // 3 bits on the wire
w.serializeBits({ value: 0xdeadbeef }, 32); // full width
w.serializeBits64({ value: 0x123456789abcdef0n }, 64); // BigInt domain
w.serializeAlign(); // zero-pads to the next byte boundary
w.flush();
serializeAlign writes zero bits up to the next byte boundary (nothing if
already aligned); the reader verifies the padding is zero and latches
Align otherwise.
Ranged integers
serializeInt(ref, min, max) is the format's defining operation: the
value rides as an offset from min in exactly bitsRequired(min, max)
bits. Both sides must state the same range — the range is part of the
message format, not the wire.
w2.serializeInt({ value: -37 }, -100, 100); // 8 bits
w2.serializeInt({ value: 7 }, 7, 7); // degenerate range: ZERO bits
min <= max is the legal relation for every ranged operation —
serializeInt, serializeInt64, serializeInt128 and serializeFixed,
on every storage width. The degenerate min === max range is a field
you may declare, not misuse: the writer emits nothing, the reader consumes
nothing and takes the value from min, and a measure adds zero bits.
(serializeCompressedFloat is not a ranged operation and is the one
exception: it quantizes across its bounds, so it requires min < max.)
Reads refuse values smuggled into the bit headroom of a range (an offset
above max - min latches ValueOutOfRange — reject, never clamp).
serializeInt64 and serializeInt128 are the same operation in the
BigInt domain, with offsets computed in unsigned arithmetic so ranges
wider than and are exact, in 32-bit groups least significant
first:
w2.serializeInt64({ value: -5000000000n }, -5000000000n, 5000000000n); // 34 bits
w2.serializeInt128({ value: 0n }, -(2n ** 127n), 2n ** 127n - 1n); // 128 bits
bitsRequired(min, max), bitsRequired64 and bitsRequired128 price a
range when designing a message format. They live in the unsigned
domain (the subtraction wraps, reproducing the C++ arithmetic exactly);
convert a signed bound with >>> 0 / BigInt.asUintN first, just as the
serialize operations do internally:
bitsRequired(0, 200); // 8: the cost of serializeInt over [-100, +100]
bitsRequired(-100 >>> 0, 100); // 8: same range, converted bounds
bitsRequired64(BigInt.asUintN(64, -5000000000n), 5000000000n); // 34
bitsRequired128(0n, 2n ** 100n); // 101
The unsigned helpers and bool
Fixed-width conveniences. The 8/16/32-bit helpers live in the Number
domain; 64 and 128 are BigInt. serializeUint128 is always 128 bits, low
64-bit half first. serializeBool is one bit, and the reader refuses a
latched stream just like every other operation.
w3.serializeUint8({ value: 0x7f });
w3.serializeUint16({ value: 0x1234 });
w3.serializeUint32({ value: 0x12345678 });
w3.serializeUint64({ value: 0x123456789abcdef0n });
w3.serializeUint128({ value: (1n << 100n) + 1n });
w3.serializeBool({ value: true });
Floats and doubles: bit transparent
serializeFloat (32 bits) and serializeDouble (64 bits) reproduce the
transmitted pattern exactly — every pattern is legal on the wire: NaNs
with any payload, signaling NaNs, infinities, negative zero, denormals.
Float32 values ride a software narrowing/widening that never sets the
quiet bit. Note that a float is 32 bits on the wire: write
Math.frounded values or you'll be surprised by the read-back.
w4.serializeFloat({ value: Math.fround(3.1415926) });
w4.serializeDouble({ value: 1.0 / 3.0 });
w4.serializeFloat({ value: -0 }); // -0 round trips as -0
The compressed float
serializeCompressedFloat(ref, min, max, resolution) quantizes into a
declared range at a resolution — the one lossy operation. The declaration
is part of the message format. The arithmetic is float32 with the
standard's two roundings on each side (Math.fround at every step — the
roundings are part of the format, and the family's discriminating vectors
pin the decoded bit patterns exactly).
w5.serializeCompressedFloat({ value: 5.0 }, 0.0, 10.0, 0.01); // 10 bits
// reading it back yields exactly 5.0: the value sits on a quantum.
// off-quantum values come back within the resolution; re-encoding a
// decoded value is byte-identical (the round trip is idempotent).
Finite values outside [min, max] clamp on write; a non-finite value
latches ValueOutOfRange; reads refuse integers smuggled above the
quantization ceiling.
Raw bytes
serializeBytes(data) aligns to the byte boundary (the alignment is part
of the format, padding verified on read) and then bulk-copies. The count
is never transmitted: both sides agree by passing arrays of the same
length. On read, the array you pass is filled in place — and if the read
refuses, its contents are unspecified, so check the return before you use
it.
w6.serializeBytes(Uint8Array.of(0xde, 0xad, 0xbe, 0xef));
// ...
const out = new Uint8Array(4);
r6.serializeBytes(out); // out now holds the bytes
A zero-length array still performs (and verifies) the align.
Strings: UTF-8 on the wire
serializeString(ref, bufferSize) sends the UTF-8 byte length as
serializeInt(length, 0, bufferSize - 1), then the payload as
serializeBytes (which aligns). bufferSize is part of the message
format — the same string against different buffer sizes produces
different bytes — and the payload must fit bufferSize - 1 bytes.
w7.serializeString({ value: 'golden' }, 16);
const s = {};
r7.serializeString(s, 16); // s.value === 'golden'
Reads validate the payload in every build: malformed UTF-8 (overlongs,
surrogate code points, values above U+10FFFF, truncated sequences, stray
continuations) and interior NULs latch InvalidString. A lone surrogate
in a written string — ill-formed UTF-16, the writer's contract
violated — encodes as U+FFFD, the contract surfacing JavaScript's way.
Wide strings: UTF-16 code units
serializeWideString(ref, bufferSize) sends the unit count, then one
32-bit group per UTF-16 code unit — never a code point — with no
alignment anywhere: the one place the wide path deliberately differs from
its narrow counterpart. A JavaScript string is a sequence of UTF-16
code units, so astral characters are two groups on the wire, exactly as
the family's 2-byte-wchar_t ports split them. bufferSize counts wide
characters.
w8.serializeWideString({ value: '\u{1f600}A' }, 8); // 3 units: 99 bits
Writes refuse over-long strings (ValueOutOfRange) and lone surrogates
(InvalidString — the wide wire cannot carry ill-formed UTF-16, because
conforming readers refuse it). Reads refuse groups above 0xFFFF, interior
NUL groups, and unpaired, misordered or dangling surrogates.
The relative integer
serializeIntRelative(previous, ref) prices strictly increasing sequences
— sequence numbers, ack chains — over the operation's domain, the
non-negative int32 range 0 to − 1 inclusive. current > previous
always, no wrapping. A difference of 1 costs a single bit; small
differences ride payload tiers of 5/8/13/18/23 bits; past the ladder, six
zero flags carry current itself as 32 raw bits.
w9.serializeIntRelative(100, { value: 101 }); // 1 bit
w9.serializeIntRelative(100, { value: 2100 }); // the mid-ladder tier
// read side: pass the same previous, get current back
r9.serializeIntRelative(100, seq); // seq.value === 101
previous is caller state, not wire: both sides already know it, and a
previous outside the domain throws as misuse — the domain belongs to the
operation, not to your storage type, so 2**31 is caller error exactly as
-1 is. Writing a current at or below previous, or above the domain,
latches ValueOutOfRange.
On read, every tier's reconstruction is checked: the reader rebuilds
current in a width that cannot wrap and refuses the read unless the
result is inside the domain and above previous. The absolute tier's 32
raw bits are read unsigned, so a group with the top bit set is refused
rather than arriving as a negative sequence number. A refused read leaves
ref.value untouched and is terminal for the stream.
Fixed point
serializeFixed(ref, integerBits, fractionBits, min, max) carries
Q-format fixed point. ref.value is the raw scaled integer — the
real value times 2^fractionBits — in storage of exactly
integerBits + fractionBits bits (8, 16, 32, 64 or 128; the sign bit
counts toward integerBits). min and max are in whole units,
part of the message format: Numbers for storage of 32 bits or fewer,
int64 BigInts for 64 and 128 — the value domain follows the storage
width.
// -3.25 in Q8.8 over [-100, +100] units: raw is -3.25 * 256 = -832
w10.serializeFixed({ value: -832 }, 8, 8, -100, 100); // 16 bits
// 1234.5 in Q16.16 over [-2000, +2000]
w10.serializeFixed({ value: 1234 * 65536 + 32768 }, 16, 16, -2000, 2000);
// 12345.5 in Q48.16 over [-100000, +100000]: 64-bit storage, BigInt lane
w10.serializeFixed({ value: 12345n * 65536n + 32768n }, 48, 16, -100000n, 100000n); // 34 bits
// Q64.64 over the full unit range: 128 bits, four groups
w10.serializeFixed({ value: 1n << 64n }, 64, 64, -(2n ** 63n), 2n ** 63n - 1n);
The wire is the offset from min << fractionBits in exactly the bit
length of the raw range — byte identical to serializeInt64 of the raw
value wherever storage fits 64 bits — and the round trip is exact: no
quantization, unlike the compressed float. A degenerate min === max
range costs zero bits on every storage width. Reads refuse raw values
smuggled into the bit headroom; an invalid declaration throws as caller
misuse.
Measuring
MeasureStream prices a message without a buffer. For everything except
alignment it is exact; any operation that aligns (serializeAlign,
serializeBytes, serializeString) charges the worst case — 7 bits of
padding — because the measure stream cannot know what alignment the field
will land on inside your message. The guarantee is a bound, never
equality:
measure.bitsProcessed() >= writer.bitsProcessed(); // always true
Size buffers from the measured bound (rounded up to a multiple of 8 bytes
for WriteStream).
The bitpacker underneath
BitWriter and BitReader are the streams' engine — the family wire in
two-lane 32-bit arithmetic — and are exported for code that wants raw
bitpacking without the serialize surface or its checks:
const bw = new BitWriter(new Uint8Array(16));
bw.writeBits(5, 3);
bw.writeAlign();
bw.writeBytes(Uint8Array.of(1, 2, 3));
bw.flushBits();
const br = new BitReader(bw.data());
br.readBits(3); // 5
br.readAlign(); // true: padding was zero
br.readBytes(3); // Uint8Array [1, 2, 3]
The reader prices its windows inside the buffer: any data length is supported and no slack past the data is required.
Wire compatibility
The same values produce the same bytes across the family, which implements
format version 1.1 of the standard. This is not aspiration but pinned
fact: the suite runs the family's shared conformance corpus
(conformance/, vendored from mas-bandwidth/serialize) and carries the
golden vectors — including serialize.h's 112-byte golden wire message
covering every operation class, byte for byte — plus the discriminating
float vectors, the string and wide-string pins, every relative-integer
tier, and the fixed point shapes at every group count, all minted from the
C++ implementation's own output. If your message serializes with the same
declarations on both ends, a stream written by any family implementation
reads in any other.
Two doctrines worth knowing at the edges:
- Trailing bits: writers zero the unused bits of the final byte; readers never reject a stream for their contents.
- Past-end data: bytes past the end of the data you hand
ReadStreamare never read, let alone interpreted.