Skip to main content

Encoding and Decoding

Much like Ribs JSON library, Ribs lets you define binary Codecs that make it very easy to get complete control over decoding and encoding your Dart objects to binary data.

Let's start with a hypothical set of models.

Domain Model
final class Document {
final Header header;
final IList<Message> messages;

const Document(this.header, this.messages);
}

final class Header {
final double version;
final String comment;
final int numMessages;

const Header(this.version, this.comment, this.numMessages);
}

sealed class Message {}

final class Info extends Message {
final String message;

Info(this.message);
}

final class Debug extends Message {
final int lineNumber;
final String message;

Debug(this.lineNumber, this.message);
}

Codec

To start, we'll define a Codec for the Info and Debug classes:

Subclass Codecs
final infoCodec =
Codec.utf16_32.xmap((str) => Info(str), (info) => info.message);

final debugCodec = Codec.product2(
Codec.int32L, // 32-bit little endian int
Codec.ascii32, // ascii bytes with 32bit size prefix
Debug.new,
(dbg) => (dbg.lineNumber, dbg.message),
);

The infoCodec uses the utf16_32 codec which prefixes a 32-bit integer, indicating the length of the encoded string and then the string itself using a UTF16 encoding.

The debugCodec needs to use 2 different codecs to properly encode/decode the 2 fields from the Debug class:

  • int32L: Serializes the int as little endian using 32-bits
  • ascii32: Serializes the string using ASCII, while prepending a 32-bit integer to indicate the length of the string.

Next, we'll define a Codec for Message, the superclass of Info and Debug:

Superclass Discriminator Codec
final messageCodec = Codec.discriminatedBy(
Codec.uint8, // encode identifier using an 8-bit integer
imap({
0: infoCodec, // instances of Info prefixed by ID 0
1: debugCodec, // instances of Debug prefixed by ID 1
}),
);

Here we use discriminatedBy to allow us to properly encode and decode instances of Message by prefixing an unique indentifier tag before each message. In this particular instance, that tag is an 8-bit integer.

The only pieces left are the codecs for Header and Document:

Domain Model
final documentCodec = Codec.product2(
headerCodec,
// ilist of Messages with 16-bit int prefix indicating # of elements
Codec.ilistOfN(Codec.int16, messageCodec),
Document.new,
(doc) => (doc.header, doc.messages),
);

final headerCodec = Codec.product3(
Codec.float32, // 32-bit floating point
Codec.utf8_32, // utf8 bytes with 32bit size prefix
Codec.int64, // 64-bit integer
(version, comment, numMessages) => Header(version, comment, numMessages),
(hdr) => (hdr.version, hdr.comment, hdr.numMessages),
);

Finally let's see how you can use the Codec to encode and decode binary data:

Domain Model
final doc = Document(
const Header(1.1, 'Top Secret', 3),
IList.fromDart([
Info('Hello!'),
Debug(123, 'breakpoint-1'),
Info('Goodbye!'),
]));

// Encoding will give us an error or the successfully encoded BitVector
final Either<Err, BitVector> bits = documentCodec.encode(doc);

print(bits);
// Right(3f8ccccd00000050546f702053656372657400000000000000030003000000003048656c6c6f21017b00000000000060627265616b706f696e742d310000000040476f6f6462796521)

// Decoding will give us either an error if it failed or the DecodeResult
// A DecodeResult gives us the successfully decoded value and any remaining bits from the input
// ** Note the throw is included only for edification purposes. This is not a good idea in production code
final Either<Err, DecodeResult<Document>> decoded = documentCodec
.decode(bits.getOrElse(() => throw Exception('encode failed!')));

print(decoded);
// Right(DecodeResult(Instance of 'Document', ByteVector.empty))

The example above illustrates a successful encoding of a Document and then a successful decoding of those previously encoded bits.