Custom Serialization Patterns
Overview
While Sia's built-in add* and read* methods cover all primitive types, real-world applications require serializing complex structures: nested objects, optional fields, discriminated unions, and evolving schemas. This guide covers patterns for building maintainable serialization logic.
Embedding Sia Instances
The embedSia method lets you compose serialized data from multiple Sia instances. This is useful for building packets with separately constructed headers and bodies:
import { Sia } from "@timeleap/sia";
// Build the header
const header = new Sia();
header.addUInt8(1).addUInt16(42).addUInt64(Date.now());
// Build the body
const body = new Sia();
body.addString16("Hello from the body");
// Compose into a single buffer
const packet = new Sia();
packet.embedSia(header).embedSia(body);
const bytes = packet.toUint8Array();
You can also embed raw bytes with embedBytes:
const checksum = computeChecksum(payload);
packet.embedBytes(payload).addByteArray8(checksum);
embedSia copies the written portion of the source Sia (from offset 0 to the
current write position). The source instance is not modified.Creating Custom Serializers
Pattern 1: Type-Safe Functions
The simplest approach: define a pair of arrow functions per type:
interface Sensor {
id: number;
name: string;
value: number;
timestamp: number;
}
const serializeSensor = (sia: Sia, sensor: Sensor): void => {
sia
.addUInt16(sensor.id)
.addAscii8(sensor.name)
.addInt32(sensor.value)
.addUInt64(sensor.timestamp);
};
const deserializeSensor = (sia: Sia): Sensor => ({
id: sia.readUInt16(),
name: sia.readAscii8(),
value: sia.readInt32(),
timestamp: sia.readUInt64(),
});
// Use directly
const sia = new Sia();
serializeSensor(sia, {
id: 1,
name: "temp",
value: 2350,
timestamp: Date.now(),
});
sia.seek(0);
const sensor = deserializeSensor(sia);
// Use with arrays
const sia2 = new Sia();
sia2.addArray16(sensors, serializeSensor);
sia2.seek(0);
const decoded = sia2.readArray16(deserializeSensor);
Pattern 2: Class-Based Serializer
Encapsulate buffer management and provide a clean public API:
class SensorSerializer {
private writer = new Sia();
serialize(sensor: Sensor): Uint8Array {
this.writer.seek(0);
serializeSensor(this.writer, sensor);
return this.writer.toUint8Array();
}
deserialize(data: Uint8Array): Sensor {
const reader = new Sia(data);
return deserializeSensor(reader);
}
serializeBatch(sensors: Sensor[]): Uint8Array {
this.writer.seek(0);
this.writer.addArray32(sensors, serializeSensor);
return this.writer.toUint8Array();
}
deserializeBatch(data: Uint8Array): Sensor[] {
const reader = new Sia(data);
return reader.readArray32(deserializeSensor);
}
}
Pattern 3: Generic Codec Helper
A reusable codec type that works with any data shape:
interface Codec<T> {
serialize: (sia: Sia, value: T) => void;
deserialize: (sia: Sia) => T;
}
const createCodec = <T>(
serialize: (sia: Sia, value: T) => void,
deserialize: (sia: Sia) => T,
): Codec<T> => ({ serialize, deserialize });
const sensorCodec = createCodec<Sensor>(serializeSensor, deserializeSensor);
// Generic encode/decode
const encode = <T>(value: T, codec: Codec<T>): Uint8Array => {
const sia = new Sia();
codec.serialize(sia, value);
return sia.toUint8Array();
};
const decode = <T>(data: Uint8Array, codec: Codec<T>): T => {
const sia = new Sia(data);
return codec.deserialize(sia);
};
Complex Data Structures
Nested Objects
Serialize nested objects by calling inner serializers from outer ones:
interface Address {
street: string;
city: string;
zip: string;
country: string;
}
interface Company {
name: string;
address: Address;
employeeCount: number;
}
interface Employee {
id: number;
name: string;
company: Company;
skills: string[];
}
// Layer serializers from inner to outer
const serializeAddress = (sia: Sia, addr: Address): void => {
sia
.addString8(addr.street)
.addString8(addr.city)
.addAscii8(addr.zip)
.addAscii8(addr.country);
};
const deserializeAddress = (sia: Sia): Address => ({
street: sia.readString8(),
city: sia.readString8(),
zip: sia.readAscii8(),
country: sia.readAscii8(),
});
const serializeCompany = (sia: Sia, co: Company): void => {
sia.addString8(co.name);
serializeAddress(sia, co.address);
sia.addUInt32(co.employeeCount);
};
const deserializeCompany = (sia: Sia): Company => ({
name: sia.readString8(),
address: deserializeAddress(sia),
employeeCount: sia.readUInt32(),
});
const serializeEmployee = (sia: Sia, emp: Employee): void => {
sia.addUInt32(emp.id).addString8(emp.name);
serializeCompany(sia, emp.company);
sia.addArray8(emp.skills, (s, skill) => s.addString8(skill));
};
const deserializeEmployee = (sia: Sia): Employee => ({
id: sia.readUInt32(),
name: sia.readString8(),
company: deserializeCompany(sia),
skills: sia.readArray8((s) => s.readString8()),
});
Optional Fields
Use a boolean flag to indicate the presence of optional data:
interface UserProfile {
name: string;
email: string;
bio: string | null;
avatarUrl: string | null;
phoneNumber: string | null;
}
const serializeProfile = (sia: Sia, profile: UserProfile): void => {
sia.addString8(profile.name).addString8(profile.email);
// Each optional field gets a presence flag
sia.addBool(profile.bio !== null);
if (profile.bio !== null) {
sia.addString16(profile.bio);
}
sia.addBool(profile.avatarUrl !== null);
if (profile.avatarUrl !== null) {
sia.addString8(profile.avatarUrl);
}
sia.addBool(profile.phoneNumber !== null);
if (profile.phoneNumber !== null) {
sia.addAscii8(profile.phoneNumber);
}
};
const deserializeProfile = (sia: Sia): UserProfile => {
const name = sia.readString8();
const email = sia.readString8();
const hasBio = sia.readBool();
const bio = hasBio ? sia.readString16() : null;
const hasAvatar = sia.readBool();
const avatarUrl = hasAvatar ? sia.readString8() : null;
const hasPhone = sia.readBool();
const phoneNumber = hasPhone ? sia.readAscii8() : null;
return { name, email, bio, avatarUrl, phoneNumber };
};
UInt8 bitmask.Discriminated Unions
Use a type tag byte to distinguish between variants:
interface Circle {
type: "circle";
radius: number;
}
interface Rectangle {
type: "rectangle";
width: number;
height: number;
}
interface Triangle {
type: "triangle";
a: number;
b: number;
c: number;
}
type Shape = Circle | Rectangle | Triangle;
const SHAPE_TAGS = { circle: 0, rectangle: 1, triangle: 2 } as const;
const SHAPE_NAMES = ["circle", "rectangle", "triangle"] as const;
const serializeShape = (sia: Sia, shape: Shape): void => {
sia.addUInt8(SHAPE_TAGS[shape.type]);
switch (shape.type) {
case "circle":
sia.addUInt32(shape.radius);
break;
case "rectangle":
sia.addUInt32(shape.width).addUInt32(shape.height);
break;
case "triangle":
sia.addUInt32(shape.a).addUInt32(shape.b).addUInt32(shape.c);
break;
}
};
const deserializeShape = (sia: Sia): Shape => {
const tag = sia.readUInt8();
const type = SHAPE_NAMES[tag];
switch (type) {
case "circle":
return { type, radius: sia.readUInt32() };
case "rectangle":
return { type, width: sia.readUInt32(), height: sia.readUInt32() };
case "triangle":
return {
type,
a: sia.readUInt32(),
b: sia.readUInt32(),
c: sia.readUInt32(),
};
default:
throw new Error(`Unknown shape tag: ${tag}`);
}
};
Maps and Dictionaries
Serialize key-value maps by converting to an array of entries:
const serializeMap = (sia: Sia, map: Map<string, number>): void => {
const entries = Array.from(map.entries());
sia.addArray16(entries, (s, [key, value]) => {
s.addAscii8(key).addInt32(value);
});
};
const deserializeMap = (sia: Sia): Map<string, number> => {
const entries = sia.readArray16((s) => {
const key = s.readAscii8();
const value = s.readInt32();
return [key, value] as [string, number];
});
return new Map(entries);
};
// Usage
const scores = new Map<string, number>();
scores.set("alice", 100);
scores.set("bob", 85);
scores.set("charlie", 92);
const sia = new Sia();
serializeMap(sia, scores);
sia.seek(0);
const decoded = deserializeMap(sia);
// Map { "alice" => 100, "bob" => 85, "charlie" => 92 }
For object dictionaries (Record<string, T>), convert to entries first:
const serializeRecord = (sia: Sia, record: Record<string, number>): void => {
const entries = Object.entries(record);
sia.addArray16(entries, (s, [key, value]) => {
s.addAscii8(key).addInt32(value);
});
};
const deserializeRecord = (sia: Sia): Record<string, number> => {
const entries = sia.readArray16((s) => {
const key = s.readAscii8();
const value = s.readInt32();
return [key, value] as [string, number];
});
return Object.fromEntries(entries);
};
Versioning and Evolution
Schema Versioning
Prefix serialized data with a version byte to support schema evolution:
const CURRENT_VERSION = 3;
interface Config {
name: string;
maxRetries: number;
timeout: number;
// Added in v2
enableLogging: boolean;
// Added in v3
logLevel: number;
tags: string[];
}
const serializeConfig = (sia: Sia, config: Config): void => {
sia.addUInt8(CURRENT_VERSION);
sia.addString8(config.name);
sia.addUInt8(config.maxRetries);
sia.addUInt32(config.timeout);
// v2 fields
sia.addBool(config.enableLogging);
// v3 fields
sia.addUInt8(config.logLevel);
sia.addArray8(config.tags, (s, tag) => s.addString8(tag));
};
const deserializeConfig = (sia: Sia): Config => {
const version = sia.readUInt8();
const name = sia.readString8();
const maxRetries = sia.readUInt8();
const timeout = sia.readUInt32();
// v2 fields with defaults for v1 data
const enableLogging = version >= 2 ? sia.readBool() : false;
// v3 fields with defaults for v1/v2 data
const logLevel = version >= 3 ? sia.readUInt8() : 0;
const tags = version >= 3 ? sia.readArray8((s) => s.readString8()) : [];
return { name, maxRetries, timeout, enableLogging, logLevel, tags };
};
Forward Compatibility
To support forward compatibility (old readers handling new data), include a size prefix so unknown fields can be skipped:
const serializeWithSize = (sia: Sia, config: Config): void => {
// Reserve space for the total size
const sizeOffset = sia.offset;
sia.addUInt32(0); // placeholder
const dataStart = sia.offset;
sia.addUInt8(CURRENT_VERSION);
serializeConfig(sia, config);
const dataEnd = sia.offset;
// Write the actual size back
const totalSize = dataEnd - dataStart;
const savedOffset = sia.offset;
sia.seek(sizeOffset);
sia.addUInt32(totalSize);
sia.seek(savedOffset);
};
const deserializeWithSize = (sia: Sia): Config => {
const totalSize = sia.readUInt32();
const startOffset = sia.offset;
const config = deserializeConfig(sia);
// Skip any unread bytes from a newer version
sia.seek(startOffset + totalSize);
return config;
};
Performance Optimization
Reusing Instances
For high-throughput scenarios, avoid creating new Sia instances:
class HighThroughputSerializer {
private writer = new Sia();
private reader = new Sia(new Uint8Array(0));
serialize(sensor: Sensor): Uint8Array {
this.writer.seek(0);
serializeSensor(this.writer, sensor);
return this.writer.toUint8Array();
}
deserialize(data: Uint8Array): Sensor {
this.reader.setContent(data);
return deserializeSensor(this.reader);
}
}
Pre-Allocating Buffers
When you know the maximum size of your data, allocate once:
// Sensor: 2 (id) + 1 + 32 (name max) + 4 (value) + 8 (timestamp) = 47 bytes max
const SENSOR_MAX_SIZE = 64; // round up for safety
const serializeSensorFast = (sensor: Sensor): Uint8Array => {
const sia = Sia.allocUnsafe(SENSOR_MAX_SIZE);
serializeSensor(sia, sensor);
return sia.toUint8Array();
};
Batch Processing
Process arrays of items efficiently:
const serializeSensorBatch = (sensors: Sensor[]): Uint8Array => {
const sia = new Sia();
sia.addArray32(sensors, serializeSensor);
return sia.toUint8Array();
};
const deserializeSensorBatch = (data: Uint8Array): Sensor[] => {
const sia = new Sia(data);
return sia.readArray32(deserializeSensor);
};
Error Handling
Validation Before Serialization
Validate data before serializing to catch issues early:
const validateSensor = (sensor: Sensor): void => {
if (sensor.id < 0 || sensor.id > 65535) {
throw new Error(`Sensor ID out of UInt16 range: ${sensor.id}`);
}
if (sensor.name.length > 255) {
throw new Error(`Sensor name too long for ASCII: ${sensor.name.length}`);
}
};
const safeSerializeSensor = (sia: Sia, sensor: Sensor): void => {
validateSensor(sensor);
serializeSensor(sia, sensor);
};
Try-Catch Wrapper
Wrap deserialization with error context:
const safeDeserialize = <T>(
data: Uint8Array,
deserializer: (sia: Sia) => T,
label: string,
): T => {
try {
const sia = new Sia(data);
return deserializer(sia);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(
`Failed to deserialize ${label} (${data.length} bytes): ${message}`,
);
}
};
// Usage
const sensor = safeDeserialize(bytes, deserializeSensor, "Sensor");