An In-depth Tutorial on Solidity's immutable type

Understand the underlying optimization benefits of the immutable type and its limitations.

It’s one of those articles which will acts as a beginner’s guide to best practices but be a short on. The immutable keyword in solidity is one of those things that many developers learn when going through the solidity docs or through a bootcamp.

Apparently, that’s the end of it. Because just like that the keyword is forgotten – just used to pass those certifications exams. Coming from any other programming language, things might seem a bit off in Solidity. I have felt off like this atleast twice – while learning Solidity and while learning Rust.

I mean if we are talking about Rust, why do we need to use the extra mut to make variables when we are already using var in the declaration? But that’s a topic for another discussion, right now let’s focus on the nuances of Solidity.

immutable

In one line, the immutable keyword is used to define storage identifiers in a smart contract which would be initialized only during the contract deployment in the contract constructor or inline and there on out behave like a constant.

I am not really comfortable with the wording “immutable is used to define storage variables” because event though they can be thought of as a one-time variable data element, they are more of a cross between constants and variables (leaning more towards the former).

But what does this mean? Consider the problem – you have a variable in your smart contract which you know will only be changed once. This can happen when you are receiving values during contract deployment through the constructor. These values do not need to change during the remainder of the smart contract’s lifetime. You cannot use the keyword constant while defining that variable because then you will have to declare the value then and there. immutable keyword was brought in Solidity version 0.6.5 to remedy this situation.

Small use case

One of the most common use cases of the keyword can be seen in my previous articles where the usage of Chainlink VRF was explored via coding NFT smart contracts from scratch. As you may have noticed, these contracts had a VRF Coordinator address that was being passed on through the constructor towards the later articles.

The VRF Coordinator address is the address of the VRF Oracle contract on a specific chain and chances are that it won’t change ever. If so, then it might be just declared constant, right? But what happens when you want to generalize the contract? You could pass in the address via the constructor and have the address be immutable.

Under the hood

Solidity compiles our contracts to bytecode which gets deployed on-chain. During the compilation process, if you have a constant storage variable (again, oxymoronic), then Solidity Compiler will replace all occurrences of that constant in the smart contract with its respective value. This happens because those values are available during compile time.

But with immutables, Solidity replaces those values in all references of the immutable in the smart contract during deployment – that is only when the constructor of the smart contract is invoked. If you have ever used Remix, you can try to check it out yourself. Just go the Compile tab and click on the option to copy the compiles bytecode as shown below.

Remix, get Bytecode

Just paste in Notepad or something and then search for the value you gave to your constant during its declaration. You won’t find it. That’s because you need to search it in Hexadecimal. That’s a neat trick now? You will probably find it with a PUSH opcode.

Incredibles Meme

But at this point, you won’t find the immutable value. After all, why would you? Have you even passed it yet? But if you deploy the contract on a testnet and then scroll down to the opcode section, you can find the hex encoded value in the opcode beside a PUSH operation (just like you found the value of the constant. This indicates prior to deployment the values were put into the code. You can go ahead and search for the hex encoded variable but you won’t find it. We demonstrate this with Code in the next section.

This should enforce the different storage types in solidity namely – storage, memory and calldata and how constants and immutables do not utilize any of those.

Demonstrating with Code

In this section we will go through a few very basic contracts which will show the functioning of the immutable type in Solidity and how it differs from constant and variables. The first code shown below is actually the contract demonstrating what we discussed towards the end of the last section.

In the code above, you can see that there are 3 identifiers – c1, i1 and v1 for a constant, immutable and a variable respectively. Now there are 2 functions which you might notice are commented out. Those are for setting the constant and immutable. Obviously, you won’t be able to set the value of the constant c1 after it has been defined inline before the constructor. You will get a warning like the one shown below before compilation.

Cannot compile

You can try but the code won’t compile. Unsurprisingly, the same happens when you try to set the immutable outside the constructor. You can try it out yourself and would see a warning similar to the one below.

Cannot Compile

You can further compile the contract as in and search for 787632 in the compiled bytecode. 787632 is the hexadecimal for 7894578 and you will find it beside the PUSH op. Once you deploy the contract, you can try searching for the hexadecimal encoded immutable and you will find it as well. But you won’t find the hexadecimal encoded variable value in the deployed bytecode. I have deployed the contract on Polygon’s Mumbai testnet here. The immutable i1 has been initialized to 897568 while the variable has been initialized to 278563. DB220 and 44023 are the hex values for those respectively.

Interestingly, you will notice that the PUSH operation for the constant will look something like this:

PUSH3 0x787632

While the PUSH operation for the immutable in our case will look something like this:

PUSH32 0x00000000000000000000000000000000000000000000000000000000000db220

What can we infer from here? Head over to evm.codes and search for the PUSH3 and PUSH32 operations. Both of them are push operations but the former pushes 3 bytes while the latter 32. This shows that for our immutable i1, 32 bytes of contiguous storage was reserved which was filled in during contract deployment in the code itself. This shows the upper limit of 32 bytes for immutable type in Solidity. This should also explain why we cannot use immutable with arrays which would require an arbitrary amount of contiguous memory space in the code. It's not efficient and would make the code susceptible to high gas. Strings also cannot be used (though you can use bytes32).

Moving on to our second contract, we will now focus on how the compiler can differentiate between constants and immutables and notify us in our functions. In the contract given below, you will find 4 functions. 2 functions each for the constant and immutable. When you put this contract in Remix, you will get 2 kinds of warnings.

The first kind will tell you that you can turn the visibility of the functions getConstant() and getConstantSum() to pure. You might think that this is weird. You might have expected the warning to say view instead of pure. Afterall, you are still “technically” accessing a storage variable in these functions. But the thing is the value of the constant is replaced in all references of the constant in the contract code. This means that the functions getConstant() and getConstantSum() boil down to returning a integer value and a sum of two integer values. There is no need to refer to the storage class space for these. Since pure functions in solidity are the stateless functions (basically you can take those functions as is and stick them in any other contract but for a given set of inputs the output will always be the same regardless of the contract state), the above functions are essentially pure in nature. This is why you will get a warning like the one shown below.

Convert to Pure Solidity Functions

This is different from the getImmutable() and getImmutableSum() functions. We know that all references of i1 in the contract above will be replaced by its value during contract deployment. Those values are still not known to the compiler during the compilation stage. So, these functions are accessing a storage type space but not modifying – which is the trait of view function. Hence, a warning similar to the one below.

Convert to view type solidity function

In the code below, you can see that it contains 3 contracts. Here, we will see the deployment and function invocation costs between constant, immutable and variables. These are shown with Contract3, Contract5 and Contract4 respectively. Each of these have a constructor which gets invoked during deployment and these contracts have a getSum() function which takes in a uint256 parameter. I haven’t deployed them to Mumbai testnet. The images shown after the code are from Remix deployments.

Hopefully, the code in the above gist is easy enough to understand. We shall now try to deploy them in the order Contract3, Contract4 and Contract5 and look at respective deployment and function invocation gas costs. First up the contract with only the constant.

Solidity Contract Deployment

getSum for constant

The pictures above show the deployment and getSum() function invocation for Contract3 (with the constant only).

Solidity Contract Deployment

getSum( with variable

The pictures above show the deployment and getSum() function invocation for Contract4 (with just a variable).

Solidity Contract Deployment

getSum with immutable

The pictures above show the deployment and getSum() function invocation for Contract5 (with immutable).

The difference is apparent. The gas costs with constant in deployment and getSum() invocation is least. The deployment and function invocation with variable is highest. The contract with immutable scores just in the middle. This shows that immutable is better than variable types when it comes to gas. It has the advantage of being initialized from the constructor as well as inline. This concludes what we needed to cover.

Conclusion

In our three exercises in this section, we went through:

  1. Understanding the working of immutable type in Solidity.
  2. How immutables are seen by the compiler.
  3. The gas optimization benefits of immutable.

Frankly, this is almost everything to it. You should now have a clear understanding of the type. This article was different from my other articles. It was more fundamental in nature. Hopefully, you got something out of it.

If you have any other points to add that I may have missed, feel free to start a discussion below. You can reach out to me via Twitter or by mail. Until the next one – continue to build awesome stuff on Web3 and WAGMI!

Did you find this article valuable?

Support Bored on the Edge by becoming a sponsor. Any amount is appreciated!