Skip to content

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 the Int type for its keys and values:

struct IntToInt {
counters: map<Int, Int>;
}

Since maps can use any given struct or message struct as their value types, nested maps can be created via helper structures like this:

// A `map<Address, Int>` packed into the `AllowanceMap` structure
struct AllowanceMap { unbox: map<Address, Int> }
contract NestedMaps {
// Empty receiver for the deployment,
// which forwards the remaining value back to the sender
receive() { cashback(sender()) }
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 the 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 is possible to perform integer serialization of map keys, values, or both to preserve space and reduce storage costs:

struct SerializedMapInside {
// Both keys and values here are serialized as 8-bit unsigned integers,
// thus preserving 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 map
fun emptyMap(): map<K, V>;

Declaring a map as a local variable, using the emptyMap() function from the 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 do not need default values or initialization in the init() function.

Set values, .set()

// K and V correspond to the key and value types of the given map
extends 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 map
let fizz: map<Int, Int> = emptyMap();
// Setting a couple of values under different keys
fizz.set(7, 7);
fizz.set(42, 42);
// Overriding one of the existing key-value pairs
fizz.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 map
extends fun get(self: map<K, V>, key: K): V?;

You can 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 map
let fizz: map<Int, Int> = emptyMap();
// Setting a value
fizz.set(68, 0);
// Getting the value by its key
let 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 crash at runtime if the value is, in fact, null
// Alternatively, we can check for the key in an if statement
if (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 map
extends mutates fun replace(self: map<K, V>, key: K, val: V): Bool;

To replace the value associated with a key, if such a key exists, use the .replace() method. It returns true upon successful replacement and false otherwise.

// Empty map
let fizz: map<Int, Int> = emptyMap();
// Setting a couple of values under different keys
fizz.set(7, 70);
fizz.set(42, 42);
// Overriding one of the existing key-value pairs
let replaced1 = fizz.replace(7, 68); // key 7 now points to value 68
replaced1; // true
// Trying to replace the value of a non-existing key does nothing
let replaced2 = fizz.replace(8, 68); // no key 8, so nothing was altered
replaced2; // false

If the given value is null and the key exists, the entry is deleted from the map.

// Empty map
let fizz: map<Int, Int> = emptyMap();
// Setting a couple of values under different keys
fizz.set(7, 70);
fizz.set(42, 42);
// Overriding one of the existing key-value pairs
let replaced1 = fizz.replace(7, null); // the entry under key 7 is now deleted
replaced1; // true
// Trying to replace the value of a non-existing key does nothing
let replaced2 = fizz.replace(8, null); // no key 8, so nothing was altered
replaced2; // 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 map
extends 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 a successful replacement and null otherwise.

// Empty map
let fizz: map<Int, Int> = emptyMap();
// Setting a couple of values under different keys
fizz.set(7, 70);
fizz.set(42, 42);
// Overriding one of the existing key-value pairs
let oldVal1 = fizz.replaceGet(7, 68); // key 7 now points to value 68
oldVal1; // 70
// Trying to replace the value of a non-existing key-value pair will do nothing
let oldVal2 = fizz.replaceGet(8, 68); // no key 8, so nothing was altered
oldVal2; // null

If the given value is null and the key exists, the entry will be deleted from the map.

// Empty map
let fizz: map<Int, Int> = emptyMap();
// Setting a couple of values under different keys
fizz.set(7, 70);
fizz.set(42, 42);
// Overriding one of the existing key-value pairs
let oldVal1 = fizz.replaceGet(7, null); // the entry under key 7 is now deleted
oldVal1; // 70
// Trying to replace the value of a non-existing key-value pair will do nothing
let oldVal2 = fizz.replaceGet(8, null); // no key 8, so nothing was altered
oldVal2; // null

Delete entries, .del()

// K and V correspond to the key and value types of the given map
extends mutates fun del(self: map<K, V>, key: K): Bool;

To delete a single key-value pair (a single entry), use the .del() method. It returns true in the case of successful deletion and false otherwise.

// Empty map
let fizz: map<Int, Int> = emptyMap();
// Setting a couple of values under different keys
fizz.set(7, 123);
fizz.set(42, 321);
// Deleting one of the keys
let deletionSuccess: Bool = fizz.del(7); // true, because the map contained the entry under key 7
fizz.del(7); // false, because the map no longer has an entry under key 7
// Note that assigning the `null` value to a key when using the `.set()` method
// is equivalent to calling `.del()`, although this 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 map
let fizz: map<Int, Int> = emptyMap();
// Setting a couple of values under different keys
fizz.set(7, 123);
fizz.set(42, 321);
// Deleting all of the entries at once
fizz = 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 a persistent state variable. As a result, assigning maps to emptyMap() does not incur 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 map
extends fun exists(self: map<K, V>, key: K): Bool;

The .exists() method on maps returns true if a 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 map
extends 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 the `.isEmpty()` method,
// although such a 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 map
extends 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); // true
fizz == buzz; // true, and uses much less gas to compute

Using .deepEquals() is very important in cases where a map comes from a third-party source that doesn’t provide any guarantees about the serialization layout. For one such example, consider the following code:

some-typescript-code.ts
// First map, with long labels
const m1 = beginCell()
.storeUint(2, 2) // long label
.storeUint(8, 4) // key length
.storeUint(1, 8) // key
.storeBit(true) // value
.endCell();
// Second map, with short labels
const m2 = beginCell()
.storeUint(0, 1) // short label
.storeUint(0b111111110, 9) // key length
.storeUint(1, 8) // key
.storeBit(true) // value
.endCell();

Here, both maps are formed manually and both contain the same key-value pair. If you were to send both of these maps in a message to a Tact contract and then compare them with .deepEquals() and the equality operator ==, the former would produce true because both maps have the same entry, while the latter would produce false as it performs only a shallow comparison of map hashes, which 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 map
extends 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 a 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 map
let fizz: map<Int, Int> = emptyMap();
// Setting a couple of values under different keys
fizz.set(42, 321);
fizz.set(7, 123);
// Iterating in sequential order: from the smallest keys to the biggest ones
foreach (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 is 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 the values, and keep track of the number of items in a separate variable:

contract Iteration {
// Persistent state variables
counter: Int as uint32; // Counter of map entries, serialized as a 32-bit unsigned integer
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 the key, ctx.sender Address as the 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 reaching the value of self.counter (over all the self.record entries)
let i: Int = 0; // Declare i as usual 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 nanoToncoin (nano-tons)
mode: SendIgnoreErrors, // Send ignoring any transaction errors
body: "SENDING".asComment() // String "SENDING" converted to a Cell as a message body
});
i += 1; // Don't forget to increase 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 the map significantly grows in size:

  • As the upper bound of the smart contract state size is around 6500065\,000 items of type Cell, it constrains the storage limit of maps to about 3000030\,000 key-value pairs for the whole contract.

  • The more entries you have in a map, the higher compute fees you will incur. Thus, working with large maps makes compute fees difficult to predict and manage.

  • Using a large map in a single contract does not allow distributing its workload. Hence, this can significantly degrade overall performance compared to using a smaller map along with multiple 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 set a new value in 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, for example, stop the operation
} else {
// Proceed with adding a new item
self.arr.set(self.arrLength, item);
self.arrLength += 1;
}
}
}

If you still need a large or unbounded (infinitely large) map, it is better to architect your smart contracts according to the asynchronous and actor-based model of TON blockchain. That is, use contract sharding and essentially make the entire blockchain part of your map(s).