Maps
The composite type map<K, V>
is used as a way to associate keys of type K
with corresponding values of type V
.
For example, map<Int, Int>
uses Int
type for its keys and values:
struct IntToInt { counters: map<Int, Int>;}
Since maps can use any given Struct as their value types, the nested maps can be created via helper structures like this:
import "@stdlib/deploy";
// A `map<Address, Int>` packed into the `AllowanceMap` structurestruct AllowanceMap { unbox: map<Address, Int> }
contract NestedMaps with Deployable { get fun test(): Int { // An outer map `map<Address, AllowanceMap>`, // with `AllowanceMap` Structs as values, // each containing maps of type `map<Address, Int>` let allowances: map<Address, AllowanceMap> = emptyMap();
// An inner map in the `unbox` field of the `AllowanceMap` Struct let allowance = AllowanceMap{ unbox: emptyMap() };
// Setting the inner map entry allowance.unbox.set(myAddress(), 42);
// Setting the outer map entry allowances.set(myAddress(), allowance);
// Produces 42 return allowances.get(myAddress())!!.unbox.get(myAddress())!!; }}
Keep in mind that on TVM, maps are represented as Cell
type, which is very gas-intensive. Also, nested maps will reach the limits faster than regular maps.
Allowed types
Allowed key types:
Allowed value types:
Serialization
It’s possible to do integer serialization of map keys, values or both to preserve space and reduce storage costs:
struct SerializedMapInside { // Both keys and values here would be serialized as 8-bit unsigned integers, // thus preserving the space and reducing storage costs: countersButCompact: map<Int as uint8, Int as uint8>;}
Since map keys can only be of fixed-width, variable integer types are not allowed for them. Instead, use fixed-width serialization formats.
However, map values of type Int
can have either fixed or variable bit-length serialization formats specified.
No other allowed key or value types besides Int
have serialization formats available.
Operations
Declare, emptyMap()
// K and V correspond to the key and value types of the target mapfun emptyMap(): map<K, V>;
Declaring a map as a local variable, using emptyMap()
function of standard library:
let fizz: map<Int, Int> = emptyMap();let fizz: map<Int, Int> = null; // identical to the previous line, but less descriptive
Declaring a map as a persistent state variable:
contract Example { fizz: map<Int, Int>; // Int keys to Int values init() { self.fizz = emptyMap(); // redundant and can be removed! }}
Note, that persistent state variables of type map<K, V>
are initialized empty by default and don’t need default values or an initialization in the init()
function.
Set values, .set()
// K and V correspond to the key and value types of the given mapextends mutates fun set(self: map<K, V>, key: K, val: V);
To set or replace the value under a key call the .set()
method, which is accessible for all maps.
// Empty maplet fizz: map<Int, Int> = emptyMap();
// Setting a couple of values under different keysfizz.set(7, 7);fizz.set(42, 42);
// Overriding one of the existing key-value pairsfizz.set(7, 68); // key 7 now points to value 68
Get values, .get()
// K and V correspond to the key and value types of the given mapextends fun get(self: map<K, V>, key: K): V?;
To check if a key is found in the map by calling the .get()
method, which is accessible for all maps. This will return null
if the key is missing, or the value if the key is found.
// Empty maplet fizz: map<Int, Int> = emptyMap();
// Setting a valuefizz.set(68, 0);
// Getting the value by its keylet gotButUnsure: Int? = fizz.get(68); // returns Int or null, therefore the type is Int?let mustHaveGotOrErrored: Int = fizz.get(68)!!; // explicitly asserting that the value must not be null, // which may crush at runtime if the value is, in fact, null
// Alternatively, we can check for the key in the if statementif (gotButUnsure != null) { // Hooray, let's use !! without fear now and cast Int? to Int let definitelyGotIt: Int = fizz.get(68)!!;} else { // Do something else...}
Replace values, .replace()
Available since Tact 1.6
// K and V correspond to the key and value types of the given mapextends mutates fun replace(self: map<K, V>, key: K, val: V): Bool;
To replace the value under a key, if such a key exists, use the .replace()
method. It returns true
on successful replacement and false
otherwise.
// Empty maplet fizz: map<Int, Int> = emptyMap();
// Setting a couple of values under different keysfizz.set(7, 70);fizz.set(42, 42);
// Overriding one of the existing key-value pairslet replaced1 = fizz.replace(7, 68); // key 7 now points to value 68replaced1; // true
// Trying to replace the value in a non-existing key-value pair will do nothinglet replaced2 = fizz.replace(8, 68); // no key 8, so nothing was alteredreplaced2; // false
If the given value is null
and the key exists, the entry will be deleted from the map.
// Empty maplet fizz: map<Int, Int> = emptyMap();
// Setting a couple of values under different keysfizz.set(7, 70);fizz.set(42, 42);
// Overriding one of the existing key-value pairslet replaced1 = fizz.replace(7, null); // the entry under key 7 is now deletedreplaced1; // true
// Trying to replace the value in a non-existing key-value pair will do nothinglet replaced2 = fizz.replace(8, null); // no key 8, so nothing was alteredreplaced2; // false
Replace and get old value, .replaceGet()
Available since Tact 1.6
// K and V correspond to the key and value types of the given mapextends mutates fun replaceGet(self: map<K, V>, key: K, val: V): V?;
Like .replace()
, but instead of returning a Bool
it returns the old (pre-replacement) value on successful replacement and null
otherwise.
// Empty maplet fizz: map<Int, Int> = emptyMap();
// Setting a couple of values under different keysfizz.set(7, 70);fizz.set(42, 42);
// Overriding one of the existing key-value pairslet oldVal1 = fizz.replaceGet(7, 68); // key 7 now points to value 68oldVal1; // 70
// Trying to replace the value in a non-existing key-value pair will do nothinglet oldVal2 = fizz.replaceGet(8, 68); // no key 8, so nothing was alteredoldVal2; // null
If the given value is null
and the key exists, the entry will be deleted from the map.
// Empty maplet fizz: map<Int, Int> = emptyMap();
// Setting a couple of values under different keysfizz.set(7, 70);fizz.set(42, 42);
// Overriding one of the existing key-value pairslet oldVal1 = fizz.replaceGet(7, null); // the entry under key 7 is now deletedoldVal1; // 70
// Trying to replace the value in a non-existing key-value pair will do nothinglet oldVal2 = fizz.replaceGet(8, null); // no key 8, so nothing was alteredoldVal2; // null
Delete entries, .del()
// K and V correspond to the key and value types of the given mapextends mutates fun del(self: map<K, V>, key: K): Bool;
To delete a single key-value pair (single entry), use the .del()
method. It returns true
in the case of successful deletion and false
otherwise.
// Empty maplet fizz: map<Int, Int> = emptyMap();
// Setting a couple of values under different keysfizz.set(7, 123);fizz.set(42, 321);
// Deleting one of the keyslet deletionSuccess: Bool = fizz.del(7); // true, because map contained the entry under key 7fizz.del(7); // false, because map no longer has an entry under key 7
// Note, that assigning the `null` value to the key when using the `.set()` method// is equivalent to calling `.del()`, although such approach is much less descriptive// and is generally discouraged:fizz.set(42, null); // the entry under key 42 is now deleted
To delete all the entries from the map, re-assign the map using the emptyMap()
function:
// Empty maplet fizz: map<Int, Int> = emptyMap();
// Setting a couple of values under different keysfizz.set(7, 123);fizz.set(42, 321);
// Deleting all of the entries at oncefizz = emptyMap();fizz = null; // identical to the previous line, but less descriptive
With this approach all previous entries of the map are completely discarded from the contract even if the map was declared as its persistent state variable. As a result, assigning maps to emptyMap()
does not inflict any hidden or sudden storage fees.
Check if entry exists, .exists()
Available since Tact 1.5
// K and V correspond to the key and value types of the given mapextends fun exists(self: map<K, V>, key: K): Bool;
The .exists()
method on maps returns true
if the value under the given key exists in the map and false
otherwise.
let fizz: map<Int, Int> = emptyMap();fizz.set(0, 0);
if (fizz.exists(2 + 2)) { // false dump("Something doesn't add up!");}
if (fizz.exists(1 / 2)) { // true dump("I told a fraction joke once. It was half funny.");}
if (fizz.get(1 / 2) != null) { // also true, but consumes more gas dump("Gotta pump more!");}
Check if empty, .isEmpty()
// K and V correspond to the key and value types of the given mapextends fun isEmpty(self: map<K, V>): Bool;
The .isEmpty()
method on maps returns true
if the map is empty and false
otherwise:
let fizz: map<Int, Int> = emptyMap();
if (fizz.isEmpty()) { dump("Empty maps are empty, duh!");}
// Note, that comparing the map to `null` behaves the same as `.isEmpty()` method,// although such direct comparison is much less descriptive and is generally discouraged:if (fizz == null) { dump("Empty maps are null, which isn't obvious");}
Compare with .deepEquals()
Gas-expensive
Available since Tact 1.5
// K and V correspond to the key and value types of the given mapextends fun deepEquals(self: map<K, V>, other: map<K, V>): Bool;
The .deepEquals()
method on maps returns true
if all entries of the map match corresponding entries of another map, ignoring possible differences in the underlying serialization logic. Returns false
otherwise.
let fizz: map<Int, Int> = emptyMap();let buzz: map<Int, Int> = emptyMap();
fizz.set(1, 2);buzz.set(1, 2);
fizz.deepEquals(buzz); // truefizz == buzz; // true, and uses much less gas to compute
Using .deepEquals()
is very important in cases where a map comes from the third-party source that doesn’t provide any guarantees about the serialization layout. For one such example, consider the following code:
// First map, with long labelsconst m1 = beginCell() .storeUint(2, 2) // long label .storeUint(8, 4) // key length .storeUint(1, 8) // key .storeBit(true) // value .endCell();
// Second map, with short labelsconst m2 = beginCell() .storeUint(0, 1) // short label .storeUint(0b111111110, 9) // key length .storeUint(1, 8) // key .storeBit(true) // value .endCell();
There, both maps are formed manually and both contain the same key-value pair. If you were to send both of those maps in a message to the Tact contract, and then compare them with .deepEquals()
and equality operator ==
, the former would produce true
because both maps have the same entry, while the latter would produce false
, because it only does the shallow comparison of map hashes. And those differ since the maps are serialized differently.
Convert to a Cell
, .asCell()
// K and V correspond to the key and value types of the given mapextends fun asCell(self: map<K, V>): Cell?;
On TVM, maps are represented as a Cell
type and it’s possible to construct and parse them directly. However, doing so is highly error-prone and quite messy, which is why Tact provides maps as a standalone composite type with many of the helper methods mentioned above.
To cast maps back to the underlying Cell
type, use the .asCell()
method. Since maps are initialized to null
, calling .asCell()
on a map with no values assigned will return null
and not an empty Cell
.
As an example, this method is useful for sending small maps directly in the body of the reply:
contract Example { // Persistent state variables fizz: map<Int, Int>; // our map
// Constructor (initialization) function of the contract init() { // Setting a bunch of values self.fizz.set(0, 3); self.fizz.set(1, 14); self.fizz.set(2, 15); self.fizz.set(3, 926); self.fizz.set(4, 5_358_979_323_846); }
// Internal message receiver, which responds to empty messages receive() { // Here we're converting the map to a Cell and making a reply with it self.reply(self.fizz.asCell()!!); // explicitly asserting that the map isn't null }}
Traverse over entries
To iterate over map entries there is a foreach
loop statement:
// Empty maplet fizz: map<Int, Int> = emptyMap();
// Setting a couple of values under different keysfizz.set(42, 321);fizz.set(7, 123);
// Iterating over in a sequential order: from the smallest keys to the biggest onesforeach (key, value in fizz) { dump(key); // will dump 7 on the first iteration, then 42 on the second}
Read more about it: foreach
loop in Book→Statements.
Note, that it’s also possible to use maps as simple arrays if you define a map<Int, V>
with an Int
type for the keys, any allowed V
type for values and keep track of the number of items in the separate variable:
contract Iteration { // Persistent state variables counter: Int as uint32; // counter of map entries, serialized as a 32-bit unsigned record: map<Int, Address>; // Int to Address map
// Constructor (initialization) function of the contract init() { self.counter = 0; // Setting the self.counter to 0 }
// Internal message receiver, which responds to a String message "Add" receive("Add") { // Get the Context Struct let ctx: Context = context(); // Set the entry: counter Int as a key, ctx.sender Address as a value self.record.set(self.counter, ctx.sender); // Increase the counter self.counter += 1; }
// Internal message receiver, which responds to a String message "Send" receive("Send") { // Loop until the value of self.counter (over all the self.record entries) let i: Int = 0; // declare usual i for loop iterations while (i < self.counter) { send(SendParameters{ bounce: false, // do not bounce back this message to: self.record.get(i)!!, // set the sender address, knowing that key i exists in the map value: ton("0.0000001"), // 100 nanoToncoins (nano-tons) mode: SendIgnoreErrors, // send ignoring errors in transaction, if any body: "SENDING".asComment() // String "SENDING" converted to a Cell as a message body }); i += 1; // don't forget to increase the i } }
// Getter function for obtaining the value of self.record get fun map(): map<Int, Address> { return self.record; }
// Getter function for obtaining the value of self.counter get fun counter(): Int { return self.counter; }}
It’s often useful to set an upper-bound restriction on such maps, so that you don’t hit the limits.
Limits and drawbacks
While maps can be convenient to work with on a small scale, they cause a number of issues if the number of items is unbounded and map can significantly grow in size:
-
As the upper bound of the smart contract state size is around items of type
Cell
, it constrains the storage limit of maps to be about key-value pairs for the whole contract. -
The more entries you have in a map, the bigger compute fees you’ll get. Thus, working with large maps makes compute fees tough to predict and manage.
-
Using a large map in a single contract doesn’t allow to distribute its workload. Hence, it can make the overall performance much worse compared to using a smaller map and a bunch of interacting smart contracts.
To resolve such issues you can set an upper-bound restriction on a map as a constant and check against it every time you’re setting a new value to the map:
contract Example { // Declare a compile-time constant upper-bound for our map const MaxMapSize: Int = 42;
// Persistent state variables arr: map<Int, Int>; // "array" of Int values as a map arrLength: Int = 0; // length of the "array", defaults to 0
// Internal function for pushing an item to the end of the "array" fun arrPush(item: Int) { if (self.arrLength >= self.MaxMapSize) { // Do something, stop the operation, for example } else { // Proceed with adding new item self.arr.set(self.arrLength, item); self.arrLength += 1; } }}
If you still need a large map or an unbound (infinitely large) map, it’s better to architect your smart contracts according to the asynchronous and actor-based model of TON blockchain. That is, to use contract sharding and essentially make the whole blockchain a part of your map(s).