Introduction to Solidity : Value Types

Solidity is a statically typed language, which means that the type of each variable (state and local) needs to be specified at compile time. The compiler will throw an error if the type of the variable does not match the type of the value it is assigned. Solidity provides several elementary types which can be combined to form complex types.

This are called "Value types" because their variables will always be passed as value. This means that the value of the variable will be copied to the function call and the original variable will not be modified.

Let's see the different value types :

Boolean

The boolean type bool has only two values: true and false.

It can be used with the logical operators ! (not), && (and) and || (or) and the comparison operators == (equal) and != (not equal).

bool a = true;
bool b = false;
bool c = a && b; // false
bool d = a || b; // true
bool e = !a; // false
bool f = a == b; // false
bool g = a != b; // true

Integer

The integer types int and uint are used for signed and unsigned integers respectively. They are of variable length, but at least 256 bits long. The length of an integer can be specified by an integer number of bits, e.g. int8 or uint256.

It can be used with the arithmetic operators +, -, *, / and % and the comparison operators == (equal), != (not equal), < (smaller), > (greater), <= (smaller or equal) and >= (greater or equal).

int a = 1;
int b = 2;
int c = a + b; // 3
int d = a - b; // -1
int e = a * b; // 2
int f = a / b; // 0
int g = a % b; // 1
int h = a ** b; // 1

There's also the bit operators and the shift operators.

  • & (AND): Performs a bitwise AND operation between two values. Each bit in the result is set to 1 only if both corresponding bits in the input values are 1.

  • | (OR): Performs a bitwise OR operation between two values. Each bit in the result is set to 1 if at least one of the corresponding bits in the input values is 1.

  • ^ (XOR): Performs a bitwise XOR operation between two values. Each bit in the result is set to 1 only if the corresponding bits in the input values are different.

  • ~ (NOT): Performs a bitwise NOT operation on a value. Each bit in the result is flipped (i.e., 0 becomes 1 and 1 becomes 0).

  • << (left shift): Shifts the bits of a value to the left by a specified number of positions. This is equivalent to multiplying the value by 2 to the power of the shift amount.

  • >> (right shift): Shifts the bits of a value to the right by a specified number of positions. This is equivalent to dividing the value by 2 to the power of the shift amount.

uint8 a = 0b10101010; // Binary value of 170
uint8 b = 0b11001100; // Binary value of 204

uint8 c = a & b; // Performs a bitwise AND operation between a and b
// Binary value of c is 0b10001000 (decimal value of 136)

uint8 d = a | b; // Performs a bitwise OR operation between a and b
// Binary value of d is 0b11101110 (decimal value of 238)

uint8 e = a ^ b; // Performs a bitwise XOR operation between a and b
// Binary value of e is 0b01100110 (decimal value of 102)

uint8 f = ~a; // Performs a bitwise NOT operation on a
// Binary value of f is 0b01010101 (decimal value of 85)

uint8 g = a << 2; // Shifts the bits of a to the left by 2 positions
// Binary value of g is 0b10101000 (decimal value of 168)

uint8 h = b >> 3; // Shifts the bits of b to the right by 3 positions
// Binary value of h is 0b00011001 (decimal value of 25)

Arithmetic operators such as addition, subtraction, multiplication, works with two differents modes : checked and unchecked. The default mode is checked and it will throw an exception if the operation overflows or underflow. The unchecked mode will not throw an exception.

uint8 x = 255;
uint8 y = 2;
uint8 z = x + y; // This will throw an exception, because the result (257) is outside the range of uint8

If we want to use the unchecked mode, we can use the unchecked block. This will result in wrapping arithmetic.

uint8 x = 255;
uint8 y = 2;
unchecked {
    uint8 z = x + y; // This will result in wrapping arithmetic, and z will be 1
}

This can be useful in situations where you want to perform modular arithmetic or where you are dealing with circular ranges or finite fields

For example, consider the case of implementing a simple counter that wraps around when it reaches a certain limit. Using wrapping arithmetic, we can implement this as follows:

uint8 counter = 0;

function increment() public {
    counter = counter + 1;
}

If we use checked arithmetic here, the program would throw an exception when counter reaches its maximum value, and the counter would be stuck at that value. However, if we use wrapping arithmetic, the counter would wrap around and start back at zero, effectively giving us a simple way to implement a counter that cycles through a range of values.

Address

Address can be one of the following types:

  • address: a 20 byte value (size of an Ethereum address).
  • address payable: that is the same of address but with the additional member transfer and send.

The distinction between the two types is that address payable can receive Ether, while address cannot. In genera your are not supposed to send ether to an address because it can be a smart contract that wasn't able to accept ether for exemple.

You can implecitly convert an address payable to an address but not the other way around. The other you have to make it explicit.

address payable a = 0x1234567890123456789012345678901234567890;
address b = a; // This is implicit
address c = address(a); // This is explicit
address e = payable(c) // This is explicit

Address have many members (you see the full list here) :

Here are some of the most useful :

  • balance: returns the balance of the address in wei.
  • transfer: send ether to the address. It will throw an exception if the transfer failed.

Bytes arrays

There are two types of bytes arrays :

  • bytes: a dynamic bytes array.
  • bytes1 to bytes32: fixed bytes array.

The difference between the two is that bytes is dynamic and can be resized while bytes1 to bytes32 are fixed and cannot be resized.

pragma solidity ^0.8.0;

contract ByteArrayExample {
    // Example of a fixed-size byte array
    bytes3 public myFixedArray = "abc";

    // Example of a dynamically-sized byte array
    bytes public myDynamicArray;

    function addDataToDynamicArray(bytes memory data) public {
        myDynamicArray.push(data);
    }
}

If you come from JS like me, you may ask yourself why we use bytes3 public myFixedArray = "abc" instead of a string. It's because in Solidity, a string is a dynamically-sized UTF-8 encoded string. It is useful for storing text data, but it can be expensive in terms of gas costs because of the way it is stored and manipulated.

On the other hand, a bytes type is a dynamically-sized byte array, and a bytesN type is a fixed-size byte array. These types are useful for storing binary data, such as hash values or other non-textual data.

However, it is important to note that using bytes and bytesN types may require extra care when handling encoding and decoding of text data, since these types are not specifically designed for text storage. So, it's important to weigh the trade-offs between using bytes types and string types, depending on the specific use case.

Enums

Enums are a way to define a set of named constants. They are useful when you want to define a set of named values that are not related to each other.

They are explicitly convertible to and from all integer types but implicit conversion is not allowed. They require at least one member and can have a maximum of 256 members. This default value when declared is the first member.

You can get the smallest and the biggest value of an enum with type(MyEnum).min and type(MyEnum).max.

pragma solidity ^0.8.0;

contract ExampleEnum {

    enum Color { RED, GREEN, BLUE }

    Color color = Color.BLUE;

    function setColor(Color newColor) public {
        color = newColor;
    }

    function getColor() public view returns (Color) {
        return color;
    }

    function getMinColorValue() public pure returns (uint) {
        return uint(type(Color).min);
    }

    function getMaxColorValue() public pure returns (uint) {
        return uint(type(Color).max);
    }
}

User defined value types

A user-defined value type allows creating a zero cost abstraction over an elementary value type. This is similar to an alias, but with stricter type requirements.

It can be defined by using type C is V, where C is the name of the newly introduced type and V is a built-in value type, also known as the underlying type.

Here is an example of a user defined value type :

type MyUint is uint;

Once the new type is defined, you can use the functions C.wrap and C.unwrap to convert between the custom type and the underlying type.

uint x = 42;
MyUint y = MyUint.wrap(x);
uint z = MyUint.unwrap(y);

Function types

A function type is a type that represents a function.

function(uint, uint) pure internal returns (uint) f;

They come with two flavours :

  • internal: the function can only be called from inside the current contract.
  • external: the function can only be called from outside the current contract.

If you need to have a function that can be called from inside and outside the contract, you can use the public keyword.

Here's a summary of the differences between public, private, external, and internal functions in Solidity:

public: can be called from within the contract, externally, or via derived contracts. When a function is marked as public, the compiler generates a getter function that allows access to the state variable associated with the function.

private: can only be called from within the contract. Private functions are not part of the contract's external interface, so they cannot be called from outside the contract.

external: can only be called externally (i.e., from outside the contract). It is similar to a public function, but it does not generate a getter function.

internal: can only be called from within the contract and derived contracts. Internal functions are not part of the contract's external interface, so they cannot be called from outside the contract.

Function types are notated as follow :

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

function doSomething() public pure returns (uint)
function getBalance() public view returns (uint)
function receivePayment() public payable
function myFunction() external public returns (uint)

Return types cannot be empty. If the function does not return anything, the whole returns (<return types>) should be omitted.

The function are by default internal.

The different types of a function are :

  • pure: the function does not read from or modify the state.
  • view: the function does not modify the state.
  • payable: the function can receive ether.

A pure function is a function that does not read or modify the state of the contract, meaning that it has no side effects. It only returns a value computed from the input arguments. A pure function cannot modify the contract storage, emit events, call other functions that are not also pure, or use Ether. It can be called without requiring a transaction and without incurring gas fees.

A view function is similar to a pure function, in that it also does not modify the contract storage or state, but it can read from the storage. view functions can be used to inspect and return data from the current state of the contract. They can also be called without requiring a transaction and without incurring gas fees.

A payable function is a function that can receive Ether as part of the function call. This means that the function can either send or receive Ether, or both. In order to send Ether to a contract function, the function must be marked as payable. payable functions can also read from the contract state or modify the contract storage.

Conclusion

Here is the end for Value Types. If you want to learn more about, you can check out the Solidity documentation. I hope you enjoyed this article and helped you to understand the basics of Solidity.