Skip to content

Serialization and Deserialization

Each schema has the following methods:

/**
* Writes the value (according to the schema's structure) at the cursor.
*/
write(cursor: ByteCursor, value: TIn): void;
/**
* Reads a value (according to the schema's structure) at the cursor.
*/
read(cursor: ByteCursor): TOut;
/**
* Returns the number of bytes the value takes up when encoded. Given `MaxValue`, returns the
* maximum number of bytes any value can take up, which is `NaN` if the schema is unbounded.
*/
measure(value: TIn | MaxValue): number;

TIn is the type of values the schema accepts when writing, and TOut the type of values it produces when reading. They can be extracted from any schema with bin.ExtractIn<typeof Schema> and bin.ExtractOut<typeof Schema>. They usually only differ in that TIn is readonly, so that readonly data can be written too.

Schemas read from and write to a bin.ByteCursor: a DataView over the buffer, the byte offset at which to read or write next, and the endianness to use. bin.cursor() creates one:

import * as bin from 'typed-binary';
// Creating a fixed-length buffer of arbitrary size (64 bytes).
const buffer = new ArrayBuffer(64); // Or Buffer.alloc(64); in Node.js
// Writing four bytes into the buffer
const writer = bin.cursor(buffer);
bin.u8.write(writer, 'W'.charCodeAt(0));
bin.u8.write(writer, 'o'.charCodeAt(0));
bin.u8.write(writer, 'w'.charCodeAt(0));
bin.u8.write(writer, 0);
const reader = bin.cursor(buffer);
console.log(bin.string.read(reader)); // > Wow

Each read or write advances the cursor’s offset, which is why separate cursors are used above for writing and reading. The offset can also be set directly, to seek (reader.offset = 0) or to skip bytes (reader.offset += 4).

bin.cursor() accepts an ArrayBuffer, or a view of one (like a typed array or a Node.js Buffer), in which case offsets count from the start of that view. It also takes options:

const cursor = bin.cursor(buffer, {
byteOffset: 4, // The offset to start at. Defaults to 0.
endianness: 'big', // 'big', 'little' or 'system'. Defaults to 'system'.
});

Since a cursor is a plain object, it can also be created by hand:

const cursor: bin.ByteCursor = { view: new DataView(buffer), offset: 0, littleEndian: true };

A DataView can only view an ArrayBuffer (or a SharedArrayBuffer), so data meant for a stream is first written into a buffer. A resizable ArrayBuffer can grow as needed, and a DataView created without a length follows its size:

const buffer = new ArrayBuffer(1024, { maxByteLength: 64 * 1024 });
const cursor = bin.cursor(buffer);
function send(message: bin.ExtractIn<typeof Message>) {
const size = Message.measure(message);
if (buffer.byteLength < cursor.offset + size) {
buffer.resize(cursor.offset + size);
}
Message.write(cursor, message);
}
function flush() {
stream.write(new Uint8Array(buffer.slice(0, cursor.offset)));
cursor.offset = 0;
}

Creating a buffer with the most optimal size

Section titled “Creating a buffer with the most optimal size”

Schemas can measure how many bytes a particular value will take up, which can be used to create a buffer that will fit that value perfectly.

import * as bin from 'typed-binary';
export const PlayerUpdatePacket = bin.struct({
id: bin.u32,
x: bin.f32,
y: bin.f32,
});
const packet = {
id: 0,
x: 2.4,
y: 1.6,
} as const;
const packetSize = PlayerUpdatePacket.measure(packet);
const buffer = new ArrayBuffer(packetSize);

Creating a shared buffer that fits all possible values

Section titled “Creating a shared buffer that fits all possible values”

If a schema’s size is bounded (there is a max size that no value encodings will surpass), we can create a shared buffer of the maximum size the schema can take. Unbounded schemas (e.g. ones containing a bin.string or a bin.dynamicArray, or recursive ones) have a max size of NaN.

import * as bin from 'typed-binary';
export const PlayerUpdatePacket = bin.struct({
id: bin.u32,
x: bin.f32,
y: bin.f32,
});
const maxPacketSize = PlayerUpdatePacket.measure(bin.MaxValue); // or PlayerUpdatePacket.maxSize
const sharedBuffer = new ArrayBuffer(maxPacketSize);

Mistakes in how schemas are defined (e.g. passing something that isn’t a schema to bin.struct) throw an InvalidSchemaShapeError. Values that don’t match their schema when writing or measuring (e.g. an array of the wrong length, or a value that isn’t part of an enum), and malformed data when reading (e.g. an unknown union variant or enum index), throw a ValidationError.

These checks run in production too, but their detailed messages are left out of production builds (where bundlers replace process.env.NODE_ENV with 'production'), so that they don’t add to the size of your app. The errors then have a generic message instead.