Skip to content
BSD
Esc
navigateopen⌘Jpreview
On this page

BSD

Composable, type-safe binary decoding for TypeScript.

Introduction

BSD (Binary Schema Decoder) is a declarative binary decoder for TypeScript. Schemas consume a Uint8Array sequentially and return statically inferred values. BSD decodes data; it does not encode it.

import { bytes, struct, u8, u16 } from "@marceloclp/bsd";

const Profile = struct({
  version: u8().is(1),
  id: u16(),
  name: bytes(u8()).utf8(),
});

const data = Uint8Array.from(1, 42, 0, 3, 65, 100, 97);
const profile = Profile.decode(data);
// { version: 1, id: 42, name: "Ada" }
Expression Operation
struct({...}) Decodes fields in declaration order
u8().is(1) Reads one byte and verifies it is 1
u16() Reads a little-endian 16-bit unsigned integer
bytes(u8()).utf8() Reads a length-prefixed UTF-8 string

Core model

Sequential decoding

A schema reads from a shared cursor. Each operation starts where the previous one ended, so declaration order must match the binary layout.

const Header = struct({
  version: u8(), // byte 0
  flags: u8(), // byte 1
  size: u16(), // bytes 2–3
});

Schema composition

Every constructor returns a schema. Schemas can therefore be nested and reused without custom parsing code.

const Point = struct({
  x: u16(),
  y: u16(),
});

const Polygon = struct({
  id: u32(),
  points: array(u16(), Point),
});

Schemas can also be piped (similar to flatMap()), where the decoded schema is used as an argument for the next schema. This allows for highly dynamic schema composition and decoding, but makes encoding impossible.

// A UTF-16 string where the prefixed length represents
const Utf16String = u32()
  .pipe((len) => bytes(len * 2))
  .utf16();
// A padded UTF-16 string where the prefixed length represents
const PaddedUtf16String = u32()
  .pad(4)
  .pipe((len) => bytes(len * 2))
  .utf16();
const NullTerminatedString = find(u16(), (v) => v < 0x20)
  .pipe((x, r) => bytes(x - r.byteOffset))
  .utf16();

Little-endian numbers

Numeric schemas interpret the least-significant byte first. The schema width determines how many bytes are consumed.

u16().decode(Uint8Array.of(0x34, 0x12));
// 0x1234

Zero-copy byte views

bytes(n) returns a Uint8Array view into the input buffer. This avoids an allocation, but mutations affect the original input. You can use .copy() to make a copy that does not share storage with the input and is safe to mutate.

const input = Uint8Array.of(1, 2, 3);
const view = bytes(2).decode(input);

view[0] = 9;
console.log(input[0]); // 9
console.log(view[0] === input[0]); // true
const input = Uint8Array.of(1, 2, 3);
const view = bytes(2).copy().decode(input);

view[0] = 9;
console.log(input[0]); // 1
console.log(view[0] === input[0]); // false

Validation and transformation

Validation and transformations are achieved through schema modifiers, which run after decoding. Each schema is a sequence of one decoder and zero or more modifiers. Modifiers run in order.

Modifier Usage
.check() Custom validation rule
.is() Shallow equality and type-narrowing
.in() Shallow equality against an iterable and type-narrowing
.transform() Custom transformation
const Version: Bsd<2> = u8().is(2 as const);
const Priority: Bsd<0 | 1 | 2> = u8().in([0, 1, 2] as const);
const Percentage = u16().transform((value) => value / 100);

Contextual failures

Invalid data throws BsdIssue. The error records where the schema started, where decoding stopped, and the nested field or array index.

try {
  array(3, u8().lte(10)).decode(Uint8Array.of(1, 99, 3));
} catch (error) {
  if (error instanceof BsdIssue) {
    console.log(error.schemaOffset); // 1
    console.log(error.byteOffset); // 2
    console.log(error.path); // [1]
  }
}

Was this page helpful?