Vexil

Vexil (Validated Exchange Language) is a typed schema definition language with first-class encoding semantics. It describes the shape, constraints, and wire encoding of data crossing system boundaries.

What makes Vexil different?

Encoding is part of the type system. The type u4 means exactly 4 bits on the wire. The annotation @varint on a u64 changes the wire encoding to unsigned LEB128. The schema IS the wire contract, not just the shape contract.

Deterministic encoding. Same data always produces identical bytes. This enables BLAKE3 content addressing, deduplication, and replay detection -- things that Protocol Buffers, Cap'n Proto, and FlatBuffers cannot guarantee.

Multi-language. Generate code for Rust, TypeScript, and Go from the same .vexil schema. All three produce byte-identical wire output, verified by compliance vectors.

Quick example

namespace sensor.packet

enum SensorKind : u8 {
    Temperature @0
    Humidity    @1
    Pressure    @2
}

message SensorReading {
    channel  @0 : u4              # 4 bits on the wire
    kind     @1 : SensorKind
    value    @2 : u16
    sequence @3 : u32 @varint     # variable-length encoding
}

Generate code:

vexilc codegen sensor.vexil --target rust
vexilc codegen sensor.vexil --target typescript
vexilc codegen sensor.vexil --target go

Installation

cargo install vexilc

Pre-built binaries for Linux, macOS, and Windows are available on the Releases page.

Installation

Pre-built binaries

Download from the Releases page. Binaries are available for:

  • Linux x86-64
  • Linux ARM64
  • macOS Apple Silicon
  • macOS Intel
  • Windows x86-64

Shell installer (Linux/macOS)

curl --proto '=https' --tlsv1.2 -LsSf https://github.com/vexil-lang/vexil/releases/latest/download/vexilc-installer.sh | sh

PowerShell installer (Windows)

powershell -ExecutionPolicy Bypass -c "irm https://github.com/vexil-lang/vexil/releases/latest/download/vexilc-installer.ps1 | iex"

From crates.io

cargo install vexilc

Requires Rust 1.94 or later.

From source

git clone https://github.com/vexil-lang/vexil
cd vexil
cargo build --release --bin vexilc

The binary will be at target/release/vexilc.

Verify

vexilc --version

Your First Schema

Create a file called hello.vexil:

namespace hello

message Greeting {
    name    @0 : string
    message @1 : string
    count   @2 : u32
}

Or use vexilc init:

vexilc init hello
# Creates hello.vexil

Check for errors

vexilc check hello.vexil

If the schema is valid, vexilc prints the schema hash and exits with code 0. Errors show source spans:

Error: unknown type `strin`
   ╭─[ hello.vexil:3:18 ]
   │
 3 │     name    @0 : strin
   │                  ──┬──
   │                    ╰── UnknownType
───╯

Understand the schema

  • namespace hello -- every schema needs a namespace
  • message Greeting -- a struct-like type with ordered fields
  • @0, @1, @2 -- ordinals determine wire order (not source order)
  • : string, : u32 -- field types determine encoding

Schema hash

Every schema has a deterministic BLAKE3 hash:

vexilc hash hello.vexil
# a1b2c3d4...  hello.vexil

Two schemas with identical content produce identical hashes, regardless of whitespace or comments. The hash is computed from the canonical form of the schema, not the raw source text.

Generating Code

Single file

# Rust (default target)
vexilc codegen hello.vexil --target rust --output hello.rs

# TypeScript
vexilc codegen hello.vexil --target typescript --output hello.ts

# Go
vexilc codegen hello.vexil --target go --output hello.go

Default target is rust. Output goes to stdout if --output is omitted.

Multi-file project

For schemas with imports, use the build subcommand:

vexilc build root.vexil --include ./schemas --output ./generated --target rust

This resolves all imports, compiles in topological order, and generates one file per namespace.

Watch mode

Auto-rebuild on save:

vexilc watch root.vexil --include ./schemas --output ./generated --target typescript

Changes to any .vexil file in the watched directories trigger a rebuild with 200ms debounce.

Using generated code

Rust

Add vexil-runtime to your Cargo.toml:

[dependencies]
vexil-runtime = "0.5"
#![allow(unused)]
fn main() {
use vexil_runtime::{BitWriter, BitReader, Pack, Unpack};

let greeting = Greeting {
    name: "world".to_string(),
    message: "hello".to_string(),
    count: 42,
    _unknown: Vec::new(),
};

// Encode
let mut w = BitWriter::new();
greeting.pack(&mut w).unwrap();
let bytes = w.finish();

// Decode
let mut r = BitReader::new(&bytes);
let decoded = Greeting::unpack(&mut r).unwrap();
}

TypeScript

Install @vexil-lang/runtime:

npm install @vexil-lang/runtime
import { BitWriter, BitReader } from '@vexil-lang/runtime';
import { encodeGreeting, decodeGreeting } from './hello';

const w = new BitWriter();
encodeGreeting(
  { name: 'world', message: 'hello', count: 42, _unknown: new Uint8Array(0) },
  w,
);
const bytes = w.finish();

const r = new BitReader(bytes);
const decoded = decodeGreeting(r);

Go

import vexil "github.com/vexil-lang/vexil/packages/runtime-go"

greeting := &Greeting{
    Name:    "world",
    Message: "hello",
    Count:   42,
}

w := vexil.NewBitWriter()
greeting.Pack(w)
bytes := w.Finish()

r := vexil.NewBitReader(bytes)
var decoded Greeting
decoded.Unpack(r)

Types

Primitive types

TypeSizeDescription
bool1 bitTrue or false
u8 -- u648--64 bitsUnsigned integers
i8 -- i648--64 bitsSigned integers (two's complement)
f3232 bitsIEEE 754 single-precision float
f6464 bitsIEEE 754 double-precision float

Sub-byte types

TypeSizeDescription
u1 -- u71--7 bitsUnsigned sub-byte integers
i2 -- i72--7 bitsSigned sub-byte integers

Sub-byte fields are packed LSB-first within each byte. This is unique to Vexil -- Protocol Buffers and other formats cannot represent fields smaller than one byte.

Semantic types

TypeWire encodingDescription
stringLEB128 length + UTF-8Text
bytesLEB128 length + rawBinary data
uuid16 bytesUUID as raw bytes
timestamp64-bit signedUnix epoch (interpretation is application-defined)
rgb3 bytesRed, green, blue
hash32 bytesBLAKE3 hash

Parameterized types

TypeDescription
optional<T>Presence bit + value
array<T>LEB128 count + elements
map<K, V>LEB128 count + key-value pairs
result<T, E>Boolean tag + ok or error value

Wire encoding

All types encode to a deterministic byte sequence. Fixed-size types pack at their natural bit width. Variable-length types (string, bytes, array, map) use LEB128 length prefixes.

The @varint annotation changes a fixed-width integer to unsigned LEB128 encoding. The @zigzag annotation uses ZigZag encoding for signed integers (small magnitudes use fewer bytes).

See the language specification for complete encoding rules.

Messages

Messages are the primary data type in Vexil -- ordered, typed fields with explicit ordinals.

message SensorReading {
    channel  @0 : u4
    kind     @1 : SensorKind
    value    @2 : u16
    sequence @3 : u32 @varint
}

Fields are encoded in ordinal order. Each field has a name, an ordinal (@N), and a type.

Field ordinals

Ordinals determine wire order. They must be unique within a message but don't need to be sequential. Gaps are allowed -- this is important for schema evolution, since you can add new fields at new ordinals without disturbing existing ones.

message Config {
    name    @0 : string
    # @1 was removed
    timeout @2 : u32
    retries @3 : u8
}

Field annotations

Fields can carry encoding annotations:

message Packet {
    sequence @0 : u32 @varint     # LEB128 variable-length encoding
    delta    @1 : i32 @zigzag     # ZigZag encoding for signed values
    payload  @2 : bytes
}

Wire encoding

Fields are packed in ordinal order with LSB-first bit packing. Sub-byte fields (like u4) pack tightly -- two u4 fields occupy a single byte. After all fields, the encoder flushes to a byte boundary.

Unknown fields

Every generated message struct has an _unknown field that captures trailing bytes from newer schema versions. When a v1 decoder reads v2-encoded data, the extra bytes are preserved. Re-encoding includes them, enabling forward-compatible round-tripping with no data loss.

See the language specification for the full normative reference.

Enums and Flags

Enums

Enums define a closed set of named variants with a fixed-width backing type.

enum Direction : u8 {
    North @0
    East  @1
    South @2
    West  @3
}

The backing type (: u8) determines the wire size. Variant ordinals (@N) are the values written to the wire.

Non-exhaustive enums

By default, enums are exhaustive -- receiving an unknown variant is an error. Use @non_exhaustive to allow future additions:

@non_exhaustive
enum Status : u8 {
    Active   @0
    Inactive @1
}

A non-exhaustive enum can safely add variants in newer schema versions without breaking existing decoders.

Flags

Flags are bitmask types where each named bit occupies a specific position in a fixed-width integer.

flags Permissions : u8 {
    Read    @0
    Write   @1
    Execute @2
}

Multiple flags can be set simultaneously. The ordinal (@N) is the bit position, not the value -- Read @0 means bit 0 (value 1), Write @1 means bit 1 (value 2), Execute @2 means bit 2 (value 4).

Flags encode as their backing type on the wire. A flags Permissions : u8 always occupies exactly 8 bits.

See the language specification for the full normative reference.

Unions

Unions represent a value that can be one of several typed variants. They are Vexil's tagged union / sum type.

union Shape {
    Circle    @0 : f32          # radius
    Rectangle @1 : Dimensions
    Point     @2                # no payload
}

Wire encoding

A union encodes as a discriminant tag followed by the variant payload. The tag type is determined by the number of variants -- the compiler picks the smallest unsigned integer that fits.

Non-exhaustive unions

Like enums, unions can be marked @non_exhaustive to allow adding variants without breaking existing decoders:

@non_exhaustive
union Event {
    Click  @0 : ClickData
    Scroll @1 : ScrollData
}

Variants with and without payloads

Variants can carry a payload type or be empty:

union Result {
    Ok    @0 : Data
    Error @1 : string
    Empty @2
}

See the language specification for the full normative reference.

Newtypes and Configs

Newtypes

A newtype wraps an existing type with a distinct name. On the wire, it encodes identically to the underlying type.

newtype UserId = u64
newtype Temperature = f32

Newtypes provide type safety in generated code -- a UserId and a raw u64 are different types even though they have the same wire representation.

Newtypes with annotations

newtype CompactId = u64 @varint

The annotation applies to the wire encoding of the underlying type.

Configs

Configs are compile-time constant declarations. They do not appear on the wire but are available in generated code as constants.

config MAX_PACKET_SIZE : u32 = 1500
config VERSION : string = "1.0.0"

Configs are useful for sharing magic numbers and version strings between schema and application code without encoding them in every message.

See the language specification for the full normative reference.

Annotations

Annotations modify the behavior of types, fields, and declarations. They are prefixed with @.

Encoding annotations

These change how a field is encoded on the wire:

AnnotationApplies toEffect
@varintunsigned integersLEB128 variable-length encoding
@zigzagsigned integersZigZag encoding (small magnitudes use fewer bytes)
@deltanumeric fields in arraysDelta encoding (store differences, not absolute values)
message Packet {
    sequence @0 : u32 @varint
    offset   @1 : i32 @zigzag
}

Declaration annotations

AnnotationApplies toEffect
@non_exhaustiveenum, unionAllows adding variants without breaking decoders
@deprecatedfields, variantsMarks as deprecated in generated code
@removed(ordinal, reason: "...")message fieldsTyped tombstone for removed fields
@non_exhaustive
enum Status : u8 {
    Active     @0
    @deprecated
    Legacy     @1
    Suspended  @2
}

Removed fields

When evolving a schema, use @removed to leave a typed tombstone. This allows decoders to skip the correct number of bytes for the removed field:

message Config {
    name       @0 : string
    @removed(1, reason: "migrated to timeout_ms") : u32
    timeout_ms @2 : u64
}

See the language specification for the full normative reference.

Imports

Vexil supports multi-file schemas with explicit imports. This allows you to split large schemas into reusable modules.

Basic imports

import common.types

namespace myapp.protocol

message Request {
    id     @0 : common.types.RequestId
    action @1 : string
}

The imported namespace must be resolvable via the include paths passed to vexilc build.

Project compilation

When using imports, use vexilc build instead of vexilc codegen:

vexilc build root.vexil --include ./schemas --output ./generated --target rust

The compiler:

  1. Parses the root file and discovers imports
  2. Resolves each import against the include directories
  3. Compiles all schemas in topological order (dependencies first)
  4. Generates code for each schema with proper cross-file references

Diamond dependencies

If A imports B and C, and both B and C import D, the compiler deduplicates D. Each type is compiled exactly once, and generated code references the canonical location.

Generated imports

Each target language handles cross-file references idiomatically:

  • Rust: use statements referencing sibling modules
  • TypeScript: relative import statements with barrel index.ts files
  • Go: standard package imports

See the language specification for the full normative reference.

Schema Evolution

Vexil supports safe schema evolution -- adding fields, deprecating fields, and detecting breaking changes.

Compatible changes

These changes are safe (v1 and v2 can interoperate):

ChangeClassification
Add a field with a new ordinalMinor
Add a variant to @non_exhaustive enum/unionMinor
Mark a field @deprecatedPatch
Rename a field (ordinal unchanged)Patch

Breaking changes

These changes require all peers to upgrade simultaneously:

ChangeWhy
Remove a fieldWire layout changes
Change a field's typeWire encoding differs
Change a field's ordinalWire order changes
Add/remove @varint, @zigzag, @deltaEncoding differs

Detecting breaking changes

vexilc compat v1/schema.vexil v2/schema.vexil

Output:

  ✓ field "flags" added at @2           compatible (minor)
  ✗ field "timeout" type u32 → optional<u32>  BREAKING (major)

Result: BREAKING — requires major version bump

JSON output for CI integration:

vexilc compat v1.vexil v2.vexil --format json

The compat command exits with code 0 for compatible changes and code 1 for breaking changes, making it suitable for CI gates.

Forward compatibility

When a v1 decoder receives v2-encoded data with extra fields, the trailing bytes are captured in the _unknown field. Re-encoding preserves them -- no data loss during round-tripping.

Typed tombstones

When removing a field, use @removed with the original type to enable decode-and-discard:

message Config {
    name       @0 : string
    @removed(1, reason: "migrated to timeout_ms") : u32
    timeout_ms @2 : u64
}

The tombstone tells the decoder exactly how many bytes to skip for ordinal 1, even though the field no longer exists in the current schema.

See the language specification for the full normative reference.

Delta Encoding

Delta encoding is an annotation that instructs the encoder to write differences between consecutive values instead of absolute values. This is useful for time-series data where consecutive readings are close together.

message TimeSeries {
    timestamps @0 : array<u64 @delta>
    values     @1 : array<i32 @delta @zigzag>
}

How it works

With @delta, the encoder writes:

  1. The first value as-is
  2. Each subsequent value as current - previous

The decoder reverses the process, accumulating deltas to reconstruct absolute values.

When to use delta encoding

Delta encoding is most effective when:

  • Values increase monotonically (timestamps, sequence numbers)
  • Consecutive values are close together (sensor readings, coordinates)
  • Combined with @varint or @zigzag -- small deltas compress to fewer bytes

Combining annotations

Delta encoding composes with other encoding annotations:

message GpsTrack {
    timestamps @0 : array<u64 @delta @varint>    # monotonic, small deltas
    latitudes  @1 : array<i32 @delta @zigzag>    # signed deltas near zero
    longitudes @2 : array<i32 @delta @zigzag>
}

Note: Delta encoding support is currently specified but implementation may vary by backend. Check the limitations document for current status.

See the language specification for the full normative reference.

vexilc

vexilc is the Vexil schema compiler. It validates schemas, generates code for multiple target languages, and provides tools for schema evolution and binary file inspection.

Usage

vexilc <subcommand> [args]

Subcommands

CommandDescription
checkValidate a schema and print its hash
codegenGenerate code for a single schema file
buildGenerate code for a multi-file project
watchWatch files and rebuild on changes
compatCompare schemas for breaking changes
initCreate a new schema file
hashPrint the BLAKE3 schema hash
packEncode a .vx text file to .vxb binary
unpackDecode a .vxb binary file to .vx text
formatFormat a .vx text file
infoInspect .vxb/.vxc file headers
compileCompile a schema to .vxc binary format

Global options

OptionDescription
-V, --versionPrint version
-h, --helpPrint help

Targets

The --target option (used by codegen, build, and watch) accepts:

  • rust (default)
  • typescript
  • go

check

Validate a Vexil schema file and print its BLAKE3 hash.

Usage

vexilc check <file.vexil>

Example

$ vexilc check sensor.vexil
schema hash: a1b2c3d4e5f6...

If the schema has errors, they are printed with source spans and the command exits with code 1:

Error: unknown type `strin`
   ╭─[ sensor.vexil:4:18 ]
   │
 4 │     name    @0 : strin
   │                  ──┬──
   │                    ╰── UnknownType
───╯

Exit codes

CodeMeaning
0Schema is valid
1Schema has errors

codegen

Generate code from a single Vexil schema file.

Usage

vexilc codegen <file.vexil> [--target <target>] [--output <path>]

Options

OptionDefaultDescription
--target <target>rustCode generation target: rust, typescript, or go
--output <path>stdoutWrite output to a file instead of stdout

Examples

# Generate Rust to stdout
vexilc codegen sensor.vexil

# Generate TypeScript to a file
vexilc codegen sensor.vexil --target typescript --output sensor.ts

# Generate Go
vexilc codegen sensor.vexil --target go --output sensor.go

Notes

  • For schemas with imports, use build instead
  • The generated code depends on the corresponding runtime library (vexil-runtime for Rust, @vexil-lang/runtime for TypeScript)
  • Schema errors are reported before code generation begins

build

Generate code for a multi-file Vexil project with imports.

Usage

vexilc build <root.vexil> --include <dir> --output <dir> [--target <target>]

Options

OptionDefaultDescription
--include <dir>(none)Directory to search for imported schemas (can be repeated)
--output <dir>(required)Output directory for generated code
--target <target>rustCode generation target: rust, typescript, or go

Example

vexilc build protocol.vexil \
  --include ./schemas \
  --output ./generated \
  --target rust

Output:

  wrote ./generated/common/types.rs
  wrote ./generated/protocol.rs
build complete: 3 schemas compiled

How it works

  1. Parses the root schema file
  2. Discovers import statements and resolves them against --include directories
  3. Compiles all schemas in topological order (dependencies before dependents)
  4. Generates one output file per namespace, with cross-file references handled by the backend
  5. Handles diamond dependencies by deduplicating shared imports

watch

Watch for file changes and automatically rebuild.

Usage

vexilc watch <root.vexil> [--include <dir>] [--output <dir>] [--target <target>]

Options

OptionDefaultDescription
--include <dir>(none)Additional directories to watch and search for imports
--output <dir>(none)Output directory (if omitted, runs check only)
--target <target>rustCode generation target

Example

vexilc watch protocol.vexil --include ./schemas --output ./generated --target typescript

Output:

[watch] Initial build...
  wrote ./generated/protocol.ts
build complete: 2 schemas compiled
[watch] Ready. Watching for changes...

Behavior

  • Performs an initial build on startup
  • Watches the root file's directory and all --include directories recursively
  • Only reacts to .vexil file changes (creates and modifications)
  • Debounces rapid changes with a 200ms delay
  • If --output is omitted, runs check on each change instead of a full build

compat

Compare two schema versions and detect breaking changes.

Usage

vexilc compat <old.vexil> <new.vexil> [--format <human|json>]

Options

OptionDefaultDescription
--format <format>humanOutput format: human or json

Example

$ vexilc compat v1/sensor.vexil v2/sensor.vexil
  ✓ field "flags" added at @2           compatible (minor)
  ✗ field "timeout" type u32 → u64      BREAKING (major)

Result: BREAKING — requires major version bump

JSON output

$ vexilc compat v1.vexil v2.vexil --format json
{
  "changes": [
    {
      "kind": "field_added",
      "declaration": "SensorReading",
      "field": "flags",
      "detail": "field \"flags\" added at @2",
      "classification": "minor"
    }
  ],
  "result": "compatible",
  "suggested_bump": "minor"
}

Exit codes

CodeMeaning
0Compatible changes only
1Breaking changes detected
2Schema compilation error

Detected changes

The compat checker detects field additions, removals, type changes, ordinal changes, renames, deprecations, encoding changes, variant additions/removals, declaration additions/removals, namespace changes, and flags bit changes.

init

Create a new Vexil schema file with a starter template.

Usage

vexilc init [name]

Example

$ vexilc init myapp
Created myapp.vexil

This creates myapp.vexil with a starter schema:

namespace myapp

message Hello {
    name     @0 : string
    greeting @1 : string
    count    @2 : u32
}

Notes

  • The command refuses to overwrite an existing file
  • The name becomes both the filename and the namespace

hash

Print the BLAKE3 hash of a compiled schema.

Usage

vexilc hash <file.vexil>

Example

$ vexilc hash sensor.vexil
a1b2c3d4e5f67890...  sensor.vexil

How it works

The hash is computed from the canonical form of the schema, not the raw source text. This means:

  • Whitespace differences don't affect the hash
  • Comment differences don't affect the hash
  • Reordering declarations (without changing semantics) may or may not affect the hash, depending on the canonical form rules

Two schemas that describe the same types with the same encoding produce the same hash. This enables:

  • Schema identity verification at connection time
  • Content addressing for cached compilations
  • Detecting when a schema has actually changed vs. just been reformatted

Rust Runtime

The vexil-runtime crate provides the runtime support needed by Vexil-generated Rust code.

Installation

[dependencies]
vexil-runtime = "0.5"

Core types

BitWriter

Encodes data into a byte buffer with LSB-first bit packing.

#![allow(unused)]
fn main() {
use vexil_runtime::BitWriter;

let mut w = BitWriter::new();
w.write_bits(0b1010, 4);  // write 4 bits
w.write_u8(255);           // write a full byte
w.write_varint(12345);     // write LEB128-encoded integer
let bytes = w.finish();    // flush and return the byte buffer
}

BitReader

Decodes data from a byte buffer with LSB-first bit packing.

#![allow(unused)]
fn main() {
use vexil_runtime::BitReader;

let mut r = BitReader::new(&bytes);
let nibble = r.read_bits(4)?;   // read 4 bits
let byte = r.read_u8()?;        // read a full byte
let value = r.read_varint()?;   // read LEB128-encoded integer
}

Pack and Unpack traits

Generated message types implement Pack and Unpack:

#![allow(unused)]
fn main() {
use vexil_runtime::{BitWriter, BitReader, Pack, Unpack};

// Encode
let mut w = BitWriter::new();
my_message.pack(&mut w)?;
let bytes = w.finish();

// Decode
let mut r = BitReader::new(&bytes);
let decoded = MyMessage::unpack(&mut r)?;
}

API documentation

Full API documentation is available on docs.rs/vexil-runtime.

Source

crates/vexil-runtime/

TypeScript Runtime

The @vexil-lang/runtime npm package provides BitWriter and BitReader for Vexil-generated TypeScript code. It produces byte-identical output to the Rust runtime, verified by compliance vectors.

Installation

npm install @vexil-lang/runtime

Zero dependencies.

Core types

BitWriter

import { BitWriter } from '@vexil-lang/runtime';

const w = new BitWriter();
w.writeBits(0b1010, 4);   // write 4 bits
w.writeU8(255);            // write a full byte
w.writeVarint(12345n);     // write LEB128-encoded integer (BigInt)
const bytes = w.finish();  // flush and return Uint8Array

BitReader

import { BitReader } from '@vexil-lang/runtime';

const r = new BitReader(bytes);
const nibble = r.readBits(4);   // read 4 bits
const byte = r.readU8();        // read a full byte
const value = r.readVarint();   // read LEB128-encoded integer

Generated code usage

import { BitWriter, BitReader } from '@vexil-lang/runtime';
import { encodeMyMessage, decodeMyMessage } from './generated/my_message';

// Encode
const w = new BitWriter();
encodeMyMessage(myData, w);
const bytes = w.finish();

// Decode
const r = new BitReader(bytes);
const decoded = decodeMyMessage(r);

Compliance

The TypeScript runtime is tested against the same compliance vectors as the Rust runtime. Both must produce identical bytes for every test case.

Source

packages/runtime-ts/

Go Runtime

The Go runtime provides BitWriter and BitReader for Vexil-generated Go code.

Installation

go get github.com/vexil-lang/vexil/packages/runtime-go

Core types

BitWriter

import vexil "github.com/vexil-lang/vexil/packages/runtime-go"

w := vexil.NewBitWriter()
w.WriteBits(0b1010, 4)    // write 4 bits
w.WriteU8(255)             // write a full byte
w.WriteVarint(12345)       // write LEB128-encoded integer
bytes := w.Finish()        // flush and return byte slice

BitReader

r := vexil.NewBitReader(bytes)
nibble := r.ReadBits(4)    // read 4 bits
b := r.ReadU8()            // read a full byte
value := r.ReadVarint()    // read LEB128-encoded integer

Generated code usage

Generated Go structs implement Pack and Unpack methods:

// Encode
w := vexil.NewBitWriter()
myMessage.Pack(w)
bytes := w.Finish()

// Decode
r := vexil.NewBitReader(bytes)
var decoded MyMessage
decoded.Unpack(r)

Source

packages/runtime-go/

Sensor Telemetry

The examples/sensor-packet/ directory demonstrates a basic Vexil schema for sensor telemetry data.

Schema

namespace sensor.packet

enum SensorKind : u8 {
    Temperature @0
    Humidity    @1
    Pressure    @2
}

message SensorReading {
    channel  @0 : u4
    kind     @1 : SensorKind
    value    @2 : u16
    sequence @3 : u32 @varint
}

This schema packs a sensor reading into a compact binary format:

  • channel uses only 4 bits (supports 16 channels)
  • kind uses 8 bits for the enum discriminant
  • value is a fixed 16-bit reading
  • sequence uses variable-length encoding (small sequence numbers take fewer bytes)

Running

# Generate Rust code
vexilc codegen examples/sensor-packet/sensor.vexil --target rust

# Generate TypeScript code
vexilc codegen examples/sensor-packet/sensor.vexil --target typescript

Source

examples/sensor-packet/

Cross-Language Interop

The examples/cross-language/ directory demonstrates Rust and Node.js exchanging binary data through Vexil-encoded .vxb files.

How it works

  1. A shared .vexil schema defines the data types
  2. The Rust program encodes data to a .vxb binary file
  3. The Node.js program reads the same .vxb file and decodes it
  4. Both sides produce and consume byte-identical wire format

This works because all Vexil backends (Rust, TypeScript, Go) implement the same deterministic encoding rules, verified by compliance vectors.

Running

cd examples/cross-language

# Build and run the Rust encoder
cd rust-device
cargo run

# Run the Node.js decoder
cd ../node-reader
npm install
npm start

Source

examples/cross-language/

Real-Time Dashboard

The examples/system-monitor/ directory demonstrates a real-time system monitoring dashboard using Vexil for the wire protocol between a Rust backend and a browser frontend.

Architecture

  • Rust backend collects system metrics (CPU, memory, disk) and encodes them as Vexil messages
  • WebSocket carries the binary Vexil-encoded frames to the browser
  • Browser frontend uses the TypeScript runtime to decode and display metrics in real time

Why Vexil?

Compared to sending JSON over WebSocket:

  • Smaller payloads (bit-packed fields, varint encoding)
  • Deterministic encoding (no key ordering ambiguity)
  • Type safety on both ends from the same schema
  • No serialization library needed -- the generated code is the serializer

Source

examples/system-monitor/

Development Setup

Prerequisites

  • Rust 1.94 or later
  • Node.js 18+ (for TypeScript runtime tests)
  • Go 1.21+ (for Go runtime tests)

Clone and build

git clone https://github.com/vexil-lang/vexil
cd vexil
cargo build --workspace

Run tests

# All Rust tests (~500)
cargo test --workspace

# Core compiler only
cargo test -p vexil-lang

# Rust codegen + golden + compliance tests
cargo test -p vexil-codegen-rust

# TypeScript codegen + golden tests
cargo test -p vexil-codegen-ts

# TypeScript runtime tests (120)
cd packages/runtime-ts && npx vitest run

Linting and formatting

# Must be clean (CI enforces this)
cargo clippy --workspace -- -D warnings

# Format all code
cargo fmt --all

# Check format without modifying
cargo fmt --all -- --check

Golden files

Codegen tests compare output against golden files. To update after intentional changes:

UPDATE_GOLDEN=1 cargo test -p vexil-codegen-rust
UPDATE_GOLDEN=1 cargo test -p vexil-codegen-ts

Benchmarks

cargo bench -p vexil-bench

Pre-commit hook

The repo has a pre-commit hook that runs cargo fmt --all and re-stages formatted files. Commits are always formatted. Set VEXIL_NO_FMT=1 to bypass.

Git workflow

  • Trunk-based development: small fixes go directly on main
  • Milestone-sized features use feature/<name> branches merged via PR
  • Always git pull origin main before starting work
  • CI/release workflow changes must go on a branch

Architecture

Compiler pipeline

The Vexil compiler (vexil-lang crate) processes schemas through a multi-stage pipeline:

Source → Lexer → Parser → AST → Lower → IR → TypeCheck → Validate → CompiledSchema

AST is source-faithful -- it preserves spans, comments, and original syntax. This is used for error reporting and (future) LSP support.

IR is resolved -- types are looked up, ordinals are validated, and the structure is ready for type checking and code generation.

Crate dependency graph

vexil-lang          (core compiler, zero internal deps)
├── vexil-codegen-rust   (Rust backend)
├── vexil-codegen-ts     (TypeScript backend)
├── vexil-codegen-go     (Go backend)
├── vexil-store          (binary file formats, depends on vexil-runtime)
└── vexilc               (CLI, depends on all of the above)

vexil-runtime       (Rust runtime, zero workspace deps)
@vexil-lang/runtime (TypeScript runtime, zero deps, separate npm package)

Key types

TypeDescription
CompiledSchemaSingle-file compilation result: registry, declarations, namespace, wire size
ProjectResultMulti-file result: Vec<(String, CompiledSchema)> in topological order
TypeRegistryOpaque type store indexed by TypeId
TypeDefSum type: Message, Enum, Flags, Union, Newtype, Config
CodegenBackendTrait for pluggable code generation backends

SDK architecture

The CodegenBackend trait in vexil-lang::codegen defines the interface for code generation:

  • generate(&CompiledSchema) -> Result<String> -- single file
  • generate_project(&ProjectResult) -> Result<Vec<(String, String)>> -- multi-file

Each backend (Rust, TypeScript, Go) implements this trait. The CLI dispatches to the appropriate backend based on --target.

API stability tiers

TierStabilityContents
Tier 1Stablecompile(), IR types, CodegenBackend trait
Tier 2Semi-stableAST types, individual pipeline stages
Tier 3InternalLexer, parser, canonical form internals

Build sequence

The project follows a spec-driven development approach:

  1. Specification (normative language spec)
  2. PEG grammar (derived from spec)
  3. Corpus (valid/invalid test files with spec references)
  4. Reference implementation (Rust workspace + TypeScript runtime)

Source