Using serialize.dart
September 4, 2026 · View on GitHub
Everything the library does, by example. The wire format itself is defined by the C++ reference's STANDARD.md; this document teaches the Dart surface that speaks it.
import 'package:serialize/serialize.dart';
The library is not on pub.dev yet — depend on it from git and that import resolves unchanged. See README.md.
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
Ref<T> holders (the Dart translation of the family's ref parameters),
and the stream direction decides whether the holder is consumed or
filled.
class Player {
final health = Ref<int>(0);
final alive = Ref<bool>(false);
final heading = Ref<double>(0);
}
bool serializePlayer(BitStream stream, Player player) {
return stream.serializeInt(player.health, 0, 100) &&
stream.serializeBool(player.alive) &&
stream.serializeFloat(player.heading);
}
// write
final player = Player()
..health.value = 87
..alive.value = true
..heading.value = 1.25;
final writer = WriteStream(Uint8List(64)); // length a multiple of 8
serializePlayer(writer, player); // -> true
writer.flush(); // ALWAYS flush before touching the bytes
final wire = writer.data(); // a 5-byte view: 7 + 1 + 32 bits
// read
final reader = ReadStream(wire); // any length, no slack required
final decoded = Player();
serializePlayer(reader, decoded); // -> true
decoded.health.value; // 87
// measure
final measure = MeasureStream();
serializePlayer(measure, player); // -> true
measure.bitsProcessed; // 40
WriteStream needs a buffer whose length is a multiple of 8 (the writer
stores 64-bit words to memory). 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. data() returns
a view of the written bytes, not a copy.
Writes trust, reads validate
The check model is the family standard's ("Writes assume trusted data"): the caller is responsible for well-formed writes, and reads validate everything, because the wire is a trust boundary.
On the read side, every failure — a truncated read, a value outside its
range, nonzero alignment padding, a malformed string — returns false,
and hostile bytes never throw.
A refused read leaves its destination unwritten. The Ref holder you
passed still holds exactly what it held before the call, so a caller that
trusts the holder over the return code is never handed a value the stream
did not carry. Two things the rule does not reach: serializeBytes,
serializeString and serializeWideString read into a buffer you own, and
its contents after a refusal are unspecified; and a sequence of reads over
an object or an array may leave earlier members written, because the rule
is per primitive read.
A failure is terminal. Nothing after the failing operation has a
defined position, so the stream latches: failed becomes true, and every
later read fails, consuming no bits and writing no destination — including
a zero-bit read of a degenerate range. Only reset(...), which points the
stream at data again, clears it. The bit index is not the refusal point
afterwards; after a refusal the position is not part of the contract.
final r = ReadStream(Uint8List.fromList([0x00])); // 8 bits of data
r.serializeBits(v, 32); // -> false: past the end
r.serializeBits(v, 8); // -> false too: the stream is latched
r.failed; // -> true
r.reset(Uint8List.fromList([0x00])); // point at fresh data, state cleared
r.serializeBits(v, 8); // -> true
// an offset smuggled into the bit headroom of a range is refused
final r2 = ReadStream(Uint8List.fromList([0xff]));
r2.serializeInt(ranged, 0, 200); // -> false: 8 bits carry 255, above max
One rule follows from clean refusal: check the result of any serialized value that controls a loop before the loop uses it, or a truncated packet spins the loop on garbage.
On the write side, WriteStream and MeasureStream operations always
return true — caller contracts (bit counts in range, values within
their declared ranges, min ≤ max, well-formed string payloads, buffers
that fit) are assert statements, the Dart form of the family's debug
asserts:
- Checked (
dart run --enable-asserts, anddart compile exe --enable-asserts): contract violations throwAssertionErrorat the call site. Develop and test here. - Release (the default for
dart run,dart compile exeand Flutter release builds): asserts do not exist, exactly as a C++ release build compilesserialize_assertto nothing. The caller is trusted; misuse produces garbage on the wire (which conforming readers refuse), never memory unsafety — Dart's own bounds semantics backstop the trusted path.
The wire for conforming writes is byte identical in both modes — the test suite runs green under both.
Raw bits
serializeBits moves the low bits of a non-negative value, 1 to 32.
serializeBits64 is its 64-bit twin — Dart ints are 64 bits, so wide
values need no separate domain. Values wider than 32 bits go low 32-bit
dword first — the family's group rule.
final w = WriteStream(Uint8List(16));
w.serializeBits(Ref(5), 3); // 3 bits on the wire
w.serializeBits(Ref(0xdeadbeef), 32); // full width
w.serializeBits64(Ref(0x123456789abcdef0), 64); // low 32-bit group first
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 refuses
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.
w.serializeInt(Ref(-37), -100, 100); // 8 bits
w.serializeInt(Ref(7), 7, 7); // degenerate range: ZERO bits
Reads refuse values smuggled into the bit headroom of a range (an offset
above max - min fails the read — reject, never clamp).
serializeInt64 is the same operation across the full 64-bit domain,
with offsets computed in unsigned arithmetic so ranges wider than
are exact. serializeInt128 extends it to 128 bits on the Int128 pair
type, written in 32-bit groups least significant first; where the range
fits 64 bits the bytes are identical to serializeInt64.
min <= max is the legal relation on every ranged operation — int,
int64, int128 and fixed — in every build mode. The degenerate range
is a field to accept, not a misuse: the writer emits nothing, the reader
consumes nothing and takes the value from min, and a measure adds zero,
on the 128-bit width exactly as on the narrower ones. compressedFloat is
not a ranged operation and is the exception: it quantizes across its
bounds, so it requires min < max.
w.serializeInt64(Ref(-5000000000), -5000000000, 5000000000); // 34 bits
w.serializeInt128(Ref(Int128.fromInt(-1)), Int128.minValue, Int128.maxValue); // 128 bits
bitsRequired(min, max), bitsRequired64 and bitsRequired128 price a
range when designing a message format. They live in the unsigned
domain: 32/64-bit unsigned values are held bit-transparently in Dart's
signed 64-bit int, and 128-bit bounds are UInt128.
bitsRequired(0, 200); // 8: the cost of serializeInt over [-100, +100]
bitsRequired64(0, 5000000000); // 33
bitsRequired128(UInt128.zero, UInt128.fromInt(1) << 100); // 101
The unsigned helpers and bool
Fixed-width conveniences. The 8/16/32/64-bit helpers take plain ints;
serializeUint128 is always 128 bits on the UInt128 pair, low 64-bit
half first. serializeBool is one bit.
w.serializeUint8(Ref(0x7f));
w.serializeUint16(Ref(0x1234));
w.serializeUint32(Ref(0x12345678));
w.serializeUint64(Ref(0x123456789abcdef0));
w.serializeUint128(Ref((UInt128.fromInt(1) << 100) + UInt128.fromInt(1)));
w.serializeBool(Ref(true));
Unsigned 64-bit values ride bit-transparently in Dart's signed int:
0xFFFFFFFFFFFFFFFF and -1 are the same bits, and the wire neither
knows nor cares. The Int128 / UInt128 pairs are two's complement
(hi, lo) halves with the arithmetic, shift and comparison operators the
serialize surface needs; fromInt sign-extends, toSigned() /
toUnsigned() reinterpret bit-transparently.
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 frounded
values or you'll be surprised by the read-back.
w.serializeFloat(Ref(fround(3.1415926))); // float32 on the wire
w.serializeDouble(Ref(1.0 / 3.0));
w.serializeFloat(Ref(-0.0)); // -0 round trips as -0
The bit-transparency helpers are exported for code that works with raw
patterns: float32BitsFromDouble / doubleFromFloat32Bits,
float64BitsFromDouble / doubleFromFloat64Bits, and fround — the
float32 rounding boundary the compressed float arithmetic is pinned to.
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 (every step passes through
fround — the roundings are part of the format, and the family's
discriminating vectors pin the decoded bit patterns exactly).
w.serializeCompressedFloat(Ref(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; writing a non-finite
value is a contract violation (asserted in debug, clamped in release);
reads refuse integers smuggled above the quantization ceiling.
Nested objects
serializeObject(object) runs a nested Serializable's own serialize
function inline. It is composition, not an encoding: it contributes no
bytes of its own, and inserts no framing, length prefix or alignment
around what the nested object writes.
class Header implements Serializable {
final sequence = Ref<int>(0);
final reliable = Ref<bool>(false);
@override
bool serialize(BitStream stream) =>
stream.serializeBits(sequence, 16) && stream.serializeBool(reliable);
}
class Packet {
final header = Header();
final payload = Ref<int>(0);
}
bool serializePacket(BitStream stream, Packet packet) =>
stream.serializeObject(packet.header) &&
stream.serializeBits(packet.payload, 8);
Those 25 bits are exactly the bits the same three operations write unnested. A refusal inside a nested object propagates out of it, and on a read stream the failure state is consulted before the object is entered, so a nested object on an already failed stream refuses without running.
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 Uint8List you pass is filled in place.
w.serializeBytes(Uint8List.fromList([0xde, 0xad, 0xbe, 0xef]));
// ...
final out = Uint8List(4);
r.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.
w.serializeString(Ref('golden'), 16);
final s = Ref('');
r.serializeString(s, 16); // s.value == 'golden'
Reads validate the payload in every build mode: malformed UTF-8
(overlongs, surrogate code points, values above U+10FFFF, truncated
sequences, stray continuations) and interior NULs fail the read. A Dart
string carrying an unpaired surrogate is a writer contract violation
(debug-asserted): utf8.encode would replace it, so it cannot reach the
wire faithfully.
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 Dart 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.
w.serializeWideString(Ref('\u{1f600}A'), 8); // 3 code units: 99 bits
Unpaired surrogates in a written string are a contract violation (debug-asserted — 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. 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.
The domain is 0 to - 1 inclusive (intRelativeMax), for both
previous and current. It belongs to the operation, not to Dart's int:
a previous of is caller error exactly as a negative one is. The
reader reconstructs current in Dart's 64-bit int, which cannot wrap,
then checks the result against the domain and against previous — in the
one-bit tier, in each of the five bounded tiers, and in the absolute tier,
whose 32 raw bits are read unsigned, so a group with its top bit set is
outside the domain and refused. A refused read leaves your Ref
untouched, and latches the stream.
w.serializeIntRelative(100, Ref(101)); // 1 bit
w.serializeIntRelative(100, Ref(2100)); // a mid-ladder tier
// read side: pass the same previous, get current back
r.serializeIntRelative(100, seq); // seq.value == 101
previous is caller state, not wire: both sides already know it. Writing
current <= previous, or a previous outside the domain, is a contract
violation, asserted in checked builds. A caller with a wrapping counter
unwraps it before serializing: wrap-around is not an encoding this
operation carries.
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 or 64; the sign bit counts
toward integerBits). min and max are in whole units, part of
the message format. serializeFixed128 is the 128-bit storage
counterpart: integerBits + fractionBits must equal 128, and the value
is a Ref<Int128>.
// -3.25 in Q8.8 over [-100, +100] whole units: raw is -3.25 * 256 = -832
w.serializeFixed(Ref(-832), 8, 8, -100, 100); // 16 bits
// 1234.5 in Q16.16 over [-2000, +2000]
w.serializeFixed(Ref(1234 * 65536 + 32768), 16, 16, -2000, 2000);
// 12345.5 in Q48.16 over [-100000, +100000]: 64-bit storage
w.serializeFixed(Ref(12345 * 65536 + 32768), 48, 16, -100000, 100000); // 34 bits
// Q64.64 over [-1000, +1000] whole units: 128-bit storage
final q6464 = Ref(Int128.fromInt(1) << 64); // exactly 1.0 in Q64.64
w.serializeFixed128(q6464, 64, 64, -1000, 1000);
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. Signedness never reaches
the wire: for the same bounds, signed and unsigned storage produce
identical bytes. Reads refuse raw values smuggled into the bit headroom;
an invalid declaration is caller misuse, asserted in debug.
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 on
Dart's native 64-bit integers — and are exported for code that wants raw
bitpacking without the serialize surface or its checks:
final bw = BitWriter(buffer);
bw.writeBits(5, 3);
bw.writeAlign();
bw.writeBytes(Uint8List.fromList([1, 2, 3]));
bw.flushBits();
final br = BitReader(bw.data());
br.readBits(3); // 5
br.readAlign(); // true: padding was zero
final out = Uint8List(3);
br.readBytes(out); // out is now [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 in every family implementation.
This is not aspiration but pinned fact. conformance/ holds the shared
corpus, vendored verbatim from the C++ reference and synced by CI, and the
test suite drives every vector in it through this reader — accepted
vectors must yield the stated value and consume the stated bits, refused
vectors must refuse. Beside it the suite carries the family's golden
vectors — including the 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 canonical C++
reference'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.