Structs and Messages
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.
Warning: Currently circular types are not possible. This means that Struct/Message A can't have a field of a Struct/Message B that has a field of the Struct/Message A.
Therefore, the following code won't compile:
struct A {
circularFieldA: B;
}
struct B {
impossibleFieldB: A;
}
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 (opens in a new tab). 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. 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 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 (operation codes) of a given smart contract, such as Jetton standard (opens in a new tab). The short-list of opcodes this contract is able to process is given here in FunC (opens in a new tab). 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!
};
}
Because instantiation is an expression in Tact, it's also described on the related page: Instantiation expression.
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...}
}
See those extension functions in the Reference:
Struct.toCell()
Message.toCell()
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);
}
See those extension functions in the Reference:
Struct.fromCell()
Struct.fromSlice()
Message.fromCell()
Message.fromSlice()
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 applyingStruct.fromCell()
(orMessage.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 (opens in a new tab) layout as a given Struct/Message, callingStruct.fromCell()
(orMessage.fromCell()
) on it, and then converting the result to aCell
via.toCell()
would give the copy of the originalCell
:
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
}