What are custom errors in Solidity?

Custom errors in Solidity are a language mechanism to inform the caller of a function in Solidity about the occurrence of a specific type of error. They complement throwing strings in exceptions for error communication using “revert” and “require” in a more efficient manner:

  • They allow defining error causes in a structured way, for example in combination with enumerated data types to declare error codes.
  • They reduce gas costs compared to using strings, both in contract deployment and invocation.

What is the syntax for custom errors in Solidity?

They are defined with the keyword “error”, support parameters similar to events, and can be declared both inside and outside of conventional contracts, interfaces, and even libraries.

The technical details can be found on using custom errors in Solidity.

How are custom errors used in the decentralized application?

A common use case in the decentralized application involves propagating an error from a library to the calling contract and then to the outside, reverting the transaction. A variant of this use case would be the direct propagation of an error from a function in a contract to the outside.

This is illustrated in the following code snippet, where the library function “setMilestone” called from another contract would revert the transaction. It can be observed in combination with an enumerated type to inform the calling code about the particular cause of the error:

// Error details thrown within CryptoTaskLibraryError
enum CryptoTaskLibraryErrorDetail {
    EMPTY_DESCRIPTION,
    DESCRIPTION_TOO_LONG
}  

library CryptoTaskLibrary  {       
    
    // Custom error to revert when some error 
	// condition happens
    error CryptoTaskLibraryError(CryptoTaskLibraryErrorDetail details);
	
	uint8 constant DESCRIPTION_MAX_LENGTH = 100;
    
    function setMilestone(Milestone calldata milestone, 
						    Milestone[] storage _milestones) external {        
       
		if(bytes(milestone.description).length == 0)
            revert CryptoTaskLibraryError(CryptoTaskLibraryErrorDetail.
						EMPTY_DESCRIPTION);      
			
		if(bytes(milestone.description).length > DESCRIPTION_MAX_LENGTH)
			revert CryptoTaskLibraryError(CryptoTaskLibraryErrorDetail.
						DESCRIPTION_TOO_LONG);

        // Adds a new empty Milestone to list in storage
        Milestone storage newMilestone = _milestones.push(); 

        // Fills newly added Milestone with validated supplied data         
        newMilestone.description = milestone.description;          
    }
}
contract RevertTransaction {

    Milestone[] internal _milestones;

	function createMilestone(Milestone calldata milestone) external {   
		
		// Transaction will be reverted if library call fails. Error 
		// will be propagated outside.
		CryptoTaskLibrary.setMilestone(milestone, _milestones); 
		
		//More stuff if library function is successfully called
		// ...
	}
}

Another use case involves capturing the error in a try/catch block. This situation can only occur if the error originates from an external call to another contract or library; it is not possible for internal calls within a contract. The code executed in the external call will be rolled back, but the transaction globally will not be reverted.

This is illustrated below, by calling another variant of the “setMilestone” function that emits two different errors, this time without arguments. The transaction will not be reverted due to the presence of the try/catch block; instead, an event is emitted to notify the error condition:

library CryptoTaskLibrary  {       
    
    // Custom error to revert when milestone 
	// description is empty
    error CryptoTaskMilestoneDescriptionIsEmpty();
	
	// Custom error to revert when milestone 
	// description is too long
    error CryptoTaskMilestoneDescriptionTooLong();
	
	uint8 constant DESCRIPTION_MAX_LENGTH = 100;
    
    function setMilestone(Milestone calldata milestone, 
							Milestone[] storage _milestones) external {     
	
        if(bytes(milestone.description).length == 0)
            revert CryptoTaskMilestoneDescriptionIsEmpty();     
			
		if(bytes(milestone.description).length > DESCRIPTION_MAX_LENGTH)
            revert CryptoTaskMilestoneDescriptionTooLong();

        // Adds a new empty Milestone to list in storage
        Milestone storage newMilestone = _milestones.push(); 

        // Fills newly added Milestone with validated supplied data         
        newMilestone.description = milestone.description; 
    }
}
// Status codes
enum ErrorDetail {
	SUCCESS,
    EMPTY_DESCRIPTION,
	DESCRIPTION_TOO_LONG,
	UNKNOWN_ERROR
}  

contract CatchError {

	event MilestoneCreated(uint256 id, uint8 status);

	Milestone[] internal _milestones;

	function createMilestone(Milestone calldata milestone) external {   
		
		try CryptoTaskLibrary.setMilestone(milestone, _milestones) {
			emit MilestoneCreated(milestone.id, uint8(ErrorDetail.SUCCESS));
		} catch (bytes memory reason) {
			//Gets error selector from catched exception
			bytes4 receivedSelector = bytes4(reason);
			
			// Compares received selector with every expected error   
			if(receivedSelector ==
				bytes4(keccak256(bytes("CryptoTaskMilestoneDescriptionIsEmpty()"))))
				
				emit MilestoneCreated(milestone.id, 
										uint8(ErrorDetail.EMPTY_DESCRIPTION));
			else if(receivedSelector == 
				bytes4(keccak256(bytes("CryptoTaskMilestoneDescriptionTooLong()"))))
				
				emit MilestoneCreated(milestone.id, 
										uint8(ErrorDetail.DESCRIPTION_TOO_LONG));
			else 
				emit MilestoneCreated(milestone.id, 
										uint8(ErrorDetail.UNKNOWN_ERROR));		
		}
	}
}

Of particular interest is the procedure for identifying the type of exception caught in the catch block based on the error selector.

Filtering custom errors in the decentralized application

In the decentralized application, interaction with the blockchain is done through the Viem library. This is an alternative to the traditional libraries “web3.js” and “ethers.js”.

A common procedure for checking possible errors in the function calls of a smart contract is through the “simulateContract” action of the Viem “Public Client”. Internally, this translates to an RPC call to “eth_call”:

import { createPublicClient, 
		http, 
		BaseError, 
		ContractFunctionRevertedError } from 'viem';
			 
import { polygon } from 'viem/chains';
 
const publicClient = createPublicClient({ 
  chain: mainnet,
  transport: http()
})

const inputParams = {
    account: // client wallet address,
    address: // contract address,
    abi: // contract ABI,
    functionName: // contract function to call,
    args: // contract function input arguments,
    chainId: // blockchain where contract is deployed,                      
}

// ..... Code snippet of function interacting with blochchain

try {
    const { request } = await publicClient.simulateContract(inputParams);
}
catch(error) {

	if (error instanceof BaseError) {
        const revertError = error.walk(err => 
			err instanceof ContractFunctionRevertedError)
			
        if (revertError instanceof ContractFunctionRevertedError) {
            console.log("Contract revert error", revertError.data);			
            const errorName = revertError.data?.errorName ?? '';
            const errorArgs = revertError.data?.args ?? '';            
            return `${errorName} ${errorArgs}`;
        }
		else {
			console.log("General error");
			return (error.shortMessage || error.message);
		}	
    }    
    else
        return (error.shortMessage || error.message);
}

The “ContractFunctionRevertedError” type allows for separating the name of the custom error propagated in the contract call and its possible arguments. This could inform the DApp user about the detailed cause of the error, enhancing the user experience.

Conclusion: Efficient error management in smart contracts

The use of custom errors in Solidity brings improvements in handling erroneous calls to smart contracts:

  • The Solidity code generated is more efficient in gas consumption and more structured in typing error causes.
  • The user experience in decentralized applications is enhanced by being able to provide more convenient details about each error.

It’s worth mentioning that reputable code libraries like those from OpenZeppelin have updated error propagation from their contracts to the custom error model.

For example, the “onlyOwner” modifier of the Ownable contract in older versions of the library emitted the string “Ownable: caller is not the owner” in case of access error using the “require” statement.

In contrast, the current version of the library emits the custom error “OwnableUnauthorizedAccount(account)” as seen in the access control library’s API.


Have you adopted the use of custom errors in Solidity in any contract or library? Share your own insights about this language feature.