Skip to main content
This section summarizes common patterns and conventions used in idiomatic Tolk code. While Basic syntax introduces the language itself, this page outlines the preferred ways of expressing ideas and best practices in Tolk. It may serve as a style reference throughout development.

Auto-serialization instead of slices/builders

Tolk type system is designed to entirely avoid manual cell parsing. The presence of beginCell() indicates a possibly wrong approach. All practical use cases in contract interaction are expressed with structures, unions, and references.
struct Holder {
    owner: address
    lastUpdated: uint32
    extra: Cell<ExtraInfo>
}

fun demo(data: Holder) {
    // make a cell with 299 bits and 1 ref
    val cell = data.toCell();

    // unpack it back
    val holder = Holder.fromCell(cell);
}
Familiar with TL-B?The type system is considered as a replacement for TL-B.
Read Tolk vs TL-B.
See: automatic serialization.

Cell<T> — a “cell with known shape”

All data in TON is stored in cells that reference each other. To express clear data relation, use typed cellsCell<T>. Literally, it means: a cell whose contents is T:
struct Holder {
    // ...
    extra: Cell<ExtraInfo>
}

struct ExtraInfo {
    someField: int8
    // ...
}

fun getDeepData(value: Holder) {
    // `value.extra` is a reference
    // use `load()` to access its contents
    val data = value.extra.load();
    return data.someField;
}
See: cell references in serialization.

Not “fromCell”, but “lazy fromCell”

In practice, when reading data from cells, prefer lazy:
  • lazy SomeStruct.fromCell(c) over SomeStruct.fromCell(c)
  • lazy typedCell.load() over typedCell.load()
The compiler loads only requested fields, skipping the rest. It reduces gas consumption and bytecode size.
get fun publicKey() {
    val st = lazy Storage.load();
    // <-- here "skip 65 bits, preload uint256" is inserted
    return st.publicKey
}
See: lazy loading.

Custom serializers — if can’t express with types

Even though the type system is very rich, there still may occur situations where binary serialization is non-standard. Tolk allows to declare custom types with arbitrary serialization rules.
type MyString = slice

fun MyString.packToBuilder(self, mutate b: builder) {
    // custom logic
}

fun MyString.unpackFromSlice(mutate s: slice) {
    // custom logic
}
And just use MyString as a regular type — everywhere:
struct Everywhere {
    tokenName: MyString
    fullDomain: Cell<MyString>
}
An interesting example. Imagine a structure which tail is signed:
struct SignedRequest {
    signature: uint256
    // hash of all data below is signed
    field1: int32
    field2: address?
    // ...
}
The task is to parse it and check signature. A manual solution is obvious: read uint256, calculate the hash of the remainder, read other fields. What about the type system? Even this complex scenario can be expressed by introducing a synthetic field that is populated on loading:
type HashOfRemainder = uint256

struct SignedRequest {
    signature: uint256
    restHash: HashOfRemainder   // populated on load
    field1: int32
    field2: address?
    // ...
}

fun HashOfRemainder.unpackFromSlice(mutate s: slice) {
    // `s` is after reading `signature` in our case;
    // we don't need to load anything —
    // just calculate the hash on the fly
    return s.hash()
}

fun demo(input: slice) {
    val req = SignedRequest.fromSlice(input);
    assert (req.signature == req.restHash) throw XXX;
}
See: serialization of type aliases.

Contract storage = struct + load + save

Contract storage is a regular struct, serialized into persistent on-chain data. It is convenient to add load and store methods:
struct Storage {
    counterValue: int64
}

fun Storage.load() {
    return Storage.fromCell(contract.getData())
}

fun Storage.save(self) {
    contract.setData(self.toCell())
}
See: contract storage.

Message = struct with a 32-bit prefix

By convention, every message in TON has an opcode — a unique 32-bit number. In Tolk, every struct can have a “serialization prefix” of arbitrary length. 32-bit prefixes are called opcodes. So, every incoming and outgoing message is a struct with a prefix:
struct (0x12345678) CounterIncrement {
    // ...
}
Guidelines for choosing opcodes
  • When implementing jettons, NFTs, and other standards, use predefined prefixes according to the specification of each TEP.
  • When developing custom protocols, use any random numbers.
See: structures.

Handle a message = structs + union + match

The suggested pattern to handle messages:
  • each incoming message is a struct with an opcode
  • combine these structs into a union
  • parse it via lazy fromCell and match over variants
struct (0x12345678) CounterIncrement {
    incBy: uint32
}

struct (0x23456789) CounterReset {
    initialValue: int64
}

type AllowedMessage = CounterIncrement | CounterReset

fun onInternalMessage(in: InMessage) {
    val msg = lazy AllowedMessage.fromSlice(in.body);
    match (msg) {
        CounterIncrement => {
            // use `msg.incBy`
        }
        CounterReset => {
            // use `msg.initialValue`
        }
        else => {
            // invalid input; a typical reaction is:
            // ignore empty messages, "wrong opcode" if not
            assert (in.body.isEmpty()) throw 0xFFFF
        }
    }
}
Notice lazy: it also works with unions and does “lazy match” by a slice prefix. It’s much more efficient than manual parsing an opcode and branching via if (op == TRANSFER_OP). See: handling messages and pattern matching.

Send a message = struct + createMessage

To send a message from contract A to contract B,
  • declare a struct, specify an opcode and fields expected by a receiver
  • use createMessage + send
struct (0x98765432) RequestedInfo {
    // ...
}

fun respond(/* ... */) {
    val reply = createMessage({
        bounce: BounceMode.NoBounce,
        value: ton("0.05"),
        dest: addressOfB,
        body: RequestedInfo {
            // ... initialize fields
        }
    });
    reply.send(SEND_MODE_REGULAR);
}
When both contracts are developed in the same project (sharing common codebase), such a struct is both an outgoing message for A and an incoming message for B.

Deploy another contract = createMessage

A common case: a minter deploys a jetton wallet, knowing wallet’s code and initial state. This “deployment” is actually sending a message, auto-attaching code+data, and auto-calculating its address:
val deployMsg = createMessage({
    // address auto-calculated, code+data auto-attached
    dest: {
        stateInit: {
            code: jettonWalletCode,
            data: emptyWalletStorage.toCell(),
        }
    }
});
A preferred way is to extract generating stateInit to a separate function, because it’s used not only to send a message, but also to calculate/validate an address without sending.
fun calcDeployedJettonWallet(/* ... */): AutoDeployAddress {
    val emptyWalletStorage: WalletStorage = {
        // ... initialize fields from parameters
    };

    return {
        stateInit: {
            code: jettonWalletCode,
            data: emptyWalletStorage.toCell()
        }
    }
}

fun demoDeploy() {
    val deployMsg = createMessage({
        // address auto-calculated, code+data auto-attached
        dest: calcDeployedJettonWallet(...),
        // ...
    });
    deployMsg.send(mode);
}
See: tolk-bench repo for reference jettons.

Shard optimization = createMessage

“Deploy a contract to a specific shard” is also done with the same technique. For example, in sharded jettons, a jetton wallet must be deployed to the same shard as the owner’s wallet.
val deployMsg = createMessage({
    dest: {
        stateInit: { code, data },
        toShard: {
            closeTo: ownerAddress,
            fixedPrefixLength: 8
        }
    }
});
Following the guideline above, the task is resolved by adding just a couple lines of code. Sharding will automatically be supported in createMessage and other address calculations.
fun calcDeployedJettonWallet(/* ... */): AutoDeployAddress {
    // ...
    return {
        stateInit: ...,
        toShard: {
            closeTo: ownerAddress,
            fixedPrefixLength: SHARD_DEPTH
        }
    }
}
See: sharding in createMessage.

Emitting events/logs to off-chain

Emitting events and logs “to the outer world” is done via external messages. They are useful for monitoring: being indexed by TON indexers, they show “a picture of on-chain activity”. These are also messages and also cost gas, but are constructed in a slightly different way.
  1. Create a struct to represent the message body
  2. Use createExternalLogMessage + send
struct DepositEvent {
    // ...
}

fun demo() {
    val emitMsg = createExternalLogMessage({
        dest: createAddressNone(),
        body: DepositEvent {
            // ...
        }
    });
    emitMsg.send(SEND_MODE_REGULAR);
}
See: sending external messages.

Return a struct from a get method

When a contract getter (get fun) needs to return several values — introduce a structure and return it. Do not return unnamed tensors like (int, int, int). Field names provide clear metadata for client wrappers and human readers.
struct JettonWalletDataReply {
    jettonBalance: coins
    ownerAddress: address
    minterAddress: address
    jettonWalletCode: cell
}

get fun get_wallet_data(): JettonWalletDataReply {
    return {
        jettonBalance: ...,
        ownerAddress: ...,
        minterAddress: ...,
        jettonWalletCode: ..,
    }
}
See: contract getters.

Use assertions to validate user input

After parsing an incoming message, validate required fields with assert:
assert (msg.seqno == storage.seqno) throw E_INVALID_SEQNO;
assert (msg.validUntil > blockchain.now()) throw E_EXPIRED;
Execution will terminate with some errCode, and a contract will be ready to serve the next request. This is the standard mechanism for reacting on invalid input. See: exceptions.

Organize a project into several files

No matter whether a project contains one contract or multiple — split it into files. Having identical file structure across all projects simplifies navigation:
  • errors.tolk with constants or enums
  • storage.tolk with a storage and helper methods
  • messages.tolk with incoming/outgoing messages
  • some-contract.tolk as an entrypoint
  • probably, some other
When several contracts are developed simultaneously, their share the same codebase. For instance, struct SomeMessage, outgoing for contract A, is incoming for contract B. Or for deployment, contract A should know B’s storage to assign stateInit.
Use only minimal declarations inside each contract.tolkTypically, each some-contract.tolk file contains:
  • a union with available incoming messages
  • entrypoints: onInternalMessage, get fun
  • structures for complex replies from getters
The remaining codebase is shared.
See: imports.

Prefer methods to functions

All symbols across different files share the same namespace and must have unique names project-wise. There are no “modules” or “exports”. Using methods avoids name collisions:
fun Struct1.validate(self) { /* ... */ }
fun Struct2.validate(self) { /* ... */ }
Methods are also more convenient: obj.someMethod() looks nicer than someFunction(obj):
struct AuctionConfig {
    // ...
}

// NOT
// fun isAuctionConfigInvalid(config: AuctionConfig)
// BUT
fun AuctionConfig.isInvalid(self) {
    // ...
}
Same for static methods: Auction.createFrom(...) seems better than createAuctionFrom(...). A method without self is a static one:
fun Auction.createFrom(config: cell, minBid: coins) {
    // ...
}
Static methods may also be used to group various utility functions. For example, standard functions blockchain.now() and others are essentially static methods of an empty struct.
struct blockchain

fun blockchain.now(): int /* ... */;
fun blockchain.logicalTime(): int /* ... */;
In large projects, this technique may be used to emulate namespaces. See: functions and methods.

How to describe “forward payload” in jettons

Guidelines state that transferring a jetton may have some forwardPayload to provide custom data for a transaction’s recipient. They restrict a payload to have the following format (TL-B (Either Cell ^Cell)):
  • bit ‘0’ means: payload = “all the next bits/refs” (inline payload)
  • bit ‘1’ means: payload = “the next ref”
Since it may be inline, it’s always positioned in the end of a message. However, many existing jetton implementations do not follow the schema.
  • Some implementations allow empty data to be passed (no bits at all). It is invalid, because either bit ‘0’ or bit ‘1’ must exist. An empty payload should actually be encoded as bit ‘0’: empty inline payload.
  • Some implementations do not check that no extra data is left after bit ‘1’.
  • Error codes differ between various implementations.
That’s why, to choose the correct typing, answer the following questions:
  • Do you need validation or just proxy any data as-is?
  • Do you need custom error codes while validating?
  • Do you need to assign it dynamically or just to carry it forward?
To proxy any data without validation, “payload” is just “all the rest”:
struct Transfer {
    // ...
    forwardPayload: RemainingBitsAndRefs
}
A canonical TL-B Either looks nice:
struct Transfer {
    // ...
    forwardPayload: RemainingBitsAndRefs | cell
}
It will work (see union types), it is convenient for assignment and client metadata, but
  • consumes more gas if you need just to carry it, due to branching at serialization back
  • parsing does not check, that after bit ‘1’, no extra data is left
This solution can be extended for validation of extra bits:
struct Transfer {
    // ...
    forwardPayload: RemainingBitsAndRefs | cell
    mustBeEmpty: RemainingBitsAndRefs
}

fun Transfer.validatePayload(self) {
    // if extra data exists, throws 9
    self.mustBeEmpty.assertEnd()
    // if no bits at all, failed with 9 in advance,
    // because the union could not be loaded
}
If gas consumption is critical, but validation is required — it’s cheaper not to allocate unions on the stack, but to load a slice, validate it, and keep a slice for further serialization:
struct Transfer {
    // ...
    forwardPayload: ForwardPayload
}

type ForwardPayload = RemainingBitsAndRefs

// validate TL/B `(Either Cell ^Cell)`
fun ForwardPayload.checkIsCorrectTLBEither(self) {
    var mutableCopy = self;
    // throw 9 if no bits at all ("maybe ref" loads one bit)
    if (mutableCopy.loadMaybeRef() != null) {
        // if ^Cell, throw 9 if other data exists
        mutableCopy.assertEnd()
    }
}
If you need custom error codes (not errCode 9 “cell underflow”), even calling loadMaybeRef() is discouraged. The preferred solution is:
type ForwardPayload = RemainingBitsAndRefs

struct (0b0) PayloadInline {
    data: RemainingBitsAndRefs
}

struct (0b1) PayloadRef {
    refData: cell
    rest: RemainingBitsAndRefs
}

type PayloadInlineOrRef = PayloadInline | PayloadRef

// validate TL/B `(Either Cell ^Cell)`
fun ForwardPayload.checkIsCorrectTLBEither(self) {
    val p = lazy PayloadInlineOrRef.fromSlice(self);
    match (p) {
        PayloadInline => {
            // okay, valid
        }
        PayloadRef => {
            // valid if nothing besides ref exists
            assert (p.rest.isEmpty()) throw ERR_EXTRA_BITS
        }
        else => {
            // both not bit '0' and not bit '1' — empty
            throw ERR_EMPTY_PAYLOAD_FIELD
        }
    }
}
Keeping a “remainder” is cheaper and allows graceful validation, but it’s not convenient if you need to assign a payload dynamically. It’s a plain slice, holding an encoded union. For example, creating a “ref payload” from code having a cell requires manual work:
fun createRefPayload(ref: cell) {
    // not like this, mismatched types
    val err1 = ref;
    // not like this, incorrect logic
    val err2 = ref.beginParse();

    // but like this: '1' + ref
    val payload = beginCell()
            .storeBool(true).storeRef(cell)
            .asSlice();
}
Of course, RemainingBitsAndRefs | cell is way more convenient for assignment, but as discussed above, has its own disadvantages. The conclusion: payloads are cumbersome, and the solution depends on particular requirements.

How to describe “address or none” in a field

A nullable address — address? — is a pattern to say “potentially missing address”, sometimes called “maybe address”.
  • null is “none”, serialized as ‘00’ (two zero bits)
  • address is “internal”, serialized as 267 bits: ‘100’ + workchain + hash
See: addresses.

How to calculate crc32/sha256 at compile-time

Several built-in functions operate on strings and work at compile-time:
// calculates crc32 of a string
const crc32 = stringCrc32("some_str")

// calculates sha256 of a string and returns 256-bit integer
const hash = stringSha256("some_crypto_key")

// and more
See: standard library.

How to return a string from a contract

TVM has no strings, it has only slices. A binary slice must be encoded in a specific way to be parsed and interpreted correctly as a string.
  1. Fixed-size strings via bitsN — possible if the size is predefined.
  2. Snake strings: portion of data → the rest in a ref cell, recursively.
  3. Variable-length encoding via custom serializers.
See: strings.

Final suggestion: do not micro-optimize

Tolk compiler is smart. It targets “zero overhead”: clean, consistent logic must be as efficient as low-level code. It automatically inlines functions, reduces stack permutations, and does a lot of underlying work to let a developer focus on business logic. And it works. Any attempts to overtrick the compiler result either in negligible or even in negative effect. That’s why, follow the “readability-first principle”:
  • use one-line methods without any worries — they are auto-inlined
  • use small structures — they are as efficient as raw stack values
  • extract constants and variables for clarity
  • do not use assembler functions unless being absolutely sure
Use Tolk as intended — gas will take care of itself.
But if the logic is hard to follow — it’s where the inefficiency hides.
See: compiler optimizations.