Schema Compiler

Schema Quick Start

Define your types once, generate serializers for every language, and use them in your app.

The fastest way to use Sia is to skip the manual addString8 / readString8 calls entirely. Define your data structures in a .sia file, compile once, and import the generated code. You get type-safe encode/decode functions in every language — from a single source of truth.

This guide covers the schema-first workflow. If you prefer writing serialization code by hand (or only have one or two message types), see the manual Quick Start.

Install

Install the schema compiler

npm install -g @timeleap/sia-schema

Or use it without installing via npx:

npx @timeleap/sia-schema compile --help

Install Sia for your language

npm install @timeleap/sia
go get github.com/TimeleapLabs/go-sia/v2
pip install timeleap-sia
Add go-sia via CMake FetchContent or as a git submodule. See Installation.

Define Your Schema

Create a file called user.sia:

schema User {
  name    string8
  age     uint8
  email   string16
  active  bool
}

That's it. Four lines. No field numbers, no decorators, no annotations.

Compile

sia compile user.sia -o user.ts
sia compile user.sia -o user.go
sia compile user.sia -o user.py
sia compile user.sia -o user.hpp

The compiler generates encode and decode functions that handle all the serialization details for you.

Use the Generated Code

This is where the magic is — import and use the generated code just like any other module:

import { Sia } from "@timeleap/sia";
import { encodeUser, decodeUser, type User } from "./user";

// Create a user
const alice: User = {
  name: "Alice",
  age: 30,
  email: "alice@example.com",
  active: true,
};

// Encode to bytes — one line
const bytes = encodeUser(new Sia(), alice).toUint8Array();

// Decode from bytes — one line
const decoded = decodeUser(new Sia(bytes));

console.log(decoded.name);   // "Alice"
console.log(decoded.email);  // "alice@example.com"
package main

import (
    "fmt"
    sia "github.com/TimeleapLabs/go-sia/v2/pkg"
    "./schema" // your generated file
)

func main() {
    // Create a user
    alice := schema.User{
        Name:   "Alice",
        Age:    30,
        Email:  "alice@example.com",
        Active: true,
    }

    // Encode to bytes
    bytes := alice.Sia().Bytes()

    // Decode from bytes
    var decoded schema.User
    decoded.FromSia(sia.NewFromBytes(bytes))

    fmt.Println(decoded.Name)  // "Alice"
    fmt.Println(decoded.Email) // "alice@example.com"
}
from sia import Sia
from user import User  # your generated file

# Create a user
alice = User(
    name="Alice",
    age=30,
    email="alice@example.com",
    active=True,
)

# Encode to bytes
s = Sia()
alice.encode(s)
raw_bytes = s.content

# Decode from bytes
reader = Sia(raw_bytes)
decoded = User.decode(reader)

print(decoded.name)   # "Alice"
print(decoded.email)  # "alice@example.com"
#include "user.hpp"
#include <iostream>

int main() {
    // Create a user
    User alice{"Alice", 30, "alice@example.com", true};

    // Encode to bytes
    auto encoded = encodeUser(alice);
    auto bytes = encoded->Bytes();

    // Decode from bytes
    auto reader = sia::NewFromBytes(bytes);
    User decoded = decodeUser(reader);

    std::cout << decoded.name << std::endl;   // "Alice"
    std::cout << decoded.email << std::endl;   // "alice@example.com"
}

Send It Across the Wire

The whole point of binary serialization is sending data between systems. Here's how easy that looks with schema-generated code:

import { Sia } from "@timeleap/sia";
import { encodeUser, decodeUser, type User } from "./user";

// === Sender (e.g. browser client) ===
const user: User = { name: "Alice", age: 30, email: "alice@example.com", active: true };
const bytes = encodeUser(new Sia(), user).toUint8Array();

ws.send(bytes); // Send over WebSocket

// === Receiver (e.g. Node.js server) ===
ws.on("message", (data: Uint8Array) => {
  const user = decodeUser(new Sia(data));
  console.log(`${user.name} (${user.age}) — ${user.email}`);
});
// === Sender ===
alice := schema.User{Name: "Alice", Age: 30, Email: "alice@example.com", Active: true}
conn.Write(alice.Sia().Bytes())

// === Receiver ===
buf := make([]byte, 1024)
n, _ := conn.Read(buf)

var user schema.User
user.FromSia(sia.NewFromBytes(buf[:n]))
fmt.Printf("%s (%d) — %s\n", user.Name, user.Age, user.Email)
# === Sender ===
alice = User(name="Alice", age=30, email="alice@example.com", active=True)
s = Sia()
alice.encode(s)
websocket.send(s.content)

# === Receiver ===
data = websocket.recv()
reader = Sia(data)
user = User.decode(reader)
print(f"{user.name} ({user.age}) — {user.email}")
// === Sender ===
User alice{"Alice", 30, "alice@example.com", true};
auto encoded = encodeUser(alice);
socket.send(encoded->Bytes());

// === Receiver ===
auto data = socket.recv();
auto reader = sia::NewFromBytes(data);
User user = decodeUser(reader);
std::cout << user.name << " (" << (int)user.age << ") — " << user.email << std::endl;

Cross-Language Compatibility

The bytes are identical regardless of which language encoded them. Serialize in TypeScript, deserialize in Go — it just works:

schema ChatMessage {
  sender    string8
  text      string16
  timestamp uint64
}

A TypeScript client encodes a ChatMessage, a Go server decodes it. Same bytes, same field order, no version negotiation, no schema registry.

Nested Schemas

Schemas can reference other schemas. The compiler handles the nesting automatically:

schema Address {
  street  string8
  city    string8
  zip     int32
}

schema Person {
  name     string8
  age      uint8
  address  Address
  tags     string8[]
}
import { Sia } from "@timeleap/sia";
import { encodePerson, decodePerson, type Person } from "./schema";

const person: Person = {
  name: "Bob",
  age: 25,
  address: { street: "123 Main St", city: "Zurich", zip: 8001 },
  tags: ["developer", "gopher"],
};

const bytes = encodePerson(new Sia(), person).toUint8Array();
const decoded = decodePerson(new Sia(bytes));

console.log(decoded.address.city); // "Zurich"
console.log(decoded.tags);         // ["developer", "gopher"]
person := schema.Person{
    Name: "Bob",
    Age:  25,
    Address: schema.Address{Street: "123 Main St", City: "Zurich", Zip: 8001},
    Tags: []string{"developer", "gopher"},
}

bytes := person.Sia().Bytes()

var decoded schema.Person
decoded.FromSia(sia.NewFromBytes(bytes))

fmt.Println(decoded.Address.City) // "Zurich"
fmt.Println(decoded.Tags)        // ["developer" "gopher"]
person = Person(
    name="Bob",
    age=25,
    address=Address(street="123 Main St", city="Zurich", zip=8001),
    tags=["developer", "gopher"],
)

s = Sia()
person.encode(s)
raw = s.content

decoded = Person.decode(Sia(raw))
print(decoded.address.city)  # "Zurich"
print(decoded.tags)          # ["developer", "gopher"]
Person person{"Bob", 25, {"123 Main St", "Zurich", 8001}, {"developer", "gopher"}};

auto encoded = encodePerson(person);
auto decoded = decodePerson(sia::NewFromBytes(encoded->Bytes()));

std::cout << decoded.address.city << std::endl; // "Zurich"

Compare: Manual vs Schema

Here's what writing the same User type looks like manually vs with the schema compiler:

Manual (you write this)

interface User {
  name: string;
  age: number;
  email: string;
  active: boolean;
}

function encodeUser(sia: Sia, user: User): Sia {
  sia.addString8(user.name);
  sia.addUInt8(user.age);
  sia.addString16(user.email);
  sia.addBool(user.active);
  return sia;
}

function decodeUser(sia: Sia): User {
  return {
    name: sia.readString8(),
    age: sia.readUInt8(),
    email: sia.readString16(),
    active: sia.readBool(),
  };
}
type User struct {
    Name   string
    Age    uint8
    Email  string
    Active bool
}

func EncodeUser(s *sia.Sia, u User) {
    s.AddString8(u.Name)
    s.AddUInt8(u.Age)
    s.AddString16(u.Email)
    s.AddBool(u.Active)
}

func DecodeUser(s *sia.Sia) User {
    return User{
        Name:   s.ReadString8(),
        Age:    s.ReadUInt8(),
        Email:  s.ReadString16(),
        Active: s.ReadBool(),
    }
}
class User:
    def __init__(self, name, age, email, active):
        self.name = name
        self.age = age
        self.email = email
        self.active = active

    def encode(self, sia):
        sia.add_string8(self.name)
        sia.add_uint8(self.age)
        sia.add_string16(self.email)
        sia.add_bool(self.active)
        return sia

    @classmethod
    def decode(cls, sia):
        return cls(
            name=sia.read_string8(),
            age=sia.read_uint8(),
            email=sia.read_string16(),
            active=sia.read_bool(),
        )
struct User {
    std::string name;
    uint8_t age;
    std::string email;
    bool active;
};

std::shared_ptr<sia::Sia> encodeUser(User u) {
    auto s = sia::New();
    s->AddString8(u.name);
    s->AddUInt8(u.age);
    s->AddString16(u.email);
    s->AddBool(u.active);
    return s;
}

User decodeUser(std::shared_ptr<sia::Sia> s) {
    User u;
    u.name = s->ReadString8();
    u.age = s->ReadUInt8();
    u.email = s->ReadString16();
    u.active = s->ReadBool();
    return u;
}

Schema (the compiler writes it for you)

schema User {
  name    string8
  age     uint8
  email   string16
  active  bool
}
Run sia compile user.sia -o user.ts and you're done.
Run sia compile user.sia -o user.go and you're done.
Run sia compile user.sia -o user.py and you're done.
Run sia compile user.sia -o user.cpp and you're done.

The manual version is 15-25 lines of careful, order-dependent code per type. The schema version is 6 lines. Add ten more message types and the difference is enormous.

What's Next

Syntax Reference

Full reference for the .sia schema language: types, optional fields, arrays, nested schemas, and more.

VS Code Extension

Syntax highlighting for .sia files in VS Code.

Plugins & RPC

Define RPC endpoints with the plugin system for the Timeleap network.

Code Generation

How the compiler generates code for each language, with the full compilation pipeline.