Define Binary Protocols Once, Generate Everywhere
BinSchema turns declarative, bit-precise schemas into production-ready encoders, decoders, and documentation for TypeScript, Go, and Rust.
Overview
Bit-Level Precision
Define fields from 1 to 64 bits with configurable bit ordering (MSB/LSB first). Perfect for network protocols and hardware formats.
Rich Type System
Primitives, strings, arrays, discriminated unions, back-references, and variable-length integers (LEB128, VLQ, DER).
Computed Fields
Auto-calculate lengths, positions, element counts, and CRC32 checksums. No manual bookkeeping needed.
Multi-Language Output
Generate type-safe encoders and decoders for TypeScript, Go, and Rust from a single schema definition.
IDE Support
JSON Schema available for autocomplete and validation in VS Code, WebStorm, and any editor with JSON Schema support.
Documentation Generation
Generate HTML documentation directly from your schema. Keep docs and code in sync automatically.
When to Use BinSchema
BinSchema is a schema-first tool for designing compact binary protocols. Define your wire format once, get type-safe encoders and decoders in every target language.
Use BinSchema when…
- You're building a real-time protocol (games, chat, telemetry) over WebSockets or TCP and every byte counts
- You need both encoding and decoding — client and server, possibly in different languages
- Your format uses bit-packed flags, variable-length fields, computed lengths, or CRC checksums
- You want one schema, multiple languages — TypeScript frontend, Go backend, Rust embedded
How it compares
- vs. Protobuf / FlatBuffers / Cap'n Proto — those impose their own wire format. BinSchema gives you full control over every bit on the wire
- vs. Kaitai Struct — Kaitai is decode-only. BinSchema generates both encoders and decoders from the same schema
- vs. hand-written parsers — BinSchema eliminates the boilerplate and keeps all languages in sync automatically
Performance
DNS packet decode benchmark — same packets, same schema, all languages. The C parser is hand-written and serves as the theoretical floor.
DNS Query (29 bytes)
| Implementation | Decode | Encode |
|---|---|---|
| C hand-written | 32 ns | — |
| Rust BinSchema | 136 ns | 186 ns |
| ldns (C library) | 214 ns | 431 ns |
| Go BinSchema (optimized) | 96 ns | |
| Go BinSchema (standard) | 320 ns | 1,048 ns |
| Go Kaitai Struct | 395 ns | — |
| TS BinSchema | 995 ns | 950 ns |
DNS Response (45 bytes)
| Implementation | Decode | Encode |
|---|---|---|
| C hand-written | 35 ns | — |
| Rust BinSchema | 225 ns | 309 ns |
| ldns (C library) | 325 ns | 520 ns |
| Go BinSchema (optimized) | 165 ns | |
| Go BinSchema (standard) | 500 ns | 1,511 ns |
| Go Kaitai Struct | 610 ns | — |
| TS BinSchema | 1,600 ns | 1,500 ns |
Kaitai Struct and the C parser are decode-only. ldns is a production C DNS library (NLnet Labs). Rust encode uses encode_into with a reused encoder. All benchmarks on Apple M3 Max.
Installation
From npm
npm install binschema
Usage
# Generate TypeScript code
binschema generate --language ts --schema protocol.bschema.json --out ./generated
# Generate Go code
binschema generate --language go --schema protocol.bschema.json --out ./generated
# Generate Rust code
binschema generate --language rust --schema protocol.bschema.json --out ./generated
# Generate HTML documentation
binschema docs build --schema protocol.bschema.json --out docs.html
import { generateTypeScript, generateGo, validateSchema } from 'binschema';
const schema = {
config: { endianness: 'big_endian' },
types: {
Header: {
sequence: [
{ name: 'magic', type: 'uint32', const: 0x42494E53 },
{ name: 'version', type: 'uint8' },
{ name: 'length', type: 'uint16', computed: { type: 'length_of', target: 'payload' } },
{ name: 'payload', type: 'array', kind: 'field_referenced', length_field: 'length', items: 'uint8' }
]
}
}
};
const result = validateSchema(schema);
const tsCode = generateTypeScript(schema);
const goCode = generateGo(schema);
See It in Action
Define your schema once, generate type-safe encoders and decoders for TypeScript, Go, and Rust.
1. Define Your Schema
{
"config": { "endianness": "big_endian" },
"types": {
"SensorReading": {
"sequence": [
{ "name": "device_id", "type": "uint16" },
{ "name": "temperature", "type": "float32" },
{ "name": "humidity", "type": "uint8" },
{ "name": "timestamp", "type": "uint32" }
]
}
}
}
2. Generate Code
binschema generate --language ts --schema sensor.schema.json --out ./generated
// Generated types and encoder/decoder classes
export interface SensorReadingInput {
device_id: number; // uint16
temperature: number; // float32
humidity: number; // uint8
timestamp: number; // uint32
}
export interface SensorReadingOutput {
device_id: number;
temperature: number;
humidity: number;
timestamp: number;
}
export class SensorReadingEncoder extends BitStreamEncoder {
encode(value: SensorReadingInput): Uint8Array { /* ... */ }
}
export class SensorReadingDecoder extends SeekableBitStreamDecoder {
decode(): SensorReadingOutput { /* ... */ }
}
View full generated code
import { BitStreamEncoder, Endianness } from "./bit-stream.js";
import { SeekableBitStreamDecoder } from "./seekable-bit-stream.js";
import { createReader } from "./binary-reader.js";
export interface SensorReadingInput {
/** 16-bit Unsigned Integer */
device_id: number;
/** 32-bit Floating Point (IEEE 754) */
temperature: number;
/** 8-bit Unsigned Integer */
humidity: number;
/** 32-bit Unsigned Integer */
timestamp: number;
}
export interface SensorReadingOutput {
device_id: number;
temperature: number;
humidity: number;
timestamp: number;
}
export type SensorReading = SensorReadingOutput;
export class SensorReadingEncoder extends BitStreamEncoder {
constructor() {
super("msb_first");
}
encode(value: SensorReadingInput): Uint8Array {
this.writeUint16(value.device_id, "big_endian");
this.writeFloat32(value.temperature, "big_endian");
this.writeUint8(value.humidity);
this.writeUint32(value.timestamp, "big_endian");
return this.finish();
}
calculateSize(value: SensorReading): number {
let size = 0;
size += 2; // device_id
size += 4; // temperature
size += 1; // humidity
size += 4; // timestamp
return size;
}
}
export class SensorReadingDecoder extends SeekableBitStreamDecoder {
constructor(input: Uint8Array | number[] | string) {
const reader = createReader(input);
super(reader, "msb_first");
}
decode(): SensorReadingOutput {
return {
device_id: this.readUint16("big_endian"),
temperature: this.readFloat32("big_endian"),
humidity: this.readUint8(),
timestamp: this.readUint32("big_endian"),
};
}
}
// Generated struct with Encode/Decode methods
type SensorReading struct {
DeviceId uint16
Temperature float32
Humidity uint8
Timestamp uint32
}
func (m *SensorReading) Encode() ([]byte, error) { /* ... */ }
func DecodeSensorReading(bytes []byte) (*SensorReading, error) { /* ... */ }
View full generated code
package main
import (
"fmt"
"github.com/serialexp/binschema/runtime"
)
type SensorReading struct {
DeviceId uint16
Temperature float32
Humidity uint8
Timestamp uint32
}
func (m *SensorReading) Encode() ([]byte, error) {
encoder := runtime.NewBitStreamEncoder(runtime.MSBFirst)
encoder.WriteUint16(m.DeviceId, runtime.BigEndian)
encoder.WriteFloat32(m.Temperature, runtime.BigEndian)
encoder.WriteUint8(m.Humidity)
encoder.WriteUint32(m.Timestamp, runtime.BigEndian)
return encoder.Finish(), nil
}
func DecodeSensorReading(bytes []byte) (*SensorReading, error) {
decoder := runtime.NewBitStreamDecoder(bytes, runtime.MSBFirst)
result := &SensorReading{}
deviceId, err := decoder.ReadUint16(runtime.BigEndian)
if err != nil {
return nil, fmt.Errorf("failed to decode device_id: %w", err)
}
result.DeviceId = deviceId
temperature, err := decoder.ReadFloat32(runtime.BigEndian)
if err != nil {
return nil, fmt.Errorf("failed to decode temperature: %w", err)
}
result.Temperature = temperature
humidity, err := decoder.ReadUint8()
if err != nil {
return nil, fmt.Errorf("failed to decode humidity: %w", err)
}
result.Humidity = humidity
timestamp, err := decoder.ReadUint32(runtime.BigEndian)
if err != nil {
return nil, fmt.Errorf("failed to decode timestamp: %w", err)
}
result.Timestamp = timestamp
return result, nil
}
// Generated structs with encode/decode implementations
#[derive(Debug, Clone, PartialEq)]
pub struct SensorReadingInput {
pub device_id: u16,
pub temperature: f32,
pub humidity: u8,
pub timestamp: u32,
}
#[derive(Debug, Clone, PartialEq)]
pub struct SensorReadingOutput {
pub device_id: u16,
pub temperature: f32,
pub humidity: u8,
pub timestamp: u32,
}
impl SensorReadingInput {
pub fn encode(&self) -> Result<Vec<u8>> { /* ... */ }
}
impl SensorReadingOutput {
pub fn decode(bytes: &[u8]) -> Result<Self> { /* ... */ }
}
View full generated code
use binschema_runtime::{
BitStreamEncoder, BitStreamDecoder,
Endianness, BitOrder, Result,
};
#[derive(Debug, Clone, PartialEq)]
pub struct SensorReadingInput {
pub device_id: u16,
pub temperature: f32,
pub humidity: u8,
pub timestamp: u32,
}
#[derive(Debug, Clone, PartialEq)]
pub struct SensorReadingOutput {
pub device_id: u16,
pub temperature: f32,
pub humidity: u8,
pub timestamp: u32,
}
pub type SensorReading = SensorReadingOutput;
impl SensorReadingInput {
pub fn encode(&self) -> Result<Vec<u8>> {
let mut encoder = BitStreamEncoder::new(BitOrder::MsbFirst);
encoder.write_uint16(self.device_id, Endianness::BigEndian);
encoder.write_float32(self.temperature, Endianness::BigEndian);
encoder.write_uint8(self.humidity);
encoder.write_uint32(self.timestamp, Endianness::BigEndian);
Ok(encoder.finish())
}
}
impl SensorReadingOutput {
pub fn decode(bytes: &[u8]) -> Result<Self> {
let mut decoder = BitStreamDecoder::new(
bytes.to_vec(), BitOrder::MsbFirst
);
let device_id = decoder.read_uint16(Endianness::BigEndian)?;
let temperature = decoder.read_float32(Endianness::BigEndian)?;
let humidity = decoder.read_uint8()?;
let timestamp = decoder.read_uint32(Endianness::BigEndian)?;
Ok(Self {
device_id,
temperature,
humidity,
timestamp,
})
}
}
3. Use the Generated Code
import { SensorReadingEncoder, SensorReadingDecoder } from "./generated.js";
// Encode a sensor reading to binary
const encoder = new SensorReadingEncoder();
const bytes = encoder.encode({
device_id: 42,
temperature: 23.5,
humidity: 65,
timestamp: 1700000000,
});
// bytes: Uint8Array(11) — ready to send over the wire
// Decode binary back to a typed object
const decoder = new SensorReadingDecoder(bytes);
const reading = decoder.decode();
console.log(reading.temperature); // 23.5
console.log(reading.device_id); // 42
package main
import "fmt"
func main() {
// Encode a sensor reading to binary
reading := &SensorReading{
DeviceId: 42,
Temperature: 23.5,
Humidity: 65,
Timestamp: 1700000000,
}
bytes, err := reading.Encode()
if err != nil {
panic(err)
}
// bytes: []byte — ready to send over the wire
// Decode binary back to a typed struct
decoded, err := DecodeSensorReading(bytes)
if err != nil {
panic(err)
}
fmt.Println(decoded.Temperature) // 23.5
fmt.Println(decoded.DeviceId) // 42
}
use generated::*;
fn main() -> Result<()> {
// Encode a sensor reading to binary
let reading = SensorReadingInput {
device_id: 42,
temperature: 23.5,
humidity: 65,
timestamp: 1_700_000_000,
};
let bytes = reading.encode()?;
// bytes: Vec<u8> — ready to send over the wire
// Decode binary back to a typed struct
let decoded = SensorReadingOutput::decode(&bytes)?;
println!("{}", decoded.temperature); // 23.5
println!("{}", decoded.device_id); // 42
Ok(())
}
Schema Definition Reference
A quick overview of the types and constructs available in a BinSchema definition.
Primitives
uint8,uint16,uint32,uint64int8,int16,int32,int64float32,float64bool
Bit-Level
bit— 1–64 bits, MSB or LSB first- Mixed bit and byte fields in the same struct
- Configurable bit ordering per-schema or per-field
Strings & Bytes
- Fixed-length and length-prefixed strings
- Null-terminated strings
- Raw byte arrays with field-referenced lengths
Arrays
- Fixed count, field-referenced count
- Length-delimited (byte length from another field)
- Variant-terminated (read until a discriminator matches)
Computed Fields
length_of— byte length of another fieldcount_of— element count of an arrayposition_of— byte offset for random accesscrc32_of— CRC32 checksum
Advanced
- Discriminated unions (tagged variants)
- Conditional fields (
ifexpressions) - Variable-length integers (LEB128, VLQ, DER)
- Constant and reserved fields
Examples
Explore real-world schemas and the documentation generated directly from them.