Solidity Conversions of Elementary Types

5/5 - (2 votes)

In this article, we’ll entertain ourselves with conversions between elementary types in Solidity. We’ll mention implicit and explicit conversions, as well as conversions between literals.

It’s part of our long-standing tradition to make this (and other) articles a faithful companion, or a supplement to the official Solidity documentation, for this article’s topic.

Solidity Conversions of Elementary Types

Implicit Conversions

The compiler performs implicit conversions in three scenarios:

  1. in some cases during variable assignments;
  2. while arguments are being passed to functions; and
  3. when operators are applied.

In the general case, an implicit conversion between value types can be done if there’s a semantical sensibility and no loss of information can occur during the procedure.

Note: Semantic is interpreted as “of or relating to meaning or arising from distinctions between the meanings of different words or symbols” (source).

One such example of a sensible conversion is from a shorter (un)signed integer to a longer signed integer, e.g. uint8/int8 to int128.

πŸ’‘ However, a conversion from int8 to uint128 is not sensible because an unsigned integer type cannot hold negative values, regardless of the unsigned type size, so a forceful conversion would lead to loss of information, specifically, the negative sign.

When an operator should be applied to different types of operands, the compiler will implicitly try converting one of the operands to the type of the other operand, and this will also be attempted with assignments.

In other words, a type of one of the operands is selected, and then the operations are performed in that type. What implicit conversions are possible depends on a specific type.

In one such example, variables y and z are the operands of the addition operation. Since they don’t have the same type, a direction of conversion must be determined.

The variable y is of type uint8 and it is implicitly convertible to type uint16 of variable z, but not the other way around, because of the possible loss of information (reduction of value range between the types).

Therefore, variable y is converted to the type of variable z, and the addition operation is performed in the uint16 type. The result of the addition operation, i.e. y + z is of type uint16.

Finally, the result is assigned to a variable of type uint32, so another implicit conversion is performed to that type.

uint8 y;
uint16 z;
uint32 x = y + z;

Explicit Conversions

We now know how to recognize cases when implicit conversion is performed. However, there are cases when the compiler won’t allow implicit conversion, but if we’re certain that a conversion can be done, we can explicitly perform it.

⚑ Warning: explicit conversions may potentially result in unexpected behavior and also allow us to bypass the security features of the compiler. We have to thoroughly test the conversion result and be absolutely sure that it matches our expectations.

The following example illustrates what happens when we convert a negative integer to an unsigned integer:

int  y = -3;
uint x = uint(y);

When this code executes, x will have the value 0xfffff..fd (64 hex characters), i.e. -3 represented as two’s complement of 256 bits.

πŸ—’οΈ Note: Two’s complement is a way computers deal with integers. It consists of two simple steps: the inversion of the binary form of the integer and the addition of 1 (docs).

When we explicitly convert an integer to a smaller type, higher-order bits are trimmed:

uint32 a = 0x12345678;
uint16 b = uint16(a); // becomes 0x5678

When an integer is explicitly converted to a larger type, left padding is applied, i.e. at the higher-order end.

However, by comparing the result of this conversion, we’ll notice it is equal to the original integer, e.g.:

uint16 a = 0x1234;
uint32 b = uint32(a); // b will be 0x00001234 now
assert(a == b);

Fixed-size Byte Conversions

The fixed-size byte conversion works in contrast to the integer-type conversion, i.e. when a smaller type is converted to a larger type, it is padded on the right side (as opposed to left-padded integer types).

We’ll notice that accessing the byte at the same index at a fixed-size byte array will get us the same result before and after the conversion, given that the index is still in range after the conversion:

bytes2 a = 0x1234;
bytes4 b = bytes4(a); // b will be 0x12340000
assert(a[0] == b[0]);
assert(a[1] == b[1]);

Converting a larger type to a smaller type will lead to trimming of the byte sequence on the lower-ordered bytes (as opposed to trimming of the higher-ordered bytes in integer types conversion):

bytes2 a = 0x1234;
bytes1 b = bytes1(a); // b will be 0x12

This contrasting behavior between integers and fixed-size byte arrays with regard to trimming (truncating, cutting off) and padding makes explicit conversions between them allowed only if they are of the same size.

In other cases, such as when we’re converting between integers and fixed-size byte arrays with different sizes, we’d have to use intermediate conversions to explicitly apply the desired trimming and padding effects.

bytes2 a = 0x1234;

Changes the type since the sizes fit: bytes2 β†’ uint16, and then extends the size: uint16 β†’ uint32.

uint32 b = uint16(a); // b will be 0x00001234

Extends the size: bytes2 β†’ bytes4 and then changes the type: bytes4 β†’ uint32.

uint32 c = uint32(bytes4(a)); // c will be 0x12340000

Changes the type since the sizes fit: bytes2 β†’ uint16, and then reduces the size: uint16 β†’ uint8.

uint8 d = uint8(uint16(a)); // d will be 0x34

Changes the size since the types fit: bytes2 β†’ bytes1, and then changes the type: bytes1 β†’ uint8.

uint8 e = uint8(bytes1(a)); // e will be 0x12

We can also explicitly convert bytes arrays and bytes calldata slices to fixed bytes1..32 types. If the array is longer than the target fixed bytes type, it will be trimmed on the right, and if the array is shorter, it will be padded on the right side with zeros, as in the example:

// SPDX-License-Identifier: GPL-3.0
pragma solidity ^0.8.5;

contract C {
    bytes s = "abcdefgh";
    function f(bytes calldata c, bytes memory m) public view returns (bytes16, bytes3) {
        require(c.length == 16, "");
        bytes16 b = bytes16(m);  // if length of m is greater than 16, truncation will happen
        b = bytes16(s);  // padded on the right, so result is "abcdefgh\0\0\0\0\0\0\0\0"
        bytes3 b1 = bytes3(s); // truncated, b1 equals to "abc"
        b = bytes16(c[:8]);  // also padded with zeros
        return (b, b1);
    }
}

Conversions between Literals and Elementary Types

Integer Types

We can use implicit conversion of decimal and hexadecimal number literals to any integer type, but the integer type must be large enough to avoid truncation while representing the literal, e.g.

Requires a uint of at least 8 bits, i.e. 2 bits per character.

uint8 a = 12; // fine

Requires an uint of at least 16 bits, i.e., 2 bits per character.

uint32 b = 1234; // fine

Requires an uint of at least 24 bits, i.e. 2 bits per character. uint16 is therefore not enough and truncates the result.

uint16 c = 0x123456; // fails, since it would have to truncate to 0x3456

Fixed-Size Byte Arrays

We cannot implicitly convert decimal number literals to fixed-size byte arrays.

We can do so with hexadecimal number literals, but with the same limitation as shown in the section above, i.e., only if the number of hex digits precisely fits the size of the bytesN type.

Exceptionally, decimal and hexadecimal literals with a value of zero, because they can be converted to any fixed-size bytesN type.

Decimal literal cannot be converted to a fixed-size array.

bytes2 a = 54321;

Hexadecimal literal, but only one byte in length. A precise match in length is required.

bytes2 b = 0x12; // not allowed

Hexadecimal literal, but only one and a half bytes in length (12 bits). A precise match in length is required.

bytes2 c = 0x123; // not allowed

Hexadecimal literal with two bytes in length (16 bits). A precise match in length is achieved.

bytes2 d = 0x1234; // fine

Hexadecimal literal with two bytes in length (16 bits). A precise match in length is achieved.

bytes2 e = 0x0012; // fine

Hexadecimal literal with zero value can be converted to any fixed-size length.

bytes4 f = 0; // fine

Hexadecimal literal with zero value can be converted to any fixed-size length.

bytes4 g = 0x0; // fine

We can also implicitly convert string literals and hexadecimal strings to fixed-size byte arrays with the same condition as above, i.e. the number of characters must precisely match the size of the fixed-size byte array.

Hexadecimal string literal with two bytes in length (16 bits). A precise match in length is achieved.

bytes2 a = hex"1234"; // fine

String literal with two bytes (one byte per character) in length (16 bits). A precise match in length is achieved.

bytes2 b = "xy"; // fine

Hexadecimal string literal with one byte in length (8 bits). A precise match in length is not achieved.

bytes2 c = hex"12"; // not allowed

Hexadecimal string literal with one and a half bytes in length (12 bits). A precise match in length is not achieved.

bytes2 d = hex"123"; // not allowed

String literal with one byte in length (8 bits). A precise match in length is not achieved.

bytes2 e = "x"; // not allowed

String literal with three bytes in length (24 bits). A precise match in length is not achieved.

bytes2 f = "xyz"; // not allowed

Addresses

In one of the previous articles on Address Literals, we mentioned how hexadecimal literals must have the correct size and pass the checksum test to be implicitly converted to the address type. No other form of literal can be implicitly converted to the address type.

However, we can explicitly convert from either bytes20, or any other integer type, to the address type, but such conversion would result in address payable.

Finally, we can convert any address adr to address payable by using the cast operator payable(a).

Conclusion

In this article, we learned about conversions of elementary data types in Solidity, touching on implicit and explicit conversions, but also on conversions between literal types.

First, we familiarized ourselves with what implicit conversions are and how they do their magic in terms of how conversion direction is determined and how are types automatically converted from one to another.

Second, we learned about explicit conversion in terms of what should we take care of when using it, how to apply it, and what results to expect, especially when working with fixed-size byte conversion.

Third, we found out what conversions between literals are, and went through specifics on literal conversion regarding integer types, fixed-size byte arrays, and addresses.

What’s Next?

This tutorial is part of our extended Solidity documentation with videos and more accessible examples and explanations. You can navigate the series here (all links open in a new tab):