TypeScript Usage
Overview
Sia is written in TypeScript with strict mode enabled. Its generic array methods (addArray8<T>, readArray8<T>, etc.) enable fully type-safe serialization and deserialization. This guide covers patterns for using TypeScript's type system with Sia.
Type Definitions
Define interfaces for the data you want to serialize:
interface User {
id: number;
name: string;
email: string;
age: number;
active: boolean;
}
Then write symmetric serialize/deserialize functions:
import { Sia } from "@timeleap/sia";
const serializeUser = (sia: Sia, user: User): void => {
sia
.addUInt32(user.id)
.addString8(user.name)
.addString8(user.email)
.addUInt8(user.age)
.addBool(user.active);
};
const deserializeUser = (sia: Sia): User => ({
id: sia.readUInt32(),
name: sia.readString8(),
email: sia.readString8(),
age: sia.readUInt8(),
active: sia.readBool(),
});
These functions also serve as callbacks for typed array serialization:
const sia = new Sia();
const users: User[] = [
{ id: 1, name: "Alice", email: "alice@example.com", age: 30, active: true },
{ id: 2, name: "Bob", email: "bob@example.com", age: 25, active: false },
];
sia.addArray16(users, serializeUser);
sia.seek(0);
const decoded: User[] = sia.readArray16(deserializeUser);
Serializing Typed Objects
Basic Interface Pattern
The simplest approach is to write a pair of functions for each type:
interface Point {
x: number;
y: number;
z: number;
}
const serializePoint = (sia: Sia, point: Point): void => {
sia.addInt32(point.x).addInt32(point.y).addInt32(point.z);
};
const deserializePoint = (sia: Sia): Point => ({
x: sia.readInt32(),
y: sia.readInt32(),
z: sia.readInt32(),
});
Generic Codec Functions
For a reusable pattern, define generic encode/decode wrappers:
const encode = <T>(
value: T,
serializer: (sia: Sia, value: T) => void,
): Uint8Array => {
const sia = new Sia();
serializer(sia, value);
return sia.toUint8Array();
};
const decode = <T>(data: Uint8Array, deserializer: (sia: Sia) => T): T => {
const sia = new Sia(data);
return deserializer(sia);
};
// Usage
const bytes = encode(myUser, serializeUser);
const user = decode(bytes, deserializeUser);
Type-Safe Serializer Classes
Pattern: Serializer Class
Encapsulate serialization logic for a type in a class:
class UserSerializer {
private sia = new Sia();
serialize(user: User): Uint8Array {
this.sia.seek(0);
this.sia
.addUInt32(user.id)
.addString8(user.name)
.addString8(user.email)
.addUInt8(user.age)
.addBool(user.active);
return this.sia.toUint8Array();
}
deserialize(data: Uint8Array): User {
const sia = new Sia(data);
return {
id: sia.readUInt32(),
name: sia.readString8(),
email: sia.readString8(),
age: sia.readUInt8(),
active: sia.readBool(),
};
}
serializeMany(users: User[]): Uint8Array {
this.sia.seek(0);
this.sia.addArray16(users, (s, user) => {
s.addUInt32(user.id)
.addString8(user.name)
.addString8(user.email)
.addUInt8(user.age)
.addBool(user.active);
});
return this.sia.toUint8Array();
}
deserializeMany(data: Uint8Array): User[] {
const sia = new Sia(data);
return sia.readArray16((s) => ({
id: s.readUInt32(),
name: s.readString8(),
email: s.readString8(),
age: s.readUInt8(),
active: s.readBool(),
}));
}
}
Pattern: Abstract Base
For projects with many serializable types, define a base class:
abstract class Serializer<T> {
private sia = new Sia();
abstract writeTo(sia: Sia, value: T): void;
abstract readFrom(sia: Sia): T;
serialize(value: T): Uint8Array {
this.sia.seek(0);
this.writeTo(this.sia, value);
return this.sia.toUint8Array();
}
deserialize(data: Uint8Array): T {
const reader = new Sia(data);
return this.readFrom(reader);
}
serializeArray(items: T[]): Uint8Array {
this.sia.seek(0);
this.sia.addArray32(items, (s, item) => this.writeTo(s, item));
return this.sia.toUint8Array();
}
deserializeArray(data: Uint8Array): T[] {
const reader = new Sia(data);
return reader.readArray32((s) => this.readFrom(s));
}
}
// Implement for a specific type
class PointSerializer extends Serializer<Point> {
writeTo(sia: Sia, point: Point): void {
sia.addInt32(point.x).addInt32(point.y).addInt32(point.z);
}
readFrom(sia: Sia): Point {
return {
x: sia.readInt32(),
y: sia.readInt32(),
z: sia.readInt32(),
};
}
}
Advanced Type Patterns
Versioning with Enums
Use a version byte to handle schema evolution:
enum SchemaVersion {
V1 = 1,
V2 = 2,
}
interface UserV1 {
version: SchemaVersion.V1;
name: string;
age: number;
}
interface UserV2 {
version: SchemaVersion.V2;
name: string;
age: number;
email: string;
active: boolean;
}
type VersionedUser = UserV1 | UserV2;
const serializeVersionedUser = (sia: Sia, user: VersionedUser): void => {
sia.addUInt8(user.version);
sia.addString8(user.name).addUInt8(user.age);
if (user.version === SchemaVersion.V2) {
sia.addString8(user.email).addBool(user.active);
}
};
const deserializeVersionedUser = (sia: Sia): VersionedUser => {
const version = sia.readUInt8() as SchemaVersion;
const name = sia.readString8();
const age = sia.readUInt8();
if (version === SchemaVersion.V2) {
return {
version,
name,
age,
email: sia.readString8(),
active: sia.readBool(),
};
}
return { version: SchemaVersion.V1, name, age };
};
Discriminated Unions
Serialize tagged union types with a type discriminator byte:
interface TextMessage {
type: "text";
content: string;
}
interface ImageMessage {
type: "image";
width: number;
height: number;
data: Uint8Array;
}
interface AudioMessage {
type: "audio";
duration: number;
sampleRate: number;
data: Uint8Array;
}
type Message = TextMessage | ImageMessage | AudioMessage;
const MESSAGE_TYPES = { text: 0, image: 1, audio: 2 } as const;
const MESSAGE_TYPE_LOOKUP = ["text", "image", "audio"] as const;
const serializeMessage = (sia: Sia, msg: Message): void => {
sia.addUInt8(MESSAGE_TYPES[msg.type]);
switch (msg.type) {
case "text":
sia.addString16(msg.content);
break;
case "image":
sia.addUInt16(msg.width).addUInt16(msg.height).addByteArray32(msg.data);
break;
case "audio":
sia
.addUInt32(msg.duration)
.addUInt32(msg.sampleRate)
.addByteArray32(msg.data);
break;
}
};
const deserializeMessage = (sia: Sia): Message => {
const typeIndex = sia.readUInt8();
const type = MESSAGE_TYPE_LOOKUP[typeIndex];
switch (type) {
case "text":
return { type, content: sia.readString16() };
case "image":
return {
type,
width: sia.readUInt16(),
height: sia.readUInt16(),
data: sia.readByteArray32(),
};
case "audio":
return {
type,
duration: sia.readUInt32(),
sampleRate: sia.readUInt32(),
data: sia.readByteArray32(),
};
default:
throw new Error(`Unknown message type: ${typeIndex}`);
}
};
Generic Codec Pattern
Define a reusable codec type that bundles serialization and deserialization:
interface Codec<T> {
serialize: (sia: Sia, value: T) => void;
deserialize: (sia: Sia) => T;
}
// Create codecs for each type
const pointCodec: Codec<Point> = {
serialize: (sia, p) => {
sia.addInt32(p.x).addInt32(p.y).addInt32(p.z);
},
deserialize: (sia) => ({
x: sia.readInt32(),
y: sia.readInt32(),
z: sia.readInt32(),
}),
};
const userCodec: Codec<User> = {
serialize: (sia, user) => {
sia
.addUInt32(user.id)
.addString8(user.name)
.addString8(user.email)
.addUInt8(user.age)
.addBool(user.active);
},
deserialize: (sia) => ({
id: sia.readUInt32(),
name: sia.readString8(),
email: sia.readString8(),
age: sia.readUInt8(),
active: sia.readBool(),
}),
};
// Generic encode/decode using codecs
const encodeWith = <T>(value: T, codec: Codec<T>): Uint8Array => {
const sia = new Sia();
codec.serialize(sia, value);
return sia.toUint8Array();
};
const decodeWith = <T>(data: Uint8Array, codec: Codec<T>): T => {
const sia = new Sia(data);
return codec.deserialize(sia);
};
// Encode/decode arrays
const encodeArrayWith = <T>(items: T[], codec: Codec<T>): Uint8Array => {
const sia = new Sia();
sia.addArray32(items, codec.serialize);
return sia.toUint8Array();
};
const decodeArrayWith = <T>(data: Uint8Array, codec: Codec<T>): T[] => {
const sia = new Sia(data);
return sia.readArray32(codec.deserialize);
};
Array Serialization Patterns
Homogeneous Arrays
Arrays where every element has the same type use the standard addArray* / readArray* methods:
// Array of strings
const sia = new Sia();
const tags = ["typescript", "binary", "fast"];
sia.addArray8(tags, (s, tag) => s.addString8(tag));
sia.seek(0);
const decoded = sia.readArray8((s) => s.readString8());
Heterogeneous Arrays
For arrays with mixed-type elements, use a type tag per element:
type Value = string | number | boolean;
const serializeValue = (sia: Sia, val: Value): void => {
if (typeof val === "string") {
sia.addUInt8(0).addString16(val);
} else if (typeof val === "number") {
sia.addUInt8(1).addInt32(val);
} else {
sia.addUInt8(2).addBool(val);
}
};
const deserializeValue = (sia: Sia): Value => {
const tag = sia.readUInt8();
switch (tag) {
case 0:
return sia.readString16();
case 1:
return sia.readInt32();
case 2:
return sia.readBool();
default:
throw new Error(`Unknown value tag: ${tag}`);
}
};
const sia = new Sia();
const mixed: Value[] = ["hello", 42, true, "world", -1, false];
sia.addArray8(mixed, serializeValue);
sia.seek(0);
const result = sia.readArray8(deserializeValue);
Error Handling
Sia throws plain Error objects when reads fail (e.g., insufficient data). Wrap deserialization in try/catch at the boundary:
const safeDecode = <T>(
data: Uint8Array,
codec: Codec<T>,
): { ok: true; value: T } | { ok: false; error: string } => {
try {
const value = decodeWith(data, codec);
return { ok: true, value };
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
return { ok: false, error: message };
}
};
// Usage
const result = safeDecode(incomingData, userCodec);
if (result.ok) {
handleUser(result.value);
} else {
console.error("Deserialization failed:", result.error);
}
Error
instances with descriptive messages like "Not enough data to read uint32".Best Practices
- Define interfaces for all serialized types. Even though Sia has no schema, TypeScript interfaces serve as your schema documentation and catch field mismatches at compile time.
// Good: explicit interface
interface Sensor {
id: number;
value: number;
timestamp: number;
}
// Bad: inline object literals with no type
const data = { id: 1, value: 3.14, timestamp: Date.now() };
- Co-locate serializers with their types. Keep the interface, serialize function, and deserialize function in the same file for easy maintenance.
// sensor.ts
export interface Sensor {
/* ... */
}
export const serializeSensor = (sia: Sia, s: Sensor): void => {
/* ... */
};
export const deserializeSensor = (sia: Sia): Sensor => {
/* ... */
};
- Use the Codec pattern for complex projects. It bundles serialization and deserialization into a single testable unit.
- Always match
add*andread*order and types. TypeScript cannot enforce that your serialization and deserialization are in sync: write unit tests for every codec. - Use enum values as UInt8 discriminators. Map string discriminators to numeric values for compact serialization.
// Good: numeric discriminator
enum EventType {
Click = 0,
Scroll = 1,
KeyPress = 2,
}
sia.addUInt8(EventType.Click); // 1 byte
// Bad: string discriminator
sia.addString8("click"); // 6 bytes
- Validate at the boundary, not inside codecs. Keep serializers simple and fast. Do validation before serializing or after deserializing, not within the codec itself.
Real-World Example: Type-Safe Message Protocol
A complete example of a typed message protocol with multiple message types:
import { Sia } from "@timeleap/sia";
// Message types
enum MessageType {
Ping = 0,
Pong = 1,
Subscribe = 2,
Unsubscribe = 3,
Data = 4,
Error = 5,
}
interface PingMessage {
type: MessageType.Ping;
timestamp: number;
}
interface PongMessage {
type: MessageType.Pong;
timestamp: number;
}
interface SubscribeMessage {
type: MessageType.Subscribe;
channel: string;
}
interface UnsubscribeMessage {
type: MessageType.Unsubscribe;
channel: string;
}
interface DataMessage {
type: MessageType.Data;
channel: string;
payload: Uint8Array;
}
interface ErrorMessage {
type: MessageType.Error;
code: number;
message: string;
}
type ProtocolMessage =
| PingMessage
| PongMessage
| SubscribeMessage
| UnsubscribeMessage
| DataMessage
| ErrorMessage;
// Serialization
const serializeProtocolMessage = (sia: Sia, msg: ProtocolMessage): void => {
sia.addUInt8(msg.type);
switch (msg.type) {
case MessageType.Ping:
case MessageType.Pong:
sia.addUInt64(msg.timestamp);
break;
case MessageType.Subscribe:
case MessageType.Unsubscribe:
sia.addAscii8(msg.channel);
break;
case MessageType.Data:
sia.addAscii8(msg.channel).addByteArray32(msg.payload);
break;
case MessageType.Error:
sia.addUInt16(msg.code).addString16(msg.message);
break;
}
};
// Deserialization
const deserializeProtocolMessage = (sia: Sia): ProtocolMessage => {
const type = sia.readUInt8() as MessageType;
switch (type) {
case MessageType.Ping:
return { type, timestamp: sia.readUInt64() };
case MessageType.Pong:
return { type, timestamp: sia.readUInt64() };
case MessageType.Subscribe:
return { type, channel: sia.readAscii8() };
case MessageType.Unsubscribe:
return { type, channel: sia.readAscii8() };
case MessageType.Data:
return {
type,
channel: sia.readAscii8(),
payload: sia.readByteArray32(),
};
case MessageType.Error:
return {
type,
code: sia.readUInt16(),
message: sia.readString16(),
};
default:
throw new Error(`Unknown message type: ${type}`);
}
};
// Usage
const writer = new Sia();
const ping: PingMessage = {
type: MessageType.Ping,
timestamp: Date.now(),
};
serializeProtocolMessage(writer, ping);
const bytes = writer.toUint8Array(); // 9 bytes: 1 (type) + 8 (timestamp)
const reader = new Sia(bytes);
const decoded = deserializeProtocolMessage(reader);
// { type: 0, timestamp: 1709654321000 }