Key features of auto-serialization
- Supports all types: unions, tensors, nullables, generics, atomics, …
- Allows you to specify serialization prefixes (particularly, opcodes)
- Allows you to manage cell references and when to load them
- Lets you control error codes and other behavior
- Unpacks data from a cell or a slice, mutate it or not
- Packs data to a cell or a builder
- Warns if data potentially exceeds 1023 bits
- More efficient than manual serialization
List of supported types and how they are serialized
A small reminder: Tolk hasintN types (int8, uint64, etc.). Of course, they can be nested, like nullable int32? or a tensor (uint5, int128).
They are just integers at the TVM level, they can hold any value at runtime: overflow only happens at serialization.
For example, if you assign 256 to uint8, asm command “8 STU” will fail with code 5 (integer out of range).
| Type | TL-B equivalent | Serialization notes |
|---|---|---|
int8, uint55, etc. | same as TL-B | N STI / N STU |
coins | VarUInteger 16 | STGRAMS |
varint16, 32, etc. | Var(U)Integer 16/32 | STVARINT16 / STVARUINT32 / etc. |
bits8, bits123, etc. | just N bits | runtime check + STSLICE (1) |
address | MsgAddress (internal/external/none) | STSLICE (2) |
bool | one bit | 1 STI |
cell | untyped reference, TL-B ^Cell | STREF |
cell? | maybe reference, TL-B (Maybe ^Cell) | STOPTREF |
Cell<T> | typed reference, TL-B ^T | STREF |
Cell<T>? | maybe typed reference, TL-B (Maybe ^T) | STOPTREF |
RemainingBitsAndRefs | rest of slice | STSLICE |
builder | only for writing, not for reading (5) | STB |
slice | only for writing, not for reading (5) | STSLICE |
T? | TL-B (Maybe T) | 1 STI + IF … |
T1 | T2 | TL-B (Either T1 T2) | 1 STI + IF … + ELSE … (3) |
T1 | T2 | ... | TL-B multiple constructors | IF … + ELSE IF … + ELSE … (4) |
(T1, T2) | TL-B (Pair T1 T2) = one by one | pack T1 + pack T2 |
(T1, T2, ...) | nested pairs = one by one | pack T1 + pack T2 + … |
SomeStruct | fields one by one | like a tensor |
enum SomeEnum | intN or uintN, where N manual or auto | enum SomeEnum: int8 for manual |
-
(1) By analogy with
intN, there arebytesNtypes. It’s just asliceunder the hood: the type shows how to serialize this slice. By default, beforeSTSLICE, the compiler inserts runtime checks (get bits/refs count + compare with N + compare with 0). These checks ensure that serialized binary data will be correct, but they cost gas. However, if you guarantee that a slice is valid (for example, it comes from trusted sources), pass an optionskipBitsNValidationto disable runtime checks. -
(2) In TVM, all addresses are also plain slices. Type
addressindicates that it’s a slice containing some valid address (internal/external/none). It’s packed withSTSLICE(no runtime checks) and loaded withLDMSGADDR.
address none with null! None is a valid address (two zero bits), whereas address? is maybe address (bit “0” OR bit “1” + address).
- (3) TL-B Either is expressed with a union
T1 | T2. For example,int32 | int64is packed as (“0” + 32-bit int OR “1” + 64-bit int).
-
(4) To (un)pack a union, say,
Msg1 | Msg2 | Msg3, we need serialization prefixes. For structures, you can specify them manually (or the compiler will generate them right here). For primitives, likeint32 | int64 | int128 | int256, the compiler generates a prefix tree (00/01/10/11 in this case). Read auto-generating serialization prefixes below. -
(5) Using raw
builderandslicefor writing is supported but not recommended, as they do not reveal any information about their internal structure. Auto-generated TypeScript wrappers will be available in the future. However, for a rawslice, a wrapper cannot be generated, as there is no way to predict how to read such a field back. This feature will likely be removed in the future.
Some examples of valid types
Serialization prefixes and opcodes
Declaring a struct, there is a special syntax to provide pack prefixes. Typically, you’ll use 32-bit prefixes for messages opcodes, or arbitrary prefixes is case you’d like to express TL-B multiple constructors.0x000F— 16-bit prefix0x0F— 8-bit prefix0b010— 3-bit prefix0b00001111— 8-bit prefix
Asset will follow manually provided prefixes:
What can NOT be serialized
intcan’t be serialized, it does not define binary width; useint32,uint64, etc.slice, for the same reason; useaddressorbitsN- tuples, not implemented
A | B(andA|B|C|...in general) if A has manual serialization prefix, B not (because it seems like a bug in your code)int32 | A(andprimitives|...|structsin general) if A has manual serialization prefix (because it’s not definite what prefixes to use for primitives)
Error messages if serialization unavailable
If you, by mistake, use unsupported types, Tolk compiler will fire a meaningful error. Example:Controlling cell references. Typed cells
Tolk gives you full control over how your data is placed in cells and how cells reference each other. When you declare fields in a struct, there is no compiler magic of reordering fields, making any implicit references, etc. As follows, whenever you need to place data in a ref, you do it manually. As well as you manually control, when contents of that ref is loaded. There are two types of references: typed and untyped.NftCollectionStorage.fromSlice (or fromCell), the process is as follows:
- read address (slice.loadAddress)
- read uint64 (slice.loadUint 64)
- read three refs (slice.loadRef); do not unpack them: we just have pointers to cells
royaltyParams is Cell<T>, not T itself. You can NOT access numerator, etc. To load those fields, you should manually unpack that ref:
T.toCell() makes Cell<T>, actually. That’s true:
Cell<address> or even Cell<int32 | int64> is also okay, you are not restricted to structures.
When it comes to untyped cells — just cell — they also denote references, but don’t denote their inner contents, don’t have the .load() method.
It’s just some cell, like code/data of a contract or an untyped nft content.
Remaining data after reading
Suppose you have struct Point (x int8, y int8), and read from a slice with contents “0102FF”. Byte “01” for x, byte “02” for y, and the remaining “FF” — is it correct? By default, this is incorrect. By default, functionsfromCell and fromSlice ensure the slice end after reading.
In this case, exception 9 (“cell underflow”) is thrown.
But you can override this behavior with an option:
Custom serializers for custom types
Imagine that you have your own “variable-length string”: you encode its len, and then data, likeVariadicString as a regular type — everywhere:
packToBuilder and unpackFromSlice are reserved for this purpose, their prototype must be exactly as showed.
UnpackOptions and PackOptions
They allow you to control behavior offromCell, toCell, and similar functions:
fromCell and similar), there are now two available options:
| Field of UnpackOptions | Description |
|---|---|
assertEndAfterReading | after finished reading all fields from a cell/slice, call slice.assertEnd to ensure no remaining data left; it’s the default behavior, it ensures that you’ve fully described data you’re reading with a struct; for struct Point, input “0102” is ok, “0102FF” will throw excno 9; default: true |
throwIfOpcodeDoesNotMatch | this excNo is thrown if opcode doesn’t match, e.g. for struct (0x01) A given input “88…”; similarly, for a union type, this is thrown when none of the opcodes match; default: 63 |
toCell and similar), there is now one option:
| Field of PackOptions | Description |
|---|---|
skipBitsNValidation | when a struct has a field of type bits128 and similar (it’s a slice under the hood), by default, compiler inserts runtime checks (get bits/refs count + compare with 128 + compare with 0); these checks ensure that serialized binary data will be correct, but they cost gas; however, if you guarantee that a slice is valid (for example, it comes from trusted sources), set this option to true to disable runtime checks; note: int32 and other are always validated for overflow without any extra gas, so this flag controls only rarely used bitsN types; default: false |
Full list of serialization functions
Each of them can be controlled byPackOptions described above.
T.toCell()— convert anything to a cell. Example:
builder.storeAny<T>(v)— similar tobuilder.storeUint()and others, but allows storing structures. Example:
Full list of deserialization functions
Each of them can be controlled byUnpackOptions described above.
T.fromCell(c)— parse anything from a cell. Example:
T.fromSlice(s)— parse anything from a slice. Example:
excode 9 “cell underflow”). Note, that a passed slice is NOT mutated; its internal pointer is NOT shifted. If you need to mutate it, like cs.loadInt(), consider calling cs.loadAny<Increment>().
slice.loadAny<T>— parse anything from a slice, shifting its internal pointer. Similar toslice.loadUint()and others, but allows loading structures. Example:
MyStorage.fromSlice(cs), but called as a method and mutates the slice. Note: options.assertEndAfterReading is ignored by this function, because it’s actually intended to read data from the middle.
slice.skipAny<T>— skip anything in a slice, shifting its internal pointer. Similar toslice.skipBits()and others, but allows skipping structures. Example:
Special type RemainingBitsAndRefs
It’s a built-in type to get “all the rest” slice tail on reading. Example:Auto-generating prefixes for unions
We’ve mentioned multiple times, thatT1 | T2 is encoded as TL-B Either: bit ‘0’ + T1 OR bit ‘1’ + T2. But what about wider unions? Say,
- if
nullexists, it’s 0, all others are 1+tree: A|B|C|D|null => 0 | 100+A | 101+B | 110+C | 111+D - if no
null, just distributed sequentially: A|B|C => 00+A | 01+B | 10+C
- if you specify prefixes manually, they will be used (no matter within a union or not)
- if you don’t specify any prefixes, the compiler auto-generates a prefix tree
- if you specify prefix for A, but forgot prefix for B,
A | Bcan’t be serialized - either bit (0/1) is just a prefix tree for two cases
Prefixed<int32, 0b0011>.
But you can just create a struct with a single field:
int32, the same slot on a stack, just adding a prefix for (de)serialization.
What if data exceeds 1023 bits
Struct fields are serialized one-by-one. So, if you have a large structure, its content may not fit into a cell. Tolk compiler calculates the maximum size of every serialized struct, and if it potentially exceeds 1023 bits, fires an error. Your choice is- either to suppress the error by placing an annotation above a struct; it means “okay, I understand”
- or repack your data by splitting into multiple cells
int8?is either one or nine bitscoinsis variadic: from 4 bits (small values) up to 124 bitsaddressis internal (267 bits), or external (up to 521 bits), or none (2 bits); but since external addresses are very rare, estimation is “from 2 to 267 bits”
- you definitely know, that
coinsfields will be relatively small, and this struct will 100% fit in reality; then, suppress the error using an annotation:
- or you really expect billions of billions in
coins, so data really can exceed; in this case, you should extract some fields into a separate cell; for example, store 800 bits as a ref; or extract other 2 fields and ref them:
Write builder, read slice
Since Tolk remains low-level whenever you need, it allows doing tricky things. Suppose you manually create an address from bits:addrB (it’s builder! not address) to write into a storage:
addrB.endCell().beginParse() as address, but creating a cell is expensive. How can you write addrB directly?
The solution is: if you want builder for writing, and address for reading… Do exactly what you want!
RemainingBitsAndRefs and some more cases.
Moreover, for performance optimizations, you can create different “views” over the same data. Don’t underestimate generics and the type system in general.
Integration with message sending
Auto-serialization is natively integrated with sending messages to other contracts. You just “send some object,” and the compiler automatically serializes it, detects whether it fits into a message cell, etc.Not “fromCell” but “lazy fromCell”
Tolk has a special keywordlazy that is combined with auto-deserialization. The compiler will load not a whole struct, but only fields requested.