Lazy loading, partial loading, partial updating
One magic keyword — lazy
— to rule them all.
Lazy loading, partial loading
Suppose you have a Storage
struct in a wallet:
struct Storage {
isSignatureAllowed: bool
seqno: uint32
subwalletId: uint32
publicKey: uint256
extensions: dict
}
fun Storage.load() {
return Storage.fromCell(contract.getData())
}
What does Storage.load()
do? It unpacks a cell, populates all struct fields, checks consistency, and so on.
The magic of lazy Storage.load()
is that it does not load the entire cell upfront. Instead, the compiler tracks exactly which fields you access and automatically loads only those, skipping the rest.
get fun getPublicKey() {
val st = lazy Storage.load();
// <-- here "skip 65 bits, preload uint256" is inserted
return st.publicKey
}
That’s it! With a single lazy
keyword, loading is deferred until the data is accessed.
The compiler tracks all control flow paths, inserts loading points as needed, groups unused fields to skip, and performs other optimizations as necessary.
Best of all, this works with any type and any combination of fields used anywhere in your code — the compiler tracks everything.
Even deeper than you might think
Suppose you have an NFT collection:
struct NftCollectionStorage {
adminAddress: address
nextItemIndex: uint64
content: Cell<CollectionContent>
...
}
struct CollectionContent {
metadata: cell
minIndex: int32
commonKey: uint256
}
Now, imagine you want to access just the content
field from the storage—and then extract commonKey
from it:
val storage = lazy NftCollectionStorage.load();
// <-- here just "preload ref" is inserted
val contentCell = storage.content;
First trick: no need to skip address or skip uint64. To get a reference field, the compiler knows exactly where it is and doesn't require skipping preceding data.
Second trick: we have contentCell
. How do we get commonKey
from it? Since content
is a cell, you need to load it… lazily:
val storage = lazy NftCollectionStorage.load();
// <-- "preload ref" inserted — to get `content`
// Cell<T>.load() unpacks a cell and gives you T
val content = lazy storage.content.load();
// <-- "skip 32 bits, preload uint256" - to get commonKey
return content.commonKey;
A quick reminder about Cell<T>
: these typed cells are commonly used to represent nested references.
When you have p: Cell<Point>
, you can't directly access p.x
— you need to load the cell first, either with Point.fromCell(p)
or, preferably, p.load()
.
Both can be used with lazy
.
Lazy matching
Similarly, when reading a union type such as an incoming message, you use lazy
:
struct (0x12345678) CounterReset { ... }
...
type MyMessage = CounterReset | CounterIncrement | ...
val msg = lazy MyMessage.fromSlice(msgBody);
match (msg) {
CounterReset => {
assert(senderAddress == storage.owner) throw 403;
// <-- here "load msg.initial" is inserted
storage.counter = msg.initial;
With lazy
applied to unions:
- No union is allocated on the stack upfront; matching and loading are deferred until needed.
match
operates naturally by inspecting the slice prefix (opcode).- Within each branch, the compiler inserts loading points and skips unused fields — just like it does for structs.
This makes lazy matching highly efficient, outperforming patterns like if (op == OP_RESET)
commonly used in FunC.
From a type system perspective, it aligns perfectly with the TVM execution model, eliminating unnecessary stack operations.
Lazy matching and else
Since lazy match
for a union is done by inspecting the prefix (opcode), you can handle unmatched cases using an else
branch.
In FunC contracts, a common pattern was to ignore empty messages:
// FunC-style
if (msgBody.isEmpty()) {
return; // ignore empty messages
}
val op = msgBody.loadUint(32); // because this would throw excno 9
if (op == OP_RESET) {
...
return;
}
throw 0xFFFF; // "invalid opcode"
The only reason to handle empty messages upfront was to avoid throwing a cell underflow error when calling loadUint
.
With lazy match
, you no longer need to pay gas upfront for these checks. You can handle all cases in the else
branch:
val msg = lazy MyMessage.fromSlice(msgBody);
match (msg) {
CounterReset => { ... }
... // handle all types of a union
// else - when nothing matched;
// even corrupted input (less than 32 bits), no "underflow" fired
else => {
// ignore empty messages, "wrong opcode" for others
assert (msgBody.isEmpty()) throw 0xFFFF
}
}
Without an explicit else
, unpacking throws error 63
by default,
which is controlled by the throwIfOpcodeDoesNotMatch
option in fromCell/fromSlice.
Adding else
allows you to override this behavior.
Note that else
in match
by type is only allowed with lazy
because it matches on prefixes.
Without lazy
, it's just a regular union, matched by a union tag (typeid
) on a stack.