User-Defined Value Types in Solidity

5/5 - (3 votes)
User-Defined Value Types in Solidity

In this article, we’ll learn about user-defined types in Solidity. It’s a somewhat shorter and simple article, but it’ll provide us with insight into how we can utilize user-defined value types as means of simple abstractions.

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 topics.

User Defined Value Types

We can find that a user-defined value type lets us create a zero-cost abstraction by using an elementary value type. This approach reminds us of an alias, however, there are stricter type requirements involved.

A user-defined value type is a construct expressed as type new_type is underlying_type, where new_type stands for the name of the newly introduced type, while the underlying_type must be one of the built-in value types, also known as the underlying type.

Then, the new_type .wrap() function is available for conversion of the underlying type to the custom type. In the opposite direction, the function new_type.unwrap() is available for conversion from the newly introduced type to the underlying type.

The new_type type has no operators, not even the equality operator ==, and doesn’t contain any bound member functions. Even more, talking about strict requirements, explicit and implicit conversion in either direction, i.e., to and from other types are also not allowed.

Regarding ABI, newly introduced types inherit the data representation from their underlying type, and the underlying type is freely used in the ABI.

A short prelude of the following example: if we take an integer 123 and want to simulate three decimal places while keeping 123 as an integer, we would just multiply it by 1000 (to add three more digits) and treat the newly added digits as decimal places.

Let’s look at the example that shows a custom type UFixed256x18, which represents a decimal fixed point type with 18 decimals.

We also use a small library for arithmetic operations on the UFixed256x18 type. We’ll notice that these 18 decimals are not really there, it’s just that the original integer is multiplied by 1018 to simulate the decimal places with the 18 newly added (least significant, rightmost) digits.

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

// Represent an 18-decimal, 256 bit wide fixed point type using 
// a user defined value type.

Declares a custom data type.

type UFixed256x18 is uint256;

/// A minimal library to do fixed point operations on UFixed256x18.

Declares a custom library for our simulated data type.

library FixedMath {

Multiplying by the multiplier will simulate the use of 18 decimal places.

    uint constant multiplier = 10**18;

    /// Adds two UFixed256x18 numbers. Reverts on overflow, 
    /// relying on checked arithmetic on uint256.

Remember, the arithmetic operation defaults to a checked mode, so overflows aren’t allowed, unless the unchecked block is used.

    function add(UFixed256x18 a, UFixed256x18 b) internal pure returns (UFixed256x18) {

First, we unwrap the custom-typed parameters a and b, then add them together because the addition operator is defined for the uint256 type; then we wrap the result back to our custom type.

The function is declared as pure because it doesn’t read from the environment or the state variables.

        return UFixed256x18.wrap(UFixed256x18.unwrap(a) + UFixed256x18.unwrap(b));
    }

    /// Multiplies UFixed256x18 and uint256. Reverts on overflow, 
    /// relying on checked arithmetic on uint256.

First, we unwrap the custom-typed parameter a, then multiply it by b because the multiplication operator is defined for uint256 type; then we wrap the result back to our custom type. The function is declared as pure because it doesn’t read from the environment or the state variables.

    function mul(UFixed256x18 a, uint256 b) internal pure returns (UFixed256x18) {
        return UFixed256x18.wrap(UFixed256x18.unwrap(a) * b);
    }

    /// Take the floor of a UFixed256x18 number.
    /// @return the largest integer that does not exceed `a`.

First, we unwrap the custom-typed parameter a, then divide it by b because the division operator is defined for uint256 type. The function is declared as pure because it doesn’t read from the environment or the state variables.

    function floor(UFixed256x18 a) internal pure returns (uint256) {
        return UFixed256x18.unwrap(a) / multiplier;
    }

    /// Turns a uint256 into a UFixed256x18 of the same value.
    /// Reverts if the integer is too large.

First, we multiply the uint256-typed parameter by the multiplier because the multiplication operator is defined for uint256 type.

We wrap the result of multiplication because we’re converting the result to our custom type with 18 decimal places (simulated). The function is declared as pure because it doesn’t read from the environment or the state variables.

    function toUFixed256x18(uint256 a) internal pure returns (UFixed256x18) {
        return UFixed256x18.wrap(a * multiplier);
    }
}

πŸ’‘ Note: Functions UFixed256x18.wrap(...) and FixedMath.toUFixed256x18(...) have the same signature (both have the same number and type of input and output parameters) but perform two distinctive operations.

The UFixed256x18.wrap(...) function returns a UFixed256x18-typed result that has the same data (binary) representation as the input parameter; the input and output parameters are identical.

On the other hand, toUFixed256x18(...) function returns a UFixed256x18-typed result that has the same numerical value as the input parameter (the same case as with 123 and 123000 in the prelude above), but a different data representation.

Conclusion

In this article, we peeked behind the curtains of the show called user-defined value types.


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):