Control Flow: If/Else, Loops & Error Handling
Explore conditional logic, loop constructs, and error handling with require, assert, and revert.
Conditional Logic and Loops
Solidity supports standard conditional statements with if, else if, and else blocks. The syntax is identical to JavaScript: "if (balance > 100) { doSomething(); } else if (balance > 0) { doSomethingElse(); } else { revert('No balance'); }". The ternary operator also works: "uint256 fee = amount > 1000 ? highFee : lowFee".
For loops come in three forms: for, while, and do-while. The for loop is most common: "for (uint256 i = 0; i < array.length; i++) { process(array[i]); }". A critical optimization is caching the array length outside the loop: "uint256 len = array.length; for (uint256 i = 0; i < len; i++) { ... }". Reading array.length from storage on every iteration costs approximately 100 extra gas per iteration.
Loops in Solidity carry a unique risk not found in traditional programming: unbounded loops can exceed the block gas limit, causing the transaction to fail. If your loop iterates over a dynamically growing array, it may work fine with ten elements but fail completely with ten thousand. This is why experienced Solidity developers prefer pull patterns over push patterns — instead of looping through all users to distribute rewards, let each user call a function to claim their individual reward.
The break and continue statements work as expected — break exits the loop entirely, and continue skips to the next iteration. However, use them sparingly in smart contracts. Complex control flow makes code harder to audit and reason about, increasing the risk of security vulnerabilities.
Error Handling with require, assert, and revert
Error handling in Solidity is fundamentally different from try-catch in traditional languages. When an error occurs in a smart contract, the entire transaction is reverted — all state changes are undone and remaining gas is returned to the caller (except gas already consumed). Solidity provides three mechanisms for triggering reverts: require, assert, and revert.
The "require" function is used for input validation and precondition checking. It takes a condition and an optional error message: "require(msg.sender == owner, 'Only owner can call')". If the condition is false, the transaction reverts with the provided message. Use require at the beginning of functions to validate that callers meet the necessary conditions before any state changes occur. Since Solidity 0.8.4, you can use custom errors with require for gas savings.
The "assert" function is used for invariant checking — conditions that should never be false if your code is correct. For example: "assert(totalSupply == sumOfAllBalances)". Unlike require, a failed assert indicates a bug in your contract. In older Solidity versions, assert consumed all remaining gas, but since 0.8.0, it also returns remaining gas. Use assert sparingly and only for conditions that represent actual bugs.
The "revert" statement unconditionally reverts the transaction: "revert('Operation not allowed')". Since Solidity 0.8.4, custom errors provide a gas-efficient alternative. You define errors at the contract level: "error InsufficientBalance(uint256 requested, uint256 available)" and use them with revert: "revert InsufficientBalance(amount, balance)". Custom errors are encoded more efficiently than string messages, saving approximately 50 gas per character compared to require with a string. They also enable structured error data that front-end applications can parse programmatically.
Try-Catch for External Calls
Solidity does support a try-catch mechanism, but exclusively for external function calls and contract creation. You cannot use try-catch for internal function calls. This pattern is essential when your contract interacts with other contracts whose behavior you cannot guarantee.
The syntax wraps an external call: "try externalContract.someFunction(args) returns (uint256 result) { // success logic } catch Error(string memory reason) { // handle revert with reason } catch (bytes memory lowLevelData) { // handle low-level errors }". The first catch block handles reverts that include a reason string from require statements. The second catch block handles assert failures, custom errors, and other low-level failures.
A common use case is token transfers. When your contract sends ERC-20 tokens, the receiving contract might revert for various reasons. Wrapping the call in try-catch allows your contract to handle the failure gracefully instead of reverting the entire transaction. For example, a batch airdrop contract might skip failed recipients rather than failing the entire batch.
Try-catch is also used during contract creation with the "new" keyword: "try new ChildContract(args) returns (ChildContract child) { // use child } catch { // handle creation failure }". This is valuable in factory patterns where you create multiple contracts and need to handle individual creation failures.
Be cautious with try-catch around external calls. If the external call makes state changes before reverting, those changes are rolled back, but state changes in your own contract before the try block are preserved. This asymmetry can lead to inconsistent state if you are not careful about ordering your operations. Always follow the checks-effects-interactions pattern even when using try-catch.