🧱Serialization with Borsh
@race-foundation/borsh
Introduction
Borsh (Binary Object Representation Serializer for Hashing) is a standardized, deterministic serialization format. In the context of the RACE Protocol, it is the cornerstone for ensuring that data structures are consistently represented across different parts of the system — from your web client to the on-chain game logic.
Why is this important?
Determinism: Serializing the same JavaScript object will always produce the exact same sequence of bytes. This is critical for cryptographic operations like hashing and signing, where even a minor difference would result in a completely different output.
Compactness: The binary format is compact, making it efficient for network transmission and on-chain storage, which helps reduce transaction fees.
Interoperability: It defines a clear specification for how data is structured. This is essential for the reliable communication between the off-chain client (your application) and the on-chain game logic (the WebAssembly handler).
The @race-foundation/borsh package is a powerful implementation of this standard, designed with developer experience in mind. It uses TypeScript decorators and helper functions to make the process of defining serializable data structures intuitive and straightforward.
Defining Schemas
To make a class serializable, you must first define its "schema." This tells the library how to convert each property into its binary format. This is done by decorating the class properties with @field and using helper functions for complex types.
Primitives: Use the
@fielddecorator with a string literal for basic types.'u8','u16','u32': Unsigned integers. These map to JavaScriptnumber.'u64','usize': Large unsigned integers. These must be handled asbigintin JavaScript.'bool': A boolean value, serialized as a single byte (0or1).'string': A UTF-8 string, prefixed with its length as au32.'u8-array': A dynamic array of bytes (Uint8Array), also prefixed with its length as au32.
import { field } from '@race-foundation/borsh'; class Player { @field('u8') level!: number; @field('u64') experience!: bigint; @field('string') name!: string; }Fixed-Size Byte Arrays: For arrays with a known, fixed length (like public keys or hashes), use the
@fielddecorator with a number representing the byte length.import { field } from '@race-foundation/borsh'; class CryptoKeys { @field(32) // A 32-byte public key publicKey!: Uint8Array; }Dynamic Arrays: For arrays of any other type where the length can vary, use the
array()helper function. It takes the type definition of the array's elements as its argument.import { field, array, struct } from '@race-foundation/borsh'; class Item { @field('u32') id!: number; } class Inventory { // An array of primitive numbers @field(array('u8')) itemQuantities!: number[]; // An array of other serializable objects (structs) @field(array(struct(Item))) items!: Item[]; }Structs (Nested Objects): To nest one serializable object within another, use the
struct()helper, passing the class constructor of the nested object.import { field, struct } from '@race-foundation/borsh'; class Position { @field('u32') x!: number; @field('u32') y!: number; } class GameObject { @field(struct(Position)) position!: Position; }Enums (Variants): Enums allow you to serialize one of several different object shapes under a common abstract type. This is perfect for game events or states that can have multiple forms.
Define an
abstractbase class.For each variant, create a class that extends the base class.
Decorate each variant class with
@variant(index), whereindexis a uniqueu8number (0-255) identifying that variant.
import { field, variant, enums } from '@race-foundation/borsh'; // 1. Define the abstract base class abstract class GameEvent {} // 2. Create and decorate each variant @variant(0) class PlayerMove extends GameEvent { @field('u32') x!: number; @field('u32') y!: number; } @variant(1) class PlayerAttack extends GameEvent { @field('u64') targetId!: bigint; } // In another class, use the `enums()` helper with the base class class Action { @field(enums(GameEvent)) event!: GameEvent; }Options (Optional Fields): For fields that might be
undefinedornull, wrap their type with theoption()helper. This adds a 1-byte prefix (0for none,1for some) to the serialized data.import { field, option } from '@race-foundation/borsh'; class PlayerProfile { @field(option('string')) nickname?: string; } const player1 = new PlayerProfile(); // nickname is undefined const player2 = new PlayerProfile(); player2.nickname = 'Racer';Maps: To serialize
Mapobjects, use themap()helper, specifying the key type and value type.import { field, map } from '@race-foundation/borsh'; class Scoreboard { @field(map('string', 'u32')) scores!: Map<string, number>; }
Using serialize and deserialize
serialize and deserializeOnce your schemas are defined, converting object instances to and from byte arrays is a simple two-function process.
serialize(object: any): Uint8Array: Takes an instance of a schema-defined class and returns itsUint8Arraybyte representation.deserialize<T>(class: Ctor<T> | EnumClass<T>, data: Uint8Array): T: Takes the class constructor (which holds the schema information) and aUint8Array, and returns a new, hydrated instance of that class.
Complete Example:
For more advanced examples, including nested structs, enums, and arrays, refer to the tests in the
@race-foundation/borshpackage, specificallypackages/borsh/tests/serialize.spec.ts.
Command-Line Tool
For quick tests, scripting, or debugging, the @race-foundation/borsh package provides a handy command-line tool, borsh-serialize, to serialize data without writing any code.
You can run it directly with npx.
Usage:
Options:
-s STRING: Appends a string.-u8 INT: Appends an integer as au8.-u16 INT: Appends an integer as au16.-u32 INT: Appends an integer as au32.-u64 INT: Appends an integer as au64.-b BOOL: Appends a boolean (trueorfalse).
Example:
Let's serialize a string "abc", followed by the boolean true, followed by the number 100 as a u64.
Output:
[3,0,0,0]: Theu32length prefix for the string "abc".[97,98,99]: The UTF-8 bytes for "a", "b", and "c".[1]: Theu8representation of true.[100,0,0,0,0,0,0,0]: The little-endianu64representation of 100.
Last updated