Skip to content

Structs and Messages

This content is not available in your language yet.

Tact supports a number of primitive data types that are tailored for smart contract use. However, using individual means of storage often becomes cumbersome, so there are Structs and Messages which allow combining types together.

Structs

Structs can define complex data types that contain multiple fields of different types. They can also be nested.

struct Point {
x: Int as int64;
y: Int as int64;
}
struct Line {
start: Point;
end: Point;
}

Structs can also contain default fields and define fields of optional types. This can be useful if you have a lot of fields, but don’t want to keep having to specify common values for them in new instances.

struct Params {
name: String = "Satoshi"; // default value
age: Int?; // field with an optional type Int?
// and default value of null
point: Point; // nested Structs
}

Structs are also useful as return values from getters or other internal functions. They effectively allow a single getter to return multiple return values.

contract StructsShowcase {
params: Params; // Struct as a contract's persistent state variable
init() {
self.params = Params{
point: Point{
x: 4,
y: 2,
},
};
}
get fun params(): Params {
return self.params;
}
}

Note, that the last semicolon ; in Struct declaration is optional and may be omitted:

struct Mad { ness: Bool }
struct MoviesToWatch {
wolverine: String;
redFunnyGuy: String
}

The order of fields matters, as it corresponds to the resulting memory layout in TL-B schemas. However, unlike some languages with manual memory management, Tact does not have any padding between fields.

Messages

Messages can hold Structs in them:

struct Point {
x: Int;
y: Int;
}
message Add {
point: Point; // holds a struct Point
}

Messages are almost the same thing as Structs with the only difference that Messages have a 32-bit integer header in their serialization containing their unique numeric id, commonly referred to as an opcode (operation code). This allows Messages to be used with receivers since the contract can tell different types of messages apart based on this id.

Tact automatically generates those unique ids (opcodes) for every received Message, but this can be manually overwritten:

// This Message overwrites its unique id with 0x7362d09c
message(0x7362d09c) TokenNotification {
forwardPayload: Slice as remaining;
}

This is useful for cases where you want to handle certain opcodes of a given smart contract, such as Jetton standard. The short-list of opcodes this contract is able to process is given here in FunC. They serve as an interface to the smart contract.

Operations

Instantiate

Creation of Struct and Message instances resembles function calls, but instead of paretheses () one needs to specify arguments in braces {} (curly brackets):

struct StA {
field1: Int;
field2: Int;
}
message MsgB {
field1: String;
field2: String;
}
fun example() {
// Instance of a Struct StA
StA{
field1: 42,
field2: 68 + 1, // trailing comma is allowed
};
// Instance of a Message MsgB
MsgB{
field1: "May the 4th",
field2: "be with you!", // trailing comma is allowed
};
}

When the name of a variable or constant assigned to a field coincides with the name of such field, Tact provides a handy syntactic shortcut sometimes called field punning. With it, you don’t have to type more than it’s necessary:

struct PopQuiz {
vogonsCount: Int;
nicestNumber: Int;
}
fun example() {
// Let's introduce a couple of variables
let vogonsCount: Int = 42;
let nicestNumber: Int = 68 + 1;
// You may instantiate the Struct as usual and assign variables to fields,
// but that is a bit repetitive and tedious at times
PopQuiz{ vogonsCount: vogonsCount, nicestNumber: nicestNumber };
// Let's use field punning and type less,
// because our variable names happen to be the same as field names
PopQuiz{
vogonsCount,
nicestNumber, // trailing comma is allowed here too!
};
}

Convert to a Cell, .toCell()

It’s possible to convert an arbitrary Struct or Message to the Cell type by using the .toCell() extension function:

struct Big {
f1: Int;
f2: Int;
f3: Int;
f4: Int;
f5: Int;
f6: Int;
}
fun conversionFun() {
dump(Big{
f1: 10000000000, f2: 10000000000, f3: 10000000000,
f4: 10000000000, f5: 10000000000, f6: 10000000000,
}.toCell()); // x{...cell with references...}
}

Obtain from a Cell or Slice, .fromCell() and .fromSlice()

Instead of manually parsing a Cell or Slice via a series of relevant .loadSomething() function calls, one can use .fromCell() and .fromSlice() extension functions for converting the provided Cell or Slice into the needed Struct or Message.

Those extension functions only attempt to parse a Cell or Slice according to the structure of your Struct or Message. In case layouts don’t match, various exceptions may be thrown — make sure to wrap your code in try...catch blocks to prevent unexpected results.

struct Fizz { foo: Int }
message(100) Buzz { bar: Int }
fun constructThenParse() {
let fizzCell = Fizz{foo: 42}.toCell();
let buzzCell = Buzz{bar: 27}.toCell();
let parsedFizz: Fizz = Fizz.fromCell(fizzCell);
let parsedBuzz: Buzz = Buzz.fromCell(buzzCell);
}

Conversion laws

Whenever one converts between Cell/Slice and Struct/Message via .toCell() and .fromCell() functions, the following laws hold:

  • For any instance of type Struct/Message, calling .toCell() on it, then applying Struct.fromCell() (or Message.fromCell()) to the result gives back the copy of the original instance:
struct ArbitraryStruct {}
message(0x2A) ArbitraryMessage {}
fun lawOne() {
let structInst = ArbitraryStruct{};
let messageInst = ArbitraryMessage{};
ArbitraryStruct.fromCell(structInst.toCell()); // = structInst
ArbitraryMessage.fromCell(messageInst.toCell()); // = messageInst
// Same goes for Slices, with .toCell().asSlice() and .fromSlice()
ArbitraryStruct.fromSlice(structInst.toCell().asSlice()); // = structInst
ArbitraryMessage.fromSlice(messageInst.toCell().asSlice()); // = messageInst
}
  • For any Cell with the same TL-B layout as a given Struct/Message, calling Struct.fromCell() (or Message.fromCell()) on it, and then converting the result to a Cell via .toCell() would give the copy of the original Cell:
struct ArbitraryStruct { val: Int as uint32 }
message(0x2A) ArbitraryMessage {}
fun lawTwo() {
// Using 32 bits to store 42 just so this cellInst can be
// re-used for working with both ArbitraryStruct and ArbitraryMessage
let cellInst = beginCell().storeUint(42, 32).endCell();
ArbitraryStruct.fromCell(cellInst).toCell(); // = cellInst
ArbitraryMessage.fromCell(cellInst).toCell(); // = cellInst
// Same goes for Slices, with .fromSlice() and .toCell().asSlice()
let sliceInst = cellInst.asSlice();
ArbitraryStruct.fromSlice(sliceInst).toCell().asSlice(); // = sliceInst
ArbitraryMessage.fromSlice(sliceInst).toCell().asSlice(); // = sliceInst
}