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 namespacemessage 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
| Type | Size | Description |
|---|---|---|
bool | 1 bit | True or false |
u8 -- u64 | 8--64 bits | Unsigned integers |
i8 -- i64 | 8--64 bits | Signed integers (two's complement) |
f32 | 32 bits | IEEE 754 single-precision float |
f64 | 64 bits | IEEE 754 double-precision float |
Sub-byte types
| Type | Size | Description |
|---|---|---|
u1 -- u7 | 1--7 bits | Unsigned sub-byte integers |
i2 -- i7 | 2--7 bits | Signed 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
| Type | Wire encoding | Description |
|---|---|---|
string | LEB128 length + UTF-8 | Text |
bytes | LEB128 length + raw | Binary data |
uuid | 16 bytes | UUID as raw bytes |
timestamp | 64-bit signed | Unix epoch (interpretation is application-defined) |
rgb | 3 bytes | Red, green, blue |
hash | 32 bytes | BLAKE3 hash |
Parameterized types
| Type | Description |
|---|---|
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:
| Annotation | Applies to | Effect |
|---|---|---|
@varint | unsigned integers | LEB128 variable-length encoding |
@zigzag | signed integers | ZigZag encoding (small magnitudes use fewer bytes) |
@delta | numeric fields in arrays | Delta encoding (store differences, not absolute values) |
message Packet {
sequence @0 : u32 @varint
offset @1 : i32 @zigzag
}
Declaration annotations
| Annotation | Applies to | Effect |
|---|---|---|
@non_exhaustive | enum, union | Allows adding variants without breaking decoders |
@deprecated | fields, variants | Marks as deprecated in generated code |
@removed(ordinal, reason: "...") | message fields | Typed 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:
- Parses the root file and discovers imports
- Resolves each import against the include directories
- Compiles all schemas in topological order (dependencies first)
- 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:
usestatements referencing sibling modules - TypeScript: relative
importstatements with barrelindex.tsfiles - 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):
| Change | Classification |
|---|---|
| Add a field with a new ordinal | Minor |
Add a variant to @non_exhaustive enum/union | Minor |
Mark a field @deprecated | Patch |
| Rename a field (ordinal unchanged) | Patch |
Breaking changes
These changes require all peers to upgrade simultaneously:
| Change | Why |
|---|---|
| Remove a field | Wire layout changes |
| Change a field's type | Wire encoding differs |
| Change a field's ordinal | Wire order changes |
Add/remove @varint, @zigzag, @delta | Encoding 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:
- The first value as-is
- 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
@varintor@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
| Command | Description |
|---|---|
check | Validate a schema and print its hash |
codegen | Generate code for a single schema file |
build | Generate code for a multi-file project |
watch | Watch files and rebuild on changes |
compat | Compare schemas for breaking changes |
init | Create a new schema file |
hash | Print the BLAKE3 schema hash |
pack | Encode a .vx text file to .vxb binary |
unpack | Decode a .vxb binary file to .vx text |
format | Format a .vx text file |
info | Inspect .vxb/.vxc file headers |
compile | Compile a schema to .vxc binary format |
Global options
| Option | Description |
|---|---|
-V, --version | Print version |
-h, --help | Print help |
Targets
The --target option (used by codegen, build, and watch) accepts:
rust(default)typescriptgo
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
| Code | Meaning |
|---|---|
| 0 | Schema is valid |
| 1 | Schema has errors |
codegen
Generate code from a single Vexil schema file.
Usage
vexilc codegen <file.vexil> [--target <target>] [--output <path>]
Options
| Option | Default | Description |
|---|---|---|
--target <target> | rust | Code generation target: rust, typescript, or go |
--output <path> | stdout | Write 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
buildinstead - The generated code depends on the corresponding runtime library (
vexil-runtimefor Rust,@vexil-lang/runtimefor 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
| Option | Default | Description |
|---|---|---|
--include <dir> | (none) | Directory to search for imported schemas (can be repeated) |
--output <dir> | (required) | Output directory for generated code |
--target <target> | rust | Code 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
- Parses the root schema file
- Discovers
importstatements and resolves them against--includedirectories - Compiles all schemas in topological order (dependencies before dependents)
- Generates one output file per namespace, with cross-file references handled by the backend
- 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
| Option | Default | Description |
|---|---|---|
--include <dir> | (none) | Additional directories to watch and search for imports |
--output <dir> | (none) | Output directory (if omitted, runs check only) |
--target <target> | rust | Code 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
--includedirectories recursively - Only reacts to
.vexilfile changes (creates and modifications) - Debounces rapid changes with a 200ms delay
- If
--outputis omitted, runscheckon 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
| Option | Default | Description |
|---|---|---|
--format <format> | human | Output 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
| Code | Meaning |
|---|---|
| 0 | Compatible changes only |
| 1 | Breaking changes detected |
| 2 | Schema 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
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
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
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:
channeluses only 4 bits (supports 16 channels)kinduses 8 bits for the enum discriminantvalueis a fixed 16-bit readingsequenceuses 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
Cross-Language Interop
The examples/cross-language/ directory demonstrates Rust and Node.js exchanging binary data through Vexil-encoded .vxb files.
How it works
- A shared
.vexilschema defines the data types - The Rust program encodes data to a
.vxbbinary file - The Node.js program reads the same
.vxbfile and decodes it - 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
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
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 mainbefore 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
| Type | Description |
|---|---|
CompiledSchema | Single-file compilation result: registry, declarations, namespace, wire size |
ProjectResult | Multi-file result: Vec<(String, CompiledSchema)> in topological order |
TypeRegistry | Opaque type store indexed by TypeId |
TypeDef | Sum type: Message, Enum, Flags, Union, Newtype, Config |
CodegenBackend | Trait 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 filegenerate_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
| Tier | Stability | Contents |
|---|---|---|
| Tier 1 | Stable | compile(), IR types, CodegenBackend trait |
| Tier 2 | Semi-stable | AST types, individual pipeline stages |
| Tier 3 | Internal | Lexer, parser, canonical form internals |
Build sequence
The project follows a spec-driven development approach:
- Specification (normative language spec)
- PEG grammar (derived from spec)
- Corpus (valid/invalid test files with spec references)
- Reference implementation (Rust workspace + TypeScript runtime)