Guides

TypeScript Usage

Patterns for type-safe serialization with Sia, including generics, interfaces, and advanced type techniques.

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);
}
Sia does not use custom error classes. All errors are standard Error instances with descriptive messages like "Not enough data to read uint32".

Best Practices

  1. 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() };
  1. 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 => {
  /* ... */
};
  1. Use the Codec pattern for complex projects. It bundles serialization and deserialization into a single testable unit.
  2. Always match add* and read* order and types. TypeScript cannot enforce that your serialization and deserialization are in sync: write unit tests for every codec.
  3. 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
  1. 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 }