TDBIN Binary Codec

TDBIN Binary Codec

TDBIN is typeDiagram's compact binary format for algebraic data types: records and tagged unions. A generated codec serializes a typed value directly to bytes and materializes the same typed value from bytes. It does not build a generic JSON-like value tree at runtime.

Status and when to use it

TDBIN is an in-repository, pre-release implementation. Its Rust runtime, generated Rust codecs, bare frames, packed frames, structural verification, and golden-vector tests are implemented. The TypeScript runtime and generator are also available, but are partial: use them for the supported shapes below and do not claim full Rust/TypeScript production conformance yet.

Use TDBIN when the producer and consumer share a typeDiagram schema and you want deterministic, compact binary messages. Do not use the bare format as a self-describing interchange format: bare bytes contain no schema identity. Use a framed message with an expected compatibility hash at a trust or deployment boundary.

The implementation has not been published as a stable crate or npm release. The commands and dependency paths in this guide are for a checkout of this repository.

Quick start: one schema, generated codecs

Start with a schema that uses records, an optional nested record, and a tagged union. This is the schema used by the Rust example below.

type Address {
  street: String
  zip: Int
}

type EmailContact {
  addr: String
}

type PhoneContact {
  number: Int
  country: Int
}

union Contact {
  Email(EmailContact)
  Phone(PhoneContact)
}

type Person {
  name: String
  age: Int
  active: Bool
  score: Float
  address: Option<Address>
  nickname: Option<String>
  contact: Contact
}
 

Save it as person.td, validate the schema, then emit Rust. encode emits the Rust type declarations and their tdbin::Struct implementations together. decode emits only the implementations, which is useful when the Rust types were generated separately with --to rust.

typediagram verify person.td
typediagram encode person.td > src/person.rs

# Alternative: generate the Rust ADTs separately, then only the codec impls.
typediagram --to rust person.td > src/types.rs
typediagram decode person.td > src/tdbin_codec.rs

The generated layout is part of the source: every record and union gets fixed data-word and pointer-word counts plus slot-addressed read/write code. Do not hand-write those implementations or reuse a codec generated from a different schema.

Rust: encode and decode generated types

Add the in-workspace runtime to the consuming Rust crate. In a standalone checkout, replace the relative path with the path to this repository.

[dependencies]
tdbin = { path = "../typeDiagram/crates/tdbin" }

Place the result of typediagram encode person.td in src/person.rs. The following main.rs constructs an actual generated Person, serializes it, decodes it, and proves that re-encoding produces the exact same bytes.

mod person;

use person::{Address, Contact, EmailContact, Person};
use tdbin::TdBin;

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let person = Person {
        name: "Ada Lovelace".to_owned(),
        age: 36,
        active: true,
        score: 9.75,
        address: Some(Address {
            street: "1 Analytical Way".to_owned(),
            zip: 1815,
        }),
        nickname: Some("Countess".to_owned()),
        contact: Contact::Email(EmailContact {
            addr: "ada@example.com".to_owned(),
        }),
    };

    let bytes = person.to_bytes()?;
    let decoded = Person::from_bytes(&bytes)?;
    assert_eq!(decoded, person);

    let encoded_again = decoded.to_bytes()?;
    assert_eq!(encoded_again, bytes);
    Ok(())
}

TdBin is blanket-implemented for every generated tdbin::Struct, so import the trait to access to_bytes and from_bytes. Both methods return typed errors instead of panicking; propagate or handle EncodeError and DecodeError in application code.

Rust framing, packing, and schema checks

to_bytes produces a bare, word-aligned body. It is ideal inside a context where the schema is already fixed, but it has no magic bytes, length prefix, or schema identity. For files, queues, services, or any boundary that may receive the wrong message type, use a frame and require the expected hash on decode.

use person::Person;
use tdbin::TdBin;

fn decode_person_frame(bytes: &[u8], expected_hash: u64) -> Result<Person, tdbin::DecodeError> {
    Person::from_framed_bytes_with_hash(bytes, expected_hash)
}

fn encode_person_frame(person: &Person, schema_hash: u64) -> Result<Vec<u8>, tdbin::EncodeError> {
    person.to_packed_framed_bytes(Some(schema_hash))
}

There are three wire choices:

Method Bytes produced Typical use
to_bytes / from_bytes Bare, unpacked body An in-process or otherwise schema-fixed boundary
to_framed_bytes / from_framed_bytes Self-delimiting frame around the body Streams and stored messages when compression is not needed
to_packed_framed_bytes / from_framed_bytes_with_hash Frame with Cap'n-Proto-style word packing Sparse data over a boundary; pair it with an expected hash

from_framed_bytes validates framing and packed content but intentionally does not compare a schema hash. from_framed_bytes_with_hash rejects a missing or different hash with DecodeError::HashMismatch; use that method when the sender is not already constrained to the exact layout.

TypeScript: generate a codec, then round-trip

TypeScript uses the public typediagram-core/tdbin runtime. The generator produces TypeScript interfaces/unions plus a StructCodec<T> for every supported type. It returns a Result, so diagnostics stay values instead of becoming thrown exceptions.

This is a build-time script that reads the same person.td schema and writes a module named person.ts. The current CLI has TDBIN commands for Rust only; use this public API for TypeScript generation.

import { readFile, writeFile } from "node:fs/promises";
import { model, parser } from "typediagram-core";
import { generateTypeScriptModule } from "typediagram-core/converters/typescript-tdbin";

const source = await readFile("person.td", "utf8");
const parsed = parser.parse(source);
const resolved = parsed.ok ? model.buildModel(parsed.value) : parsed;
const generated = resolved.ok ? generateTypeScriptModule(resolved.value) : resolved;

if (!generated.ok) {
  process.stderr.write(`${JSON.stringify(generated.error)}\n`);
  process.exitCode = 1;
} else {
  await writeFile("src/person.ts", generated.value);
}

After generating src/person.ts, the generated PersonCodec is a real codec object. The following program exercises both byte directions and uses the framed encoder/automatic decoder as well. Int is represented as a JavaScript number today, so the TypeScript generator rejects values outside the safe integer range rather than silently changing them.

import * as tdbin from "typediagram-core/tdbin";
import { PersonCodec, type Person } from "./person.js";

const person = {
  name: "Ada Lovelace",
  age: 36,
  active: true,
  score: 9.75,
  address: { street: "1 Analytical Way", zip: 1815 },
  nickname: "Countess",
  contact: { kind: "Email", _0: { addr: "ada@example.com" } },
} satisfies Person;

const bare = tdbin.encode(PersonCodec, person);
if (!bare.ok) {
  process.stderr.write(`${bare.error.code}: ${bare.error.message}\n`);
  process.exitCode = 1;
} else {
  const decoded = tdbin.decode(PersonCodec, bare.value);
  if (!decoded.ok) {
    process.stderr.write(`${decoded.error.code}: ${decoded.error.message}\n`);
    process.exitCode = 1;
  } else {
    const reencoded = tdbin.encode(PersonCodec, decoded.value);
    const byteIdentical = reencoded.ok && tdbin.toHex(reencoded.value) === tdbin.toHex(bare.value);
    process.stdout.write(`round trip: ${String(byteIdentical)}\n`);
  }
}

const schemaHash = 0xdecafn;
const framed = tdbin.encodePackedFramed(PersonCodec, person, schemaHash);
const decodedFrame = framed.ok ? tdbin.decodeAuto(PersonCodec, framed.value, schemaHash) : framed;

decodeAuto decodes a bare body when it sees no frame magic and otherwise validates the frame, unpacks it if necessary, checks the supplied hash when one is provided, then calls the typed codec. The returned error has a stable code such as HashMismatch, BadMagic, InvalidUtf8, UnknownVariant, or PointerOutOfBounds.

TypeScript support boundary

The TypeScript implementation is deliberately conservative in its current state. It supports generated records with Bool, Int, Float, String, Bytes, optional pointer fields, and nested generated records/unions. It also supports unions with bare variants, a single generated-record payload, or a single String payload.

It does not yet generate codecs for lists, scalar Option<T>, semantic scalars (DateTime, Uuid, Decimal), generics, inline enum-unions, or every Rust-supported schema form. Generated modules are not yet compiled and executed across the full browser and Rust interoperability matrix. Keep a schema inside the supported subset and use the Rust codec when you need the broader implemented feature set. See the TypeScript codec roadmap for the release criteria.

What the bytes guarantee

TDBIN layouts are determined by the schema rather than by per-field tags. Every struct has a scalar data section followed by pointer slots; pointers refer to strings, byte lists, nested structs, and composite lists. The format uses little-endian 64-bit words, and all bodies are word-aligned.

This design makes encoding canonical. For a fixed schema and typed value, value → bytes is deterministic, and bytes → value → bytes preserves the original canonical bytes. The checked Rust and TypeScript implementations both validate malformed input before materializing typed values. Their decoder defences include checked bounds, pointer-kind checks, UTF-8 validation, a maximum nesting depth, and an amplification budget for hostile pointer graphs.

Read the wire-format specification for bit-level layouts, frame flags, pointer encodings, packing, defaults, and evolution rules. The Rust codec specification describes the public Rust API and current implementation gaps.

Compatibility rules

TDBIN evolves by layout, not by source names. These are the practical rules for long-lived schemas:

The current generators do not derive and enforce a layout hash automatically. Until they do, choose, distribute, and pass the expected u64/bigint hash at the application boundary yourself. That limitation is why the explicit hash argument appears in both framed examples.

Debugging checklist

Symptom Check
HashMismatch Both sides must use the same compatibility-major hash and the frame must carry it.
BadLength, BadMagic, or BadVersion Confirm that the consumer expects a frame versus a bare body, and that transport has not truncated or transformed bytes.
PointerOutOfBounds, ReservedPointerKind, or InvalidUtf8 Treat input as malformed or corrupted; do not retry it as a different schema.
UnknownVariant The writer used a newer or incompatible union layout; check append-only evolution and discriminant width.
TypeScript generation diagnostic Check the support boundary above; lists, semantic scalars, scalar options, and generics are not emitted yet.
Rust compile error in generated output Run typediagram verify person.td, regenerate with the matching checkout, and ensure the consuming crate depends on this workspace's tdbin runtime.

Next references