Skip to main content

Someone wanted to learn this too, so Grasp built them a personal learning path.

You can keep exploring this one or create your own

Understanding Structs for Custom Data Types

Hello! Welcome to your third lesson in the "Advanced Solidity & Contract Logic" module.

In our last session, you learned how to use mappings to create highly efficient key-value stores. We saw how they are perfect for tracking a single piece of data per key, like a user's token balance. But what happens when you need to store a bundle of related information for each user? For instance, for your token, you might want to track not just a user's balance, but also when they first invested, and whether they have voting rights.

This is where today's topic comes in. Our goal is to define and use structs to create custom, complex data types. Structs are the answer to grouping related data, and when combined with mappings, they form one of the most powerful and common patterns in smart contract development.

For someone with your backend experience, this concept should feel quite natural. A struct in Solidity is analogous to a class in Java or Python (without the methods), or more closely, a type or interface in TypeScript. It's a way to define your own data structure.

What is a Struct?

A struct, short for structure, is a custom-defined data type that can group variables of different data types together under a single name. This is incredibly useful for a few reasons:

  • Organization: It keeps your code clean and readable by packaging related data together.
  • Complexity: It allows you to build complex data models that represent real-world objects or concepts.
  • Efficiency: It can help you get around the "stack too deep" error, which occurs when a function has too many local variables (more than 16), by grouping them into a single struct.

To see this in action, let's start with a video that introduces the basic syntax and demonstrates how to create and use your first struct.

Master Solidity Variables, Data Types, and Structs Master Solidity [#2]

This video from Dapp University provides a great initial walkthrough. It will show you how to define a simple struct, create an instance of it as a state variable, and then interact with it after deploying the contract.

Watch from 10:40 to 15:33. Focus on the syntax for defining the struct and then how it's instantiated and accessed like any other state variable.

As you saw, the basic syntax is straightforward:

// Define a new custom data type called 'Book'
struct Book {
    string title;
    string author;
    uint pageCount;
    bool isAvailable;
}

// Create a state variable instance of our new type
Book public myFavoriteBook = Book("The Hitchhiker's Guide", "Douglas Adams", 224, true);

The Power Combo: Mappings of Structs

While a single struct instance is useful, the true power of structs is unlocked when you combine them with mappings. Instead of mapping an address to a simple uint balance, you can map an address to a complex struct containing a wealth of information.

This mapping(address => YourStruct) pattern is the backbone of countless smart contracts. It lets you create a database-like structure where each user address points to its own unique data object.

Let's watch a video that builds this exact pattern step-by-step.

06. Solidity Mappings & Structs Tutorial

This video from DesignCourse clearly demonstrates the mapping of structs pattern. It walks through defining a struct, creating a mapping to it, and writing functions to set and retrieve data.

Watch the following segments to see the full lifecycle: 1:34 - 2:22: Defining a struct to hold data. 2:22 - 3:23: Creating the mapping that links an address to the struct. 4:22 - 6:50: Writing a function to add a new struct instance to the mapping. 8:26 - 10:33: Writing a function to retrieve the data from the mapping using an address.

Let's apply this to our token project. Imagine we want to store details about each person who invests in our token.

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

contract TokenInvestorData {

    // 1. Define the struct
    struct Investor {
        uint tokenBalance;
        uint lastInvestmentTimestamp;
        bool isWhitelisted;
    }

    // 2. Create a mapping from an address to the struct
    mapping(address => Investor) public investors;

    // 3. Write a function to add/update an investor's data
    function recordInvestment(uint amount) public {
        // Create a storage pointer to the struct for the calling address
        Investor storage investor = investors[msg.sender];

        // Update the fields of the struct
        investor.tokenBalance += amount;
        investor.lastInvestmentTimestamp = block.timestamp;
        investor.isWhitelisted = true; // Maybe investing automatically whitelists them
    }

    // Solidity automatically creates a getter for the 'investors' mapping.
    // We can also create a custom getter for convenience.
    function getInvestorBalance(address _investor) public view returns (uint) {
        return investors[_investor].tokenBalance;
    }
}

A quick note on storage vs. memory:
In the recordInvestment function, you see the line Investor storage investor = .... The storage keyword is crucial here. It tells Solidity that the investor variable is not a new copy of the data, but a pointer to the data that already exists in the contract's storage. When you modify investor.tokenBalance, you are directly modifying the data on the blockchain. If you used memory, you would only be changing a temporary copy, and the state on the blockchain would remain unchanged.

Advanced Struct Patterns and Use Cases

Structs can be nested within each other, and you can even have mappings inside a struct. This allows for very sophisticated data modeling.

The following article provides a fantastic, in-depth example of a contract that uses these advanced patterns to track user deposits and withdrawals.

Solidity Structs

To solidify your understanding, let's look at a more complex and realistic implementation. This article presents a full smart contract that uses nested structs and even a mapping inside a struct.

Read through the entire page. Pay close attention to: The MappingsStructExample contract. Notice how the Balance struct contains a Transaction struct via a mapping. The explanation of how structs are initialized with default values (0 for uint, false for bool, etc.). The section 'Balance <-> Transaction relationship'. It explains the critical pattern of using a helper variable (numDeposits) to track the number of entries in the nested mapping, since mappings themselves are not iterable.

Test your understanding!

You are building a simple token vesting contract. For each beneficiary, you need to track several pieces of information:

  • The total amount of tokens they are entitled to (totalAllocation).
  • The amount they have already withdrawn (released).
  • A boolean isVesting to indicate if they have an active schedule.

How would you define a struct named VestingSchedule and a public mapping named schedules to store this data for each beneficiary's address?

Show answer

Here is the correct implementation:

// 1. Define the struct with the required fields
struct VestingSchedule {
    uint totalAllocation;
    uint released;
    bool isVesting;
}

// 2. Create the public mapping
// The key is the beneficiary's address, and the value is their vesting schedule
mapping(address => VestingSchedule) public schedules;

With this structure, you could access a beneficiary's data like this: schedules[beneficiaryAddress].totalAllocation.

Conclusion

You've now learned how to create your own custom data types using structs. This is a massive step up from working with just primitive types. By combining structs with mappings, you can model almost any data relationship you need for your smart contracts.

Key Takeaways:

  • Structs group related variables into a single, custom data type.
  • The most common and powerful pattern is mapping(address => MyStruct), which allows you to associate a complex data object with each user.
  • You access and modify struct members using dot notation (e.g., investors[addr].tokenBalance).
  • When modifying a struct that's stored in a mapping, use a storage pointer to ensure you're changing the data on the blockchain, not a temporary copy.
  • You can nest structs and even place mappings inside them, but remember that mappings are not iterable, so you'll need counters to track entries.

Preview of the Next Lesson:
We just saw how the example contract used a mapping and a counter (numDeposits) to keep track of a collection of transactions. This is one way to handle lists of data. In our next lesson, we'll explore Solidity's native solution for ordered collections: arrays. We'll cover how to work with both fixed-size and dynamic arrays and compare their use cases and gas costs to mappings.

Can't find a good explanation? Sign up and we'll make it for you

Sign up