Mastering Solidity: A Comprehensive Guide to Data Types

Rosario Borgesi
Coinmonks
25 min readApr 21, 2024

--

Smart contracts are the backbone of decentralized applications (DApps) on the Ethereum blockchain. As a developer, mastering Solidity — the programming language for Ethereum smart contracts — is essential. One crucial aspect of Solidity is understanding its data types, which play a pivotal role in creating secure, efficient, and reliable smart contracts.

In this article, we’ll explore the various data types offered by Solidity, from primitive types to more complex data types like arrays, structs and mappings.

For each type, you will find many code examples in Solidity that will help you understand in a more practical way how they can be used.

Whether you’re a beginner or an experienced developer, this guide will equip you with the knowledge needed to write robust and effective smart contracts. I hope you will enjoy this story!

I also have a video related to the topics covered in this story. You can see the Solidity parts tested on Remix and I believe that can give you a more in depth understanding of the code:

Table of Contents

· Table of Contents
· Types
· Value Types
Booleans
Integers
Fixed Point Numbers
Addresses
Contract Types
Fixed-size byte arrays
Strings
Bytes vs Strings
Converting int and uint to string
Enums
Function Types
· Reference Types
Arrays
Structs
Mappings
· OpenZeppelin’s Data Types
· Debugging with Hardhat
· Further Exploration
· Conclusions
· References

Types

Solidity, like Java, is a statically typed language. This implies that you must explicitly specify the type of each variable (whether it’s a state variable or a local variable).

Unlike some other languages, Solidity does not have the concept of “undefined” or “null” values. However, when you declare a new variable, it automatically receives a default value based on its type.

Solidity types can be grouped in two categories:

  • Value types: data types where each variable has an independent copy. When you use a variable of a value type, it is always copied (passed by value) when used as a function argument or in assignments.
  • Reference types: store a location (reference) to the actual data rather than holding the data directly. Multiple variables can reference the same location.

Value Types

Value types variables are always passed by value. This means they are copied whenever they are used as function arguments or in assignments. In other words, you get an independent copy whenever a variable of value type is used. Additionally:

  • Variables of value types do not share memory locations.
  • Changes to one variable do not affect other variables of the same type.
  • Value types are straightforward to handle.

Currently, value types comprise:

  • Booleans
  • Integers
  • Address
  • Contract Types
  • Fixed-size byte arrays
  • Strings
  • Enums
  • Function Types

Booleans

Booleans are represented by the bool type in Solidity and can only have two values: true or false. Here are some key points:

  1. If you declare a boolean variable without explicitly assigning a value, it defaults to false. For example
bool bool1; // Default value: false

2. You can explicitly initialize boolean variables during declaration:

bool bool2 = true; // Initialized to true
bool bool3 = false; // Initialized to false

3. You can use the following operators to perform operations between bools:

  • ! (negation)
  • && (and)
  • || (or)
  • == (equality)
  • != (inequality)
bool bool1 = false;
bool bool2 = true;
bool notOp = !bool1; // true
bool andOp = bool1 && bool2; // false
bool orOp = bool1 || bool2; // true
bool equalityOp = bool1 == bool2; // false
bool inequalityOp = bool1 != bool2; // true

Integers

Integer numbers, i.e., numbers without decimals, are represented in two ways in Solidity:

  • int : These are signed integers; they can be either positive or negative.
  • uint : These are unsigned integers; they can only be positive.

Both int and uint are available in increments of 8 bits, from 8 up to 256. In fact, there are:

  • uint8, uint16, uint32, …, uint256
  • int8, int16, int32, …, int256

Each type can represent different ranges of numbers. The range of a uint varies from 0 to 2^n − 1. Therefore, in the following, you can observe the range of numbers that each type supports:

  • uint8: from 0 to 255
  • uint16: from 0 to 65,535
  • uint32: from 0 to 4,294,967,295
  • uint256: from 0 to 1.157920892373162e+77

Similarly, the range of numbers for int varies from — 2^(n-1) to 2^(n-1) — 1. So, the range of numbers supported by each type is:

  • int8: from -128 to 127
  • int16: from -32,768 to 32,767
  • int32: from -2,147,483,648 to 2,147,483,647
  • int256 from -5.78960446186581e76 to 5.78960446186581e76

If we try to assign to a variabile a number which is greater then the supported range the compiler will signal an error. For example:

int8 int1 = 127; // Ok
int8 int2 = 128; // TypeError: Type int_const 128 is not implicitly
// convertible to expected type int8. Literal is too large to fit in int8.

The type int is equivalent to int256 and the type uint is equivalent to uint256.

Supported operators are:

  • Comparisons: <=, <, ==, !=, >=, > (evaluate to bool)
  • Bit operators: &, |, ^ (bitwise exclusive or), ~ (bitwise negation)
  • Shift operators: << (left shift), >> (right shift)
  • Arithmetic operators: +, -, unary - (only for signed integers), *, /, % (modulo), ** (exponentiation)
int int1 = 2;
int int2 = 5;
bool comparisonOp1 = int1 <= int2; // true
bool comparisonOp3 = int1 == int2; // false
bool comparisonOp4 = int1 >= int2; // false
bool comparisonOp5 = int1 != int2; // true
int sum = int1 + int2; // 7
int subtraction = int1 - int2; // -3
int multiplication = int1 * int2; // 10
int division = int1 / int2; // 0
int modulo = int1 % int2; // 2
uint uint1 = 2;
uint uint2 = 3;
uint exponentiation = uint1 ** uint2; // 8

Fixed Point Numbers

In Solidity, decimal numbers (such as fixed-point, floating-point, or double types) are currently not supported. Although there are fixed and ufixed types intended to support signed and unsigned fixed-point numbers of various sizes, they are not yet fully supported.

However, you can simulate decimal numbers using integers. This is typically done by deciding on a fixed “scale” or “precision” — for example, deciding to represent all numbers as integers that are actually 10⁶ times the actual value. This would give you a precision up to 6 decimal places.

Here’s an example of how you might do this:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.25;

contract Decimals {
// Use a factor of 10^6 to simulate six decimal places
uint256 constant FACTOR = 10**6;

function add(uint256 a, uint256 b) public pure returns (uint256) {
return a + b;
}

function subtract(uint256 a, uint256 b) public pure returns (uint256) {
return a - b;
}

function multiply(uint256 a, uint256 b) public pure returns (uint256) {
return a * b / FACTOR;
}

function divide(uint256 a, uint256 b) public pure returns (uint256) {
return a * FACTOR / b;
}
}

There are also libraries that provide some similar workarounds to handle fixed-point numbers, and you can read about them here.

Addresses

The address type in Solidity is available in two similar forms:

  • address : This can hold a 20-byte value, which is the size of an Ethereum address.
  • address payable : This is similar to address, but it includes additional members, transfer and send.

The key difference between these two is that you can send Ether to an address payable, while a plain addressshould not receive Ether. This could be because it might be a smart contract that isn’t designed to accept Ether.

Converting from an address payable type to an address type is automatically allowed. However, to convert from an address type to an address payable type, you must explicitly use the payable(<address>) conversion.

Only terms of address and contract types can be explicitly converted to the address payable type using the payable(...) conversion. However, for contract types, this conversion is permissible only if the contract is capable of accepting Ether.

Operators:

  • <=, <, ==, !=, >= and >
address addr1 = 0x5B38Da6a701c568545dCfcB03FcB875f56beddC4;
address addr2 = 0xAb8483F64d9C6d1EcF9b849Ae677dD3315835cb2;
bool equal = addr1 == addr2; // false
bool notEqual = addr1 != addr2; // true
bool greater = addr1 > addr2; // false

The address type has different members that you can find here, however the most important ones are balance and transfer:

  • balance: allows to query the balance of an address.
  • transfer: allows to send Ether (in units of wei) to a payable address. It fails if the balance of the current contract is not large enough or if the Ether transfer is rejected by the receiving account. The transfer function reverts on failure.

In the following contract we demonstrate how these two functions can be used:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.25;

contract Addresses {
// the constructor is payable so that the contract can receive funds upon deployment
constructor() payable {
// deploy the contract with sufficient funds to run the test
require(msg.value >= 0.01 ether, "Insufficient Ether");
}

function test() public {
address recipientAddress = 0x1F0c72E13718D9136FfE51b89289b239A1BcfE28;
uint amount = 0.001 ether;
uint expectedGasFeesContractDeployment = 0.005 ether; // estimate of the gas fee cost to deploy the contract
uint expectedGasFeesTestFunction = 0.0005 ether; // estimate of the gas fee cost to run the test function

uint balanceRecipientBeforeTransfer = getRecipientBalance(recipientAddress);
uint balanceContractBeforeTransfer = getContractBalance();

sendEther(payable(recipientAddress), amount);

uint balanceRecipientAfterTransfer = getRecipientBalance(recipientAddress);
uint balanceContractAfterTransfer = getContractBalance();

assert(balanceRecipientAfterTransfer > balanceRecipientBeforeTransfer + amount - expectedGasFeesContractDeployment);
assert(balanceContractAfterTransfer > balanceContractBeforeTransfer - amount - expectedGasFeesTestFunction);
}

// This function sends `amount` ether to `recipient`
function sendEther(address payable recipient, uint256 amount) public {
// Check the contract has enough balance to send
require(address(this).balance >= amount, "Insufficient balance in contract");

// Transfer the ether
recipient.transfer(amount);
}
// get the balance of a `recepient` address which can be a contract or a user
function getRecipientBalance(address recipient) public view returns (uint) {
return recipient.balance;
}
// get the balance of this contract
function getContractBalance() public view returns (uint) {
return address(this).balance;
}
}
  1. The syntax uint amount = 0.001 ether; is very handy because it allows you to specify an amount in Ether that automatically gets converted to Wei.
  2. The assertions executed in the test function on the recipient and on the contract balances, both before and after the Ether transfer, take into account the gas fees. Of course, since this cost cannot be known beforehand, we cannot perform a strict assertion.

I also suggest you to take a look at the Address utility library provided by OpenZeppelin which provides some useful functoin to work with addresses.

Contract Types

Each contract establishes its own unique type. It’s possible to explicitly convert contracts to and from the address type.

If you define a local variable of a contract type (for instance, MyContract c), you have the ability to invoke functions on that contract. However, ensure that it’s assigned from a source that is of the identical contract type.
For instance:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.25;

contract CalledContract {
uint public x;

function setX(uint _x) public {
x = _x;
}
}

contract CallingContract {
CalledContract public calledContract;

constructor(address _calledContractAddress) {
calledContract = CalledContract(_calledContractAddress);
}

function callSetXOnCalledContract(uint _x) public {
calledContract.setX(_x);
}
}
  • First, you deploy the contract CalledContract. Let’s assume that the address of this contract is 0xd9145CCE52D386f254917e481eB44e9943F39138.
  • Next you deploy the contract CallingContract and pass the address of the previously deployed contract to its constructor.
  • Now when we invoke the callSetXOnCalledContract function it will modify the value of the x variable in the CalledContract.

Contracts can also be created within other contracts using the new keyword:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.25;
contract D {
uint public x;
constructor(uint a) payable {
x = a;
}
}

contract C {
D d = new D(4); // will be executed as part of C's constructor

function createD(uint arg) public {
D newD = new D(arg);
newD.x();
}

function createAndEndowD(uint arg, uint amount) public payable {
// Send ether along with the creation
D newD = new D{value: amount}(arg);
newD.x();
}
}

Contracts do not support any operators.

Fixed-size byte arrays

The value types bytes1, bytes2, bytes3, …, bytes32 hold a sequence of bytes from one to up to 32.

In general, we can assign a string literal, defined as a sequence of characters enclosed in double or single quotes ("foo" or 'bar'), to a byte array, as long as it doesn’t exceed the length of the byte array.

For instance:

bytes32 byteArray = "aa323334353637383931323334353633"; // 32 bytes OK
bytes32 byteArray2 = "aa3233343536373839313233343536334"; // 33 bytes, will not compile, because a bytes32 can hold at most 32 characters

Operators:

  • Comparisons: <=, <, ==, !=, >=, > (evaluate to bool)
  • Bit operators: &, |, ^ (bitwise exclusive or), ~ (bitwise negation)
  • Shift operators: << (left shift), >> (right shift)
  • Index access: If x is of type bytesI, then x[k] for 0 <= k < I returns the k th byte (read-only).

Members:

  • .length yields the fixed length of the byte array (read-only).
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.25;

contract Bytes32Example {
bytes32 public data;

function setData(string memory _data) public {
require(bytes(_data).length <= 32, "Data too long");
data = stringToBytes32(_data);
}

function stringToBytes32(string memory source) private pure returns (bytes32 result) {
bytes memory tempEmptyStringTest = bytes(source);
if (tempEmptyStringTest.length == 0) {
return 0x0;
}

assembly {
result := mload(add(source, 32))
}
}
}
  • In this example, the Bytes32Example contract has a data state variable of type bytes32.
  • The setData function allows you to set the data variable, but it requires that the input string is no longer than 32 bytes.
  • The stringToBytes32 function is a helper function that converts a string to a bytes32. This is done using inline assembly, which is a low-level language available in Solidity.

Please note that inline assembly should be used with caution as it can potentially introduce security vulnerabilities.

The type byte has the concat member:

Strings

As we said earlier string literals are a sequence of characters , written with either double or single-quotes ("foo" or 'bar'). They can be assigned to strings and in this case we don’t have an upper limit on the number of bytes.

string str = "myString";

String literals can only contain printable ASCII characters, which means the characters between and including 0x20 .. 0x7E.

Additionally, string literals also support the following escape characters:

  • \<newline> (escapes an actual newline)
  • \\ (backslash)
  • \' (single quote)
  • \" (double quote)
  • \n (newline)
  • \r (carriage return)
  • \t (tab)
  • \xNN (hex escape)
  • \uNNNN (unicode escape)

Like the type byte, also strings have the concat member:

Bytes vs Strings

Variables of type bytes and string are special arrays. The bytes type is is packed tightly in calldata and memory. string is equal to bytes but does not allow length or index access.

  • Solidity does not have string manipulation functions, but there are third-party string libraries.
  • You can compare two strings by their keccak256-hash using keccak256(abi.encodePacked(s1)) == keccak256(abi.encodePacked(s2))
  • You can concatenate two strings using string.concat(s1, s2).
  • As a general rule, use bytes for arbitrary-length raw byte data and string for arbitrary-length string (UTF-8) data.
  • If you can limit the length to a certain number of bytes, always use one of the value types bytes1 to bytes32 because they are much cheaper.

If you want to access the byte-representation of a string s, use bytes(s).length / bytes(s)[7] = 'x';. Keep in mind that you are accessing the low-level bytes of the UTF-8 representation, and not the individual characters. Link at the documentation.

In the following code we demonstrate how to concatenate, compare, calculate the length of a string and how to convert a string to bytes.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.25;

contract StringOperations {
function test() public pure {
string memory str1 = "abc";
string memory str2 = "def";
string memory concat = concatenate(str1, str2);
assert(compareStrings(concat, "abcdef"));
assert(stringLength(concat) == 6);
}

// Concatenates two strings and returns the result
function concatenate(string memory str1, string memory str2) public pure returns (string memory) {
return string.concat(str1, str2);
}

// Compares two strings and returns true if they are equal
function compareStrings(string memory str1, string memory str2) public pure returns (bool) {
return keccak256(abi.encodePacked(str1)) == keccak256(abi.encodePacked(str2));
}

// Converts a string to its bytes representation
function stringToBytes(string memory str) public pure returns (bytes memory) {
return bytes(str);
}

// Gets the length of a string
function stringLength(string memory str) public pure returns (uint) {
return bytes(str).length;
}
}

Converting int and uint to string

Solidity does not directly support the conversion from int or uint to strings, so we need to write our own functions to accomplish this. Below, you can find two functions, namely intToString and uintToString, that convert int and uint to string, respectively:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.25;

contract Integers {
function test() public view {
int int1 = 2;
int int2 = 5;
int sum = int1 + int2;
uint uint1 = 2;
uint uint2 = 3;
uint exponentiation = uint1 ** uint2;

assert(sum == 7);
assert(exponentiation == 8);
assert(compareStrings(intToString(sum),"7"));
assert(compareStrings(uintToString(exponentiation),"8"));
}

// convert int to string
function intToString(int256 _i) public pure returns (string memory) {
if (_i == 0) {
return "0";
}
bool negative = _i < 0;
uint256 i = negative ? uint256(-_i) : uint256(_i);
uint256 j = i;
uint256 length;
while (j != 0) {
length++;
j /= 10;
}
if (negative) {
length++;
}
bytes memory buffer = new bytes(length);
while (i != 0) {
buffer[--length] = bytes1(uint8(48 + i % 10));
i /= 10;
}
if (negative) {
buffer[0] = '-';
}
return string(buffer);
}

//convert uint to string
function uintToString(uint256 _i) public pure returns (string memory) {
if (_i == 0) {
return "0";
}
uint256 j = _i;
uint256 length;
while (j != 0) {
length++;
j /= 10;
}
bytes memory buffer = new bytes(length);
while (_i != 0) {
buffer[--length] = bytes1(uint8(48 + _i % 10));
_i /= 10;
}
return string(buffer);
}

// Compares two strings and returns true if they are equal
function compareStrings(string memory str1, string memory str2) public pure returns (bool) {
return keccak256(abi.encodePacked(str1)) == keccak256(abi.encodePacked(str2));
}
}

I suggest you also to take a look at the Strings utility library provided by OpenZeppelin which contains a lot of useful method to operate with strings.

Enums

Enums provide a method for defining custom types in Solidity. They can be explicitly converted to and from all types of integers, but they don’t support implicit conversion. An enum must have at least one member, and when declared, its default value is the first member. An enum is limited to a maximum of 256 members.

Using type(NameOfEnum).min and type(NameOfEnum).max you can get the smallest and respectively largest value of the given enum.

In the following we demonstrate the operations that can be executed with the type enum.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.25;

contract ShippingStatus {
// Enum representing shipping status
enum Status {
Pending,
Shipped,
Accepted,
Rejected,
Canceled
}

// Default value is the first element listed in
// definition of the type, in this case "Pending"
Status public status;

// Returns uint
// Pending - 0
// Shipped - 1
// Accepted - 2
// Rejected - 3
// Canceled - 4
function get() public view returns (Status) {
return status;
}

// Update status by passing uint into input
function set(Status _status) public {
status = _status;
}

// You can update to a specific enum like this
function cancel() public {
status = Status.Canceled;
}

// delete resets the enum to its first value, 0
function reset() public {
delete status;
}

function getLargestValue() public pure returns (Status) {
return type(Status).max;
}

function getSmallestValue() public pure returns (Status) {
return type(Status).min;
}

function test() public {
set(Status.Accepted);
assert(get() == Status.Accepted);
cancel();
assert(get() == Status.Canceled);
reset();
assert(get() == Status.Pending);
assert(getLargestValue() == Status.Canceled);
assert(getSmallestValue() == Status.Pending);
}
}

Function Types

Function types represent the types associated with functions. You can assign function-type variables from functions, and you can use function parameters of function type to pass functions into and return functions from function invocations. There are two kinds of function:

  • Internal functions are restricted to being invoked within the current contract.
  • External functions are meant to be called by other contracts or transactions.

Function types are notated as follows:

function (<parameter types>) {internal|external} [pure|view|payable] [returns (<return types>)]

In contrast to the parameter types, the return types cannot be empty — if the function type should not return anything, the whole returns (<return types>) part has to be omitted.

By default, function types are internal, so the internal keyword can be omitted. Note that this only applies to function types. Visibility has to be specified explicitly for functions defined in contracts, they do not have a default.

The concept of payable and non-payable functions can be a bit complex. Essentially, a function marked as payable is capable of accepting Ether payments, including payments of zero Ether, which technically makes it non-payable as well. Conversely, a function that is non-payable will refuse any Ether sent to it, meaning non-payable functions cannot be transformed into payable ones.

If a function type variable is not initialised, calling it results in a Panic error. The same happens if you call a function after using delete on it.

Public functions of the current contract can be used both as an internal and as an external function. To use f as an internal function, just use f, if you want to use its external form, use this.f.

Members:

External (or public) functions have the following members:

You can use {gas: ...} and {value: ...} to specify the amount of gas or the amount of wei sent to a function, respectively. See External Function Calls for more information.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.25;

contract FunctionTypeExample {

// A function that accepts a function of the defined type
function executeFunction(
function (uint) internal pure returns (uint) f,
uint value
) internal pure returns (uint) {
// Call the passed function with the provided value
return f(value);
}

function square (uint x) internal pure returns (uint) {
return x**2;
}

function test() public pure {
assert(executeFunction(square, 3) == 9);
}
}

In the previous example the executeFunction accepts a function type as parameter and in the test function we are passing to it the square function as an argument. More example can be found in the documentation.

Reference Types

Reference types contain a reference to the actual data, as opposed to directly storing the data itself. Additionally:

  • Variables of reference types share the underlying data.
  • Changes made to one variable affect other variables referencing the same data.
  • Reference types require careful handling to avoid unintended side effects.

Currently, reference types comprise structs, arrays and mappings.

When we use a reference type, we have to explicitly provide the data area where the type is stored:

  • storage - variable is a state variable, and it is stored on the blockchain. (persistent, modifiable)
  • memory - variable is in memory and it exists while a function is being called (non-persistent, modifiable)
  • calldata - is a non-modifiable, non-persistent area where function arguments are stored, and behaves mostly like memory. (non-persistent, non-modifiable)

Arrays

Arrays can have a compile-time fixed size, or they can have a dynamic size.

The type of an array of fixed size k and element type T is written as T[k], and an array of dynamic size as T[].

Array elements can be of any type, including mapping or struct.

It is possible to mark state variable arrays public and have Solidity create a getter. The numeric index becomes a required parameter for the getter.

A very good article that explains arrays in Solidity is this one.

In the following code we demonstrate both fixed and dynamic arrays: we can see how they can be declared as global and local variables and how we can compare two arrays.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.25;

contract TestArrays {
// arrays as state variables
uint[] public arrayDynamic = [1, 2, 3, 4, 5];
uint[3] public arrayFixed = [1, 2, 3];

function localArrayFixed() public pure returns (uint8[3] memory) {
// fixed-size array as local variable
uint8[3] memory array = [1, 2, 3];
return array;
}

function localArrayFixed1() public pure returns (uint[4] memory) {
// fixed-size array as local variable
uint8[4] memory array8 = [1, 2, 3, 4];
uint[4] memory array256;
for (uint i = 0; i < array8.length; i++) {
array256[i] = uint(array8[i]);
}
return array256;
}

function localArrayDynamic() public pure returns (uint[] memory) {
uint8[5] memory fixedArray = [1, 2, 3, 4, 5];
uint[] memory dynamicArray = new uint[](5); //memory arrays can not be resized
for (uint i = 0; i < fixedArray.length; i++) {
dynamicArray[i] = uint(fixedArray[i]);
}
return dynamicArray;
}

function test() public view returns (bool) {
assert(arraysEqual(localArrayDynamic(), arrayDynamic));
assert(arraysEqualHash(localArrayDynamic(), arrayDynamic));
}

// element by element comparison: inefficient for large arrays
function arraysEqual(uint[] memory a, uint[] memory b) public pure returns (bool) {
if (a.length != b.length) {
return false;
}
for (uint i = 0; i < a.length; i++) {
if (a[i] != b[i]) {
return false;
}
}
return true;
}

// hash comparison: this method is more efficient for large arrays
function arraysEqualHash(uint[] memory a, uint[] memory b) public pure returns (bool) {
return keccak256(abi.encode(a)) == keccak256(abi.encode(b));
}

}

In Solidity, comparing two arrays for equality (i.e., checking if they have the same elements in the same order) is not straightforward because there’s no built-in function for this. However, there are a few methods you can use:

  1. Element-by-Element Comparison: You can write a function that loops through each element in the arrays and compares them one by one, like in the function arraysEqual. This method is simple and straightforward, but it can be inefficient for large arrays.
  2. Hash Comparison: Another method is to hash each array using keccak256 and then compare the hashes, like in the function arraysEqualHash. This method is more efficient, especially for large arrays, but it requires converting the arrays to bytes, which can be complex for arrays of non-primitive types.

Memory arrays with dynamic length can be created using the new operator. As opposed to storage arrays, it is not possible to resize memory arrays (e.g. the .push member functions are not available). You either have to calculate the required size in advance or create a new memory array and copy every element.
For instance if we replace in the function localArrayDynamic the variable fixedArray with:

uint8[5] memory fixedArray = [1, 2, 3, 4, 5, 6];

It will throw an out of bound exception.

As all variables in Solidity, the elements of newly allocated arrays are always initialized with the default value.

Fixed size memory arrays cannot be assigned to dynamically-sized memory arrays that’s the reason why in the function localArrayDynamic we have used a for loop to convert a fixed size array into a dymanic one. In fact to initialize dynamically-sized arrays, we have to assign the individual elements:

// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.4.16 <0.9.0;

contract C {
function f() public pure {
uint[] memory x = new uint[](3);
x[0] = 1;
x[1] = 3;
x[2] = 4;
}
}

Methods .push() and .push(value) can be used to append a new element at the end of a dynamically-sized array, where .push() appends a zero-initialized element and returns a reference to it.

Array members:

  • length: Arrays have a length member that contains their number of elements. The length of memory arrays is fixed (but dynamic, i.e. it can depend on runtime parameters) once they are created.
  • push(): dynamic storage arrays and bytes (not string) have a member function called push() that you can use to append a zero-initialised element at the end of the array. It returns a reference to the element, so that it can be used like x.push().t = 2 or x.push() = b.
  • push(x): dynamic storage arrays and bytes (not string) have a member function called push(x) that you can use to append a given element at the end of the array. The function returns nothing.
  • pop(): dynamic storage arrays and bytes (not string) have a member function called pop() that you can use to remove an element from the end of the array. This also implicitly calls delete on the removed element. The function returns nothing.

In the following code we will demonstrate the array’s members:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.25;

contract Array {

uint[] private array;

constructor() {
// array initialization
for(uint i = 0; i < 6; i++) {
array.push(i);
}
}

function size() public view returns (uint) {
return array.length;
}

// Solidity can return the entire array.
// But this function should be avoided for
// arrays that can grow indefinitely in length.
function getAll() public view returns (uint[] memory) {
return array;
}

function get(uint _index) public view returns (uint) {
require(_index < array.length, "index out of bound");
return array[_index];
}

function append(uint _value) public {
// Appends the element to the end of the array
// This will increase the array length by 1.
array.push(_value);
}

function removeLastItem() public {
// Remove last element from array
// This will decrease the array length by 1
array.pop();
}

function remove(uint256 _index) public {
// Delete does not change the array length.
// It resets the value at index to it's default value,
// in this case 0
require(_index < array.length, "index out of bound");
delete array[_index];
}

function addAll(uint[] memory _array) public {
for (uint i = 0; i < _array.length - 1; i++) {
array.push(_array[i]);
}
}

function set(uint _index, uint element) public {
require(_index < array.length, "index out of bound");
array[_index] = element;
}

function isEmpty() public view returns (bool) {
return array.length == 0;
}

function test() public {
assert(size() == 6);
assert(arraysEqual(getTestArray(), getAll()));
assert(get(2) == 2);
assert(!isEmpty());

append(10);
assert(get(6) == 10);
assert(size() == 7);

removeLastItem();
assert(size() == 6);

remove(5);
assert(get(5) == 0);

set(5, 6);
assert(get(5) == 6);
}

function arraysEqual(uint[] memory a, uint[] memory b) public pure returns (bool) {
if (a.length != b.length) {
return false;
}
for (uint i = 0; i < a.length; i++) {
if (a[i] != b[i]) {
return false;
}
}
return true;
}

function getTestArray() public pure returns (uint[] memory) {
uint[] memory testArray = new uint[](6);
for(uint i = 0; i < 6; i++) {
testArray[i] = i;
}
return testArray;
}
}

Solidity also supports array slices:

Array slices provide a perspective on a continuous segment of an array. They are denoted as x[start:end], where both start and end are expressions that yield a uint256 type (or can be implicitly converted to it). The slice’s initial element is x[start], and its final element is x[end - 1].

An exception is raised if start exceeds end or if end surpasses the array’s length.

Both start and end are optional parameters: start is set to 0 by default, and end defaults to the array’s length.

I suggest you also to take a look at the Arrays utility library provided by OpenZeppelin which provides some useful methods for dealing with arrays.

Structs

In Solidity, a struct is a custom data type that allows you to group together multiple variables of different data types into a single unit. This feature is particularly useful for grouping related data.

Here’s an example of a simple struct in Solidity:

struct Book {
string name;
string writer;
uint id;
bool available;
}

In this example, Book is a struct that groups together a name and writer of type string, an id of type uint, and an available flag of type bool. Once the data types are grouped into a struct, the struct name represents the subsets of variables in it1.

Structs can be declared outside of a contract and imported into another contract. They are generally used to represent a record.

To access any element of the structure, the ‘dot operator’ is used, which separates the struct variable and the element we wish to access. For example, if you have a Book struct instance called myBook, you can access the name field with myBook.name.

Struct types can be used inside mappings and arrays and they can themselves contain mappings and arrays. For instance:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.25;

contract Todos {
struct Todo {
string text;
bool completed;
}

// An array of 'Todo' structs
Todo[] private todos;

constructor() {
// 3 ways to initialize a struct
// - calling it like a function
todos.push(Todo("Write the contract", false));

// key value mapping
todos.push(Todo({text: "Test the contract", completed: false}));

// initialize an empty struct and then update it
Todo memory todo;
todo.text = "Deploy the contract";
// todo.completed initialized to false

todos.push(todo);
}

function get(uint256 _index)
public
view
returns (Todo memory todo)
{
return todos[_index];
}

// update text
function updateText(uint256 _index, string memory _text) public {
Todo storage todo = todos[_index];
todo.text = _text;
}

// update completed
function toggleCompleted(uint256 _index) public {
Todo storage todo = todos[_index];
todo.completed = !todo.completed;
}

function test() public {
Todo memory todo0 = get(0);
assert(compareStrings(todo0.text, "Write the contract"));
assert(todo0.completed == false);

Todo memory todo1 = get(1);
assert(compareStrings(todo1.text, "Test the contract"));
assert(todo1.completed == false);

Todo memory todo2 = get(2);
assert(compareStrings(todo2.text, "Deploy the contract"));
assert(todo2.completed == false);

updateText(2, "Not deploying the contract");
toggleCompleted(2);
Todo memory todo2updated = get(2);
assert(compareStrings(todo2updated.text, "Not deploying the contract"));
assert(todo2updated.completed == true);

}

function compareStrings(string memory str1, string memory str2) public pure returns (bool) {
return keccak256(abi.encodePacked(str1)) == keccak256(abi.encodePacked(str2));
}
}

Mappings

In Solidity, mapping types are defined using the syntax mapping(KeyType KeyName? => ValueType ValueName?), and variables of mapping type are declared using the syntax mapping(KeyType KeyName? => ValueType ValueName?) VariableName.

  • The KeyType can be any built-in value type, bytes, string, or any contract or enum type. However, user-defined or complex types, such as mappings, structs or array types, are not permitted.
  • The ValueType can be any type, including mappings, arrays, and structs. Both KeyName and ValueName are optional, so mapping(KeyType => ValueType) is also valid, and they can be any valid identifier that is not a type.

Mappings can be thought of as hash tables, which are virtually initialized so that every possible key exists and is mapped to a value whose byte-representation is all zeros, which is a type’s default value. However, unlike hash tables, the key data is not stored in a mapping, only its keccak256 hash is used to look up the value.

It is neither possible to obtain a list of all keys of a mapping, nor a list of all values.

Mappings do not have a length or a concept of a key or value being set, and therefore cannot be erased without additional information about the assigned keys (refer to Clearing Mappings).

Mappings can only be located in storage and are thus allowed for state variables, as storage reference types in functions, or as parameters for library functions. They cannot be used as parameters or return parameters of contract functions that are publicly visible. This restriction also applies to arrays and structs that contain mappings.

State variables of mapping type can be marked as public, and Solidity will automatically create a getter for you.

Another thing to keep in mind is that mappings are not iterable. If we need to iterate over a mapping we need an IterableMapping. To demonstrate let’s take a look at the following example:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.25;

contract Mapping {
// Mapping from uint to string
mapping(uint => string) public myMap;

constructor() {
myMap[0] = "Hello";
myMap[1] = "World";
}

function get(uint _uint) public view returns (string memory) {
// Mapping always returns a value.
// If the value was never set, it will return the default value.
return myMap[_uint];
}

function set(uint _uint, string memory _str) public {
// Update the value at this string
myMap[_uint] = _str;
}

function remove(uint _uint) public {
// Reset the value to the default value.
delete myMap[_uint];
}

function test() public {
assert(compareStrings(get(0), "Hello"));
assert(compareStrings(get(1), "World"));

set(2, "How are you?");
assert(compareStrings(myMap[2], "How are you?"));

remove(2);
assert(compareStrings(myMap[2], ""));
}

function compareStrings(string memory str1, string memory str2) public pure returns (bool) {
return keccak256(abi.encodePacked(str1)) == keccak256(abi.encodePacked(str2));
}
}

OpenZeppelin’s Data Types

Solidity, as a language, encompasses a broad spectrum of data types. While it’s true that Solidity does not natively support decimal types such as floats or doubles, it also omits other complex structures like sets and queues. However, the OpenZeppelin library steps in to fill this gap with a suite of useful data types. Among these are:

  • DoubleEndedQueue: A versatile data structure allowing insertion and removal of elements from both ends.
  • EnumerableMap: Library for managing an enumerable variant of Solidity’s mapping.
  • EnumerableSet: Library for managing sets of primitive types.

These data types are part of the OpenZeppelin library’s utilities, designed to enhance Solidity’s capabilities and provide developers with the tools necessary for more complex data management.

Debugging with Hardhat

Hardhat provides the contractconsole.sol that could be useful to debug your code because it allows to print strings and bools in the console (Link at the documentation). For example, you could create a contract like this on Remix and invoke the test function to run your tests and print the results to the console:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.25;
import "hardhat/console.sol";
contract Booleans {
function test() public view {
bool bool1 = false;
bool notOp = !bool1;
console.log("Not Operator:", notOp);
}
}
// It prints out:
// console.log:
// Not Operator: true

However some data types like int and uint require a conversion to string to be printed out in the console and in the following sections you will find the proper functions to make such conversions.

Further Exploration

For those eager to dive into coding Solidity smart contracts, I recommend exploring the following resources:

For those who want to understand more about how the blockchain works I can recommend the following article:

Conclusions

I trust that this article has served as a valuable resource in your exploration of the diverse types offered by Solidity. Should you have any inquiries or come across any discrepancies, I encourage you to share your thoughts in the comments section. Here’s to your journey in coding, may it be filled with discovery and success!

References

--

--