Mappings, Structs & Enums
Work with complex data structures including mappings, custom structs, and enumerations.
Deep Dive into Mappings
Mappings are Solidity's hash table implementation and one of the most frequently used data structures in smart contracts. Declared as "mapping(KeyType => ValueType)", they provide constant-time O(1) lookups regardless of size. Unlike arrays, mappings do not have a length property and cannot be iterated — every possible key exists and maps to a default value (0 for integers, false for booleans, address(0) for addresses).
The key type must be a value type — uint, address, bytes32, bool, or an enum. Reference types like strings, arrays, and structs cannot be used as keys directly, but you can hash them first: "mapping(bytes32 => uint256)" where the key is "keccak256(abi.encodePacked(stringValue))". The value type can be anything, including other mappings, arrays, and structs, enabling complex nested data structures.
Nested mappings are extremely common. For ERC-20 token allowances, you see "mapping(address => mapping(address => uint256)) public allowances" where the first key is the token owner and the second is the spender. For a voting system with multiple proposals, you might use "mapping(uint256 => mapping(address => bool)) public hasVoted" to track which addresses have voted on which proposals.
Under the hood, mappings use keccak256 hashing to determine storage slot locations. The value for key k in a mapping at storage slot p is stored at keccak256(h(k) . p), where h is a padding function and . is concatenation. This means mapping data is scattered across the vast 2^256 storage space, making collisions statistically impossible. However, this also means you cannot enumerate mapping keys — if you need iteration, maintain a separate array of keys alongside the mapping.
Structs for Complex Data
Structs allow you to define custom composite types that group related data together, much like objects in JavaScript or structs in C. They are declared at the contract level or at the file level and can contain any type including mappings, arrays, and other structs.
A well-designed struct improves code readability and reduces errors. Consider a lending protocol: "struct Loan { address borrower; uint256 principal; uint256 interestRate; uint256 startTime; uint256 duration; bool isActive; mapping(uint256 => uint256) payments; }". This groups all loan-related data into a single logical unit. Note that structs containing mappings cannot be created in memory — they can only exist in storage.
When creating struct instances, you can use named arguments for clarity: "loans[loanId] = Loan({ borrower: msg.sender, principal: amount, interestRate: rate, startTime: block.timestamp, duration: 30 days, isActive: true })". For structs without mappings, you can also use positional arguments, but named arguments are preferred for readability and safety against parameter ordering mistakes.
Struct storage layout follows Solidity's packing rules. Variables smaller than 32 bytes are packed together into a single storage slot when possible. For example, "struct PackedData { uint128 amount; uint64 timestamp; bool active; uint8 status; }" fits into a single 32-byte storage slot because 128 + 64 + 8 + 8 = 208 bits, which is under 256. Ordering your struct fields by size — largest first — can significantly reduce storage costs. A struct with fields in the wrong order might use three slots instead of two, costing an additional 20,000 gas per write.
Enums and Practical Patterns
Enums define a type with a fixed set of named values, internally represented as uint8 (supporting up to 256 values). They make your code self-documenting and prevent invalid state assignments. For example: "enum OrderStatus { Created, Funded, Shipped, Delivered, Disputed, Cancelled }". Using an enum is clearer than using magic numbers like 0, 1, 2 that require documentation to understand.
Enums work naturally with structs and mappings: "struct Order { address buyer; address seller; uint256 amount; OrderStatus status; } mapping(uint256 => Order) public orders;". You can then write functions that check and transition between states: "function shipOrder(uint256 orderId) external { Order storage order = orders[orderId]; require(order.status == OrderStatus.Funded, 'Order not funded'); require(msg.sender == order.seller, 'Not seller'); order.status = OrderStatus.Shipped; }".
This pattern of using enums for state machines is fundamental in smart contract design. A state machine ensures that operations happen in the correct order — you cannot ship an order before it is funded, and you cannot dispute an order that has already been cancelled. Each state transition function validates the current state before allowing the change.
When combining mappings, structs, and enums, you can build sophisticated data models. Consider a decentralized marketplace: you might have "mapping(address => mapping(uint256 => Listing)) public sellerListings" with "struct Listing { string title; uint256 price; ListingStatus status; uint256 createdAt; }" and "enum ListingStatus { Active, Sold, Cancelled }". This three-type combination handles most data modeling needs in smart contracts. The key principle is to keep structs focused and enums exhaustive — every possible state should be represented, and invalid state transitions should be impossible through require checks.