Serialization
This page assumes prior knowledge of the TL-B and TVM. It serves as a concise low-level reference.
A consolidated summary of how Tolk types are serialized into TL-B–compatible binary data.
int
- Not serializable. Use
intNor other numeric types.
intN
- Fixed N-bit signed integer
- TL-B:
intN - Stored using
{N} STI - Loaded using
{N} LDI
uintN
- Fixed N-bit unsigned integer
- TL-B:
uintN - Stored using
{N} STU - Loaded using
{N} LDU
coins
- Alias to
varuint16 - TL-B:
VarUInteger 16 - Stored using
STGRAMS - Loaded using
LDGRAMS
varintN (N = 16 or 32)
- Variable-length signed integer: 4 or 5 bits for
length + 8 * lenbit number - TL-B:
VarInteger {N} - Stored using
STVARINT{N} - Loaded using
LDVARINT{N}
varuintN (N = 16 or 32)
- Variable-length unsigned integer: 4 or 5 bits for
length + 8 * lenbit number - TL-B:
VarUInteger {N} - Stored using
STVARUINT{N} - Loaded using
LDVARUINT{N}
bool
- One bit:
0or1 - TL-B:
Bool - Stored using
1 STI - Loaded using
1 LDIresulting in0or-1
address
- Standard internal address (267 bits):
0b100 + workchain + hash - TL-B:
addr_std - Stored using
STSTDADDR - Loaded using
LDSTDADDR
address? (nullable)
- Internal address or none (2 or 267 bits):
00for null, otherwise an address - TL-B:
addr_noneoraddr_std - Stored using
STOPTSTDADDR - Loaded using
LDOPTSTDADDR
any_address
- Any valid TL-B address, from 2 to 523 bits
- TL-B:
MsgAddress - Stored using
STSLICE - Loaded using
LDMSGADDR
cell, Cell<T>
- A reference
- TL-B:
^Cell/^T - Stored using
STREF - Loaded using
LDREF
cell?, Cell<T>? (nullable)
- Maybe reference (
0or1+ref) - TL-B:
Maybe ^Cell/Maybe ^T - Stored using
STOPTREF - Loaded using
LDOPTREF
bitsN
- N bits
- TL-B:
bitsN - Stored using
STSLICE, preceded by a runtime check that the slice contains exactly N bits and zero references; the check can be turned off usingskipBitsNValidation = false. - Loaded using
LDSLICE/LDSLICEXfor N > 256
RemainingBitsAndRefs
- Represents the remainder of a slice when reading, and a raw slice when writing.
- TL-B:
Cell - Stored using
STSLICE - Loaded by copying the current slice and resetting the reader to an empty slice.
builder, slice
- Can be used for writing, but not for reading.
- Not recommended, as they do not reveal internal structure and have unpredictable size.
- Auto-generated TypeScript wrappers cannot parse them.
struct
If a struct has a prefix, it is written first. The fields are then serialized sequentially.
struct (0x12345678) A {
a: int8
b: cell?
}
fun demo() {
val a: A = {
a: 123,
b: createEmptyCell(),
};
// 41 bits and 1 ref: opcode + int8 + '1' + empty ref
a.toCell()
}32-bit prefixes (opcodes)
By convention, all incoming and outgoing messages use 32-bit prefixes:
struct (0x7362d09c) TransferNotification {
queryId: uint64
// ...
}Not only 32-bit prefixes
Declaring messages with opcodes does not differ from declaring ordinary structs. A prefix can have any bit width:
0x000F— 16-bit prefix0x0F— 8-bit prefix0b010— 3-bit prefix0b00001111— 8-bit prefix
Consider the TL-B scheme:
asset_simple$001 workchain:int8 ptr:bits32 = Asset;
asset_booking$1000 order_id:uint64 = Asset;
// ...In Tolk, use structures and union types:
struct (0b001) AssetSimple {
workchain: int8
ptr: bits32
}
struct (0b1000) AssetBooking {
orderId: uint64
}
type Asset = AssetSimple | AssetBooking // | ...During deserialization, Asset is matched using the explicitly declared prefixes.
If a struct has a prefix, it is applied consistently in all contexts, both standalone and as part of a union:
AssetBooking.fromSlice(s) // expecting '1000...' (binary)
AssetBooking{...}.toCell() // '1000...'Type aliases
A type alias is identical to its underlying type, unless a custom serializer is defined.
To implement a "variadic string" encoded as len + data:
len: (## 8) // 8 bits of len
data: (bits len) // 0..255 bits of dataTo express this, define a type and provide a custom serializer:
type ShortString = slice
fun ShortString.packToBuilder(self, mutate b: builder) {
val nBits = self.remainingBitsCount();
b.storeUint(nBits, 8);
b.storeSlice(self);
}
fun ShortString.unpackFromSlice(mutate s: slice) {
val nBits = s.loadUint(8);
return s.loadBits(nBits);
}ShortString can then be used as a regular type everywhere:
tokenName: ShortString
fullDomain: Cell<ShortString>The method names packToBuilder and unpackFromSlice are reserved for this purpose. Their signatures must match exactly as shown.
enum
The serialization type can be specified manually:
// `Role` will be (un)packed as `int8`
enum Role: int8 {
Admin,
User,
Guest,
}
struct ChangeRoleMsg {
ownerAddress: address
newRole: Role // int8: -128 <= V <= 127
}Otherwise, it is calculated automatically. For Role above, uint2 is sufficient to fit values 0, 1, 2:
// `Role` will (un)packed as `uint2`
enum Role {
Admin,
User,
Guest,
}Input values are validated during deserialization. For enum Role: int8, any input value outside the range of defined enum variants, e.g., < 0 or > 2, triggers exception 5.
Values not explicitly listed in the enum definition are also rejected:
enum OwnerHashes: uint256 {
id1 = 0x1234,
id2 = 0x2345,
...
}
// on serialization, just "store uint256"
// on deserialization, "load uint256" + throw 5 if v not in [0x1234, 0x2345, ...]Nullable types T? (except address?)
- Often called
Maybe;0or1+T - TL-B:
(Maybe T) - Stored using
1 STI+IF... - Loaded using
1 LDI+IF...
Exception: address? is serialized as internal or none (2 or 267 bits):
00– null;- otherwise – a standard internal address.
Union types T1 | T2 | ...
T | nullis serialized as TL-BMaybe T.- If all
T_ihave prefixesstruct (0x1234) A, those prefixes are used. - Otherwise, the compiler generates a prefix tree automatically.
Manual serialization prefixes
If all T_i have manual prefixes, they are used:
struct (0b001) AssetSimple { /* body1 */ }
struct (0b1000) AssetBooking { /* body2 */ }
struct (0b01) AssetNothing {}
struct Demo {
// '001'+body1 OR '1000'+body2
e: AssetSimple | AssetBooking
// '001'+body1 OR '1000'+body2 OR '01'
f: AssetSimple | AssetBooking | AssetNothing
}If a prefix exists for A but not for B, the union A | B cannot be serialized.
Auto-generated prefix tree
If T_i do not have manual prefixes, the compiler generates a prefix tree.
-
A two-component union
T1 | T2is serialized as TL-BEitherusing prefixes 0 and 1. Example:int32 | int64→0 + int32or1 + int64. -
For unions with more components, longer prefixes are generated. Example:
int32 | int64 | int128 | int256→00 / 01 / 10 / 11
General rules:
- If
nullis present, it is assigned prefix 0, and all other variants use1 + tree:A|B|C|D|null→0 | 100+A | 101+B | 110+C | 111+D.
- If
nullis not present, variants are assigned sequentially:A|B|C→00+A | 01+B | 10+C.
struct WithUnion {
f: int8 | int16 | int32
}Field f is serialized as:
00 + int801 + int1610 + int32
On deserialization, the same prefixes are expected; an unmatched prefix (e.g., 11) triggers an exception.
The same applies to structs without manual prefixes:
struct A { ... } // 0x... prefixes not specified
struct B { ... }
struct C { ... }
struct WithUnion {
// auto-generated prefix tree: 00/01/10
f: A | B | C
// with null, like Maybe<A|B>: 0/10/11
g: A | B | null
// even this works; when '11', a ref exists
h: A | int32 | C | cell
}void as a union variant — empty slice
A union variant of type void has no representation on the wire — it's matched by an empty slice (zero bits, zero refs). The other variants form a normal prefix tree among themselves and are unaffected.
struct (0x01) Some { x: int32 }
// either Some (0x01 + int32) — or empty slice
type SomeOrEnd = Some | void
// either 32 bits — or empty slice
type Int32OrEnd = int32 | void
// 0+int32, 1+int64, or empty slice
type WideOrEnd = int32 | int64 | void- On pack: writing the
voidvariant emits nothing. - On unpack: when the input slice is empty, the deserialized value is the
voidvariant; otherwise, normal prefix-tree dispatch is used. voidmust be the last variant of a serializable union (otherwise the prefix dispatch would be ambiguous); the compiler enforces this when generating opcodes.
In match, the void arm is written as void => ...:
match (msg) {
Some => { /* read Some.x */ }
void => { /* slice was empty */ }
}Tensors (T1, T2, ...)
Tensor components are serialized sequentially, akin to struct fields.
string
- Serialized as a reference: a string is a snake-encoded cell
- TL-B:
^Cell - Stored using
STREF - Loaded using
LDREF
array<T>
- Serializable when
Tis serializable, e.g.,array<int8>orarray<bool?>. - Binary format:
uint8length, followed by chained cell references (snake refs), each containing aboolflag for the next reference and serialized elements.
For example, array<uint8> [1, 2, 3, 4, 5] can be packed into a single chunk with all 5 elements in a single reference, resulting in the following bitcode:
uint8is 5 (length)boolistrue(has ref)- ref
cell:boolisfalse(no next ref)0x0102030405(1, 2, 3, 4, 5)
This is not the only option. The data can be packed as ref 1 ref 2345, or ref 12 + ref 34 + ref 5, and so on. For example, ref 123 + ref 45 produces:
uint8is 5 (length)boolistrue(has ref)- ref
cell:boolistrue(has next ref)0x010203- ref
cell:boolisfalse(no next ref)0x0405
An empty array is uint8 = 0, followed by a bool = false, which equates to nine zero bits.
The compiler precalculates chunk size to fit as many elements into a cell as possible. Client-side libraries can follow a simpler strategy — for example, write into a cell until there is space, then create a nested reference, repeating the process.
Shaped tuples [T1, T2, ...]
Shaped components are serialized sequentially: serialization is identical to tensors. But their nature is different: [T1, ...] are stored in a TVM tuple, whereas (T1, ...) act like N variables on a stack.
map<K, V>
- Maybe reference:
0for empty or1+reffor dictionary contents - TL-B:
HashmapE n X - Stored using
STDICT - Loaded using
LDDICT
Callables (...ArgsT) -> ResultT
- Callables cannot be serialized.
- Lambdas can be used within contract logic but cannot be serialized for off‑chain responses.
unknown
- Cannot be serialized. Prefer using
array<T>with serializableTinstead ofarray<unknown>(tuple).
Last updated on