Deserialization
Overview
Deserialization in Sia means reading typed values back from a byte buffer using read* methods. Each read* method:
- Checks that enough bytes remain in the buffer
- Reads the value at the current offset
- Advances the offset by the number of bytes consumed
Since Sia's binary format contains no field names or type markers, you must read values in the exact same order they were written. The read types must also match the write types: reading a UInt32 where a UInt16 was written will produce incorrect results.
Basic Reading
Create a Sia instance from existing bytes and read values in order:
import { Sia } from "@timeleap/sia";
// Assume `bytes` was produced by:
// sia.addString8("Alice").addUInt8(30).addBool(true)
const sia = new Sia(bytes);
const name = sia.readString8(); // "Alice"
const age = sia.readUInt8(); // 30
const active = sia.readBool(); // true
Or reuse the same instance by seeking back to the start:
const sia = new Sia();
sia.addUInt32(42).addString8("hello");
// Seek to the beginning to read back
sia.seek(0);
const value = sia.readUInt32(); // 42
const text = sia.readString8(); // "hello"
Seeking and Skipping
seek(offset): Absolute Position
Jump to a specific byte position in the buffer:
const sia = new Sia(bytes);
sia.seek(0); // go to the beginning
sia.seek(10); // jump to byte 10
skip(bytes): Relative Advance
Advance the offset by a given number of bytes:
const sia = new Sia(bytes);
// Skip past a UInt32 (4 bytes) without reading it
sia.skip(4);
// Read the next value
const name = sia.readString8();
Both methods return this for chaining:
const value = sia.seek(0).skip(4).readUInt16();
Common Use Case: Skip to a Known Field
If you know the byte layout, you can skip directly to the field you need:
// Layout: [version: UInt8][timestamp: UInt64][name: String8]
// To read just the name, skip the first 9 bytes (1 + 8)
sia.seek(0).skip(9);
const name = sia.readString8();
Reading with setContent
You can reuse a Sia instance by swapping its underlying buffer with setContent. This avoids creating new objects in hot loops:
const sia = new Sia();
for (const message of incomingMessages) {
sia.setContent(message);
// offset is automatically reset to 0
const type = sia.readUInt8();
const payload = sia.readString16();
handleMessage(type, payload);
}
setContent replaces the internal content, size, dataView, and resets offset to 0.
Error Handling
Every read* method performs a bounds check before reading. If there isn't enough data remaining in the buffer, it throws an Error with a descriptive message:
const sia = new Sia(new Uint8Array(0));
try {
sia.readUInt8();
} catch (e) {
console.error(e.message); // "Not enough data to read uint8"
}
Error Messages by Type
| Method | Error Message |
|---|---|
readUInt8 | "Not enough data to read uint8" |
readInt8 | "Not enough data to read int8" |
readUInt16 | "Not enough data to read uint16" |
readInt16 | "Not enough data to read int16" |
readUInt32 | "Not enough data to read uint32" |
readInt32 | "Not enough data to read int32" |
readUInt64 | "Not enough data to read uint64" |
readInt64 | "Not enough data to read int64" |
readByteArrayN | "Not enough data to read byte array" |
Every read* method checks bounds before accessing data, so you don't need to do it manually.
Reading Integers
All multi-byte integers are read in little-endian byte order, matching how they were written.
const u8 = sia.readUInt8(); // 1 byte
const u16 = sia.readUInt16(); // 2 bytes, little-endian
const u32 = sia.readUInt32(); // 4 bytes, little-endian
const u64 = sia.readUInt64(); // 8 bytes, little-endian
const i8 = sia.readInt8(); // 1 byte
const i16 = sia.readInt16(); // 2 bytes, little-endian
const i32 = sia.readInt32(); // 4 bytes, little-endian
const i64 = sia.readInt64(); // 8 bytes, little-endian
Implementation Detail
Here's how readUInt32 works internally, showing the bounds check and DataView read:
readUInt32(): number {
if (this.offset + 4 > this.content.length) {
throw new Error("Not enough data to read uint32");
}
const value = this.dataView.getUint32(this.offset, true);
this.offset += 4;
return value;
}
Reading Strings
String read methods decode the length prefix, read that many bytes, and decode them back to a JavaScript string:
const s8 = sia.readString8(); // 8-bit length prefix
const s16 = sia.readString16(); // 16-bit length prefix
const s32 = sia.readString32(); // 32-bit length prefix
const s64 = sia.readString64(); // 64-bit length prefix
// Optimized ASCII decoder: faster than TextDecoder
const fixed = sia.readAsciiN(3); // no length prefix, caller specifies length
const ascii = sia.readAscii8(); // 8-bit length prefix
const ascii16 = sia.readAscii16(); // 16-bit length prefix
// Decompresses UTFZ-encoded string
const utfz = sia.readUtfz(); // 8-bit length prefix
Internally, readString8 reads a byte array and decodes it with TextDecoder:
readString8(): string {
const bytes = this.readByteArray8(true);
return this.decoder.decode(bytes);
}
Note that readString* methods pass asReference = true internally to avoid an unnecessary copy: the TextDecoder will produce a new string regardless.
Reading Byte Arrays
Byte array read methods support an optional asReference parameter that controls whether the returned Uint8Array is a copy or a view of the internal buffer.
Default (Copy)
const data = sia.readByteArray8(); // returns a new Uint8Array (copy)
The returned array is independent of the Sia buffer. Safe to store, modify, or pass to other APIs.
Zero-Copy Reference
const ref = sia.readByteArray8(true); // returns a subarray (view)
The returned array shares memory with the Sia buffer. Faster, but modifications to the buffer will affect the reference.
asReference = true, the returned Uint8Array is a view into the
Sia buffer's underlying ArrayBuffer. If the buffer is reused (e.g., with
setContent or by writing new data), the reference will see the new data.
Only use references for temporary, immediate reads.All Byte Array Variants
// With length prefix
const ba8 = sia.readByteArray8(); // 8-bit length prefix
const ba16 = sia.readByteArray16(); // 16-bit length prefix
const ba32 = sia.readByteArray32(); // 32-bit length prefix
const ba64 = sia.readByteArray64(); // 64-bit length prefix
// Fixed length, no prefix
const fixed = sia.readByteArrayN(16); // read exactly 16 bytes
// All accept asReference
const ref = sia.readByteArray32(true); // zero-copy
Reading Booleans and BigInts
Booleans
const flag = sia.readBool(); // true or false
Reads a single byte and returns true if the value is 1, false otherwise.
BigInts
const big = sia.readBigInt(); // bigint value
Reads an 8-bit length-prefixed byte array and converts it back to a bigint:
readBigInt(): bigint {
const bytes = this.readByteArray8();
let hex = "";
bytes.forEach((byte) => {
hex += byte.toString(16).padStart(2, "0");
});
return BigInt("0x" + hex);
}
Reading Arrays
Array read methods accept a deserializer function that is called once per element:
import { Sia } from "@timeleap/sia";
// Read an array of UInt16 values
const scores = sia.readArray8((s) => s.readUInt16());
// TypeScript infers: number[]
interface Player {
name: string;
score: number;
alive: boolean;
}
function readPlayer(sia: Sia): Player {
return {
name: sia.readString8(),
score: sia.readUInt32(),
alive: sia.readBool(),
};
}
const players = sia.readArray8(readPlayer);
// TypeScript infers: Player[]
interface Team {
name: string;
members: string[];
}
function readTeam(sia: Sia): Team {
return {
name: sia.readString8(),
members: sia.readArray8((s) => s.readString8()),
};
}
const teams = sia.readArray16(readTeam);
// TypeScript infers: Team[]
Array Method Variants
| Method | Max Items | Length Prefix |
|---|---|---|
readArray8 | 255 | 1 byte |
readArray16 | 65,535 | 2 bytes |
readArray32 | ~4 billion | 4 bytes |
readArray64 | 2^53 - 1 | 8 bytes |
The type parameter T is inferred from your deserializer function's return type: no explicit type annotation is needed.
Practical Example: Deserializing a Complete Message
Here's a complete example that pairs with a serializer to handle a structured message format:
import { Sia } from "@timeleap/sia";
interface Message {
version: number;
timestamp: number;
sender: string;
recipient: string;
body: string;
attachments: Uint8Array[];
priority: number;
encrypted: boolean;
}
function deserializeMessage(bytes: Uint8Array): Message {
const sia = new Sia(bytes);
return {
version: sia.readUInt8(),
timestamp: sia.readUInt64(),
sender: sia.readString8(),
recipient: sia.readString8(),
body: sia.readString32(),
attachments: sia.readArray8((s) => s.readByteArray32()),
priority: sia.readUInt8(),
encrypted: sia.readBool(),
};
}
// Corresponding serializer for reference
function serializeMessage(msg: Message): Uint8Array {
const sia = new Sia();
sia
.addUInt8(msg.version)
.addUInt64(msg.timestamp)
.addString8(msg.sender)
.addString8(msg.recipient)
.addString32(msg.body)
.addArray8(msg.attachments, (s, att) => s.addByteArray32(att))
.addUInt8(msg.priority)
.addBool(msg.encrypted);
return sia.toUint8Array();
}
// Round-trip test
const original: Message = {
version: 1,
timestamp: Date.now(),
sender: "alice",
recipient: "bob",
body: "Hello from Sia!",
attachments: [new Uint8Array([0x01, 0x02, 0x03])],
priority: 5,
encrypted: false,
};
const bytes = serializeMessage(original);
const decoded = deserializeMessage(bytes);
// decoded deeply equals original
Common Patterns
Process data as it arrives by reading incrementally from a shared Sia instance:
const reader = new Sia();
function onDataReceived(chunk: Uint8Array): void {
reader.setContent(chunk);
// Read the message type to determine how to parse the rest
const messageType = reader.readUInt8();
switch (messageType) {
case 0x01:
handlePing(reader);
break;
case 0x02:
handleData(reader);
break;
case 0x03:
handleClose(reader);
break;
}
}
function handlePing(sia: Sia): void {
const timestamp = sia.readUInt64();
sendPong(timestamp);
}
function handleData(sia: Sia): void {
const channel = sia.readString8();
const payload = sia.readByteArray32();
processPayload(channel, payload);
}
This avoids allocating a new Sia instance for every incoming message.
Read only the fields you need by seeking and skipping past unwanted data:
// Layout: [type: UInt8][priority: UInt8][timestamp: UInt64][sender: String8][body: String32]
// We only need the type and priority
function readHeader(bytes: Uint8Array): { type: number; priority: number } {
const sia = new Sia(bytes);
return {
type: sia.readUInt8(),
priority: sia.readUInt8(),
};
// We never read timestamp, sender, or body -- saving time and allocations
}
For fixed-size fields, you can also use skip to jump to a known offset:
// Read just the sender (starts at byte 10: 1 + 1 + 8)
function readSender(bytes: Uint8Array): string {
const sia = new Sia(bytes);
sia.skip(10); // skip type + priority + timestamp
return sia.readString8();
}
Wrap deserialization in try/catch to handle corrupted or truncated data gracefully:
function safeParse(bytes: Uint8Array): Message | null {
try {
return deserializeMessage(bytes);
} catch (e) {
if (e instanceof Error) {
console.error("Deserialization failed:", e.message);
// e.message will be something like "Not enough data to read uint32"
}
return null;
}
}
You can also validate the data after reading by checking a version field or magic bytes:
function parseWithValidation(bytes: Uint8Array): Message {
const sia = new Sia(bytes);
const version = sia.readUInt8();
if (version !== 1 && version !== 2) {
throw new Error(`Unsupported message version: ${version}`);
}
// Continue reading based on version...
const timestamp = sia.readUInt64();
const sender = sia.readString8();
// ...
}