Step 1 of 7
An enum for everything that can go wrong
In the last module, errors were String messages. That's quick, but the caller can only read a string, not react to it. Suppose a banking app wants to offer a smaller amount when the balance is too low, but show "contact support" when the account is frozen. With string errors it would have to compare message text, and the day someone fixes a typo in a message, that code silently breaks.
The Rust way is an error enum: one variant per way the operation can fail, each carrying the data that describes it.
#[derive(Debug, PartialEq)]
enum WithdrawError {
InvalidAmount,
InsufficientFunds { balance: u64, requested: u64 },
AccountFrozen,
}
struct Account {
balance: u64,
frozen: bool,
}
fn withdraw(account: &mut Account, amount: u64) -> Result<u64, WithdrawError> {
if account.frozen {
return Err(WithdrawError::AccountFrozen);
}
if amount == 0 {
return Err(WithdrawError::InvalidAmount);
}
if amount > account.balance {
return Err(WithdrawError::InsufficientFunds { balance: account.balance, requested: amount });
}
account.balance -= amount;
Ok(account.balance)
}
fn main() {
let mut acct = Account { balance: 100, frozen: false };
for amount in [30, 0, 500] {
match withdraw(&mut acct, amount) {
Ok(left) => println!("withdrew {amount}, {left} left"),
Err(WithdrawError::InsufficientFunds { balance, requested }) => {
println!("short by {}, you can take up to {balance}", requested - balance);
}
Err(e) => println!("declined: {e:?}"),
}
}
acct.frozen = true;
println!("{:?}", withdraw(&mut acct, 10));
}
withdrew 30, 70 left
declined: InvalidAmount
short by 430, you can take up to 70
Err(AccountFrozen)
What the enum buys you:
- Callers can match on the variant.
mainhandlesInsufficientFundsspecially and everything else generically, with no string comparisons. - Structured data.
InsufficientFundscarries the numbers, so the caller can compute "short by 430" instead of digging numbers out of a message. - The compiler keeps track. Add a variant later, and every
matchwithout a catch-all arm becomes a compile error pointing at code that needs updating. #[derive(Debug, PartialEq)]:Debuglets you print the error with{:?}(andmainreturningResultneeds it).PartialEqlets tests writeassert_eq!(result, Err(WithdrawError::AccountFrozen)).
Naming: call the type after the operation (WithdrawError, ParseConfigError) and don't repeat "Error" in every variant: WithdrawError::AccountFrozen, not WithdrawError::AccountFrozenError. Clippy's enum_variant_names lint warns when all variants end with the same word.
Common mistakes
- Forgetting the enum name. Variants live inside their enum, so they need the
WithdrawError::prefix:
#[derive(Debug)]
enum WithdrawError {
AccountFrozen,
InvalidAmount,
}
fn withdraw(frozen: bool) -> Result<u32, WithdrawError> {
if frozen {
return Err(AccountFrozen);
}
Err(WithdrawError::InvalidAmount)
}
fn main() {
println!("{:?}", withdraw(true));
}
error[E0425]: cannot find value `AccountFrozen` in this scope
- One
Other(String)variant for everything. It's the string error again in disguise. A catch-all can be useful for rare cases, but anything callers might handle differently deserves its own variant. - Too many variants. Variants are for failures the caller can tell apart and act on. Five kinds of "the number was invalid" that every caller treats the same way can be one variant with data.
Your task
A username must be 1 to 16 characters long and contain only ASCII letters, digits and _. Write validate_username using the UsernameError enum in the starter. Check in this order:
- empty:
Err(UsernameError::Empty) - more than 16 characters (count with
.chars().count()):Err(UsernameError::TooLong(length)) - the first character that's not allowed (
c.is_ascii_alphanumeric() || c == '_'tells you if it is):Err(UsernameError::InvalidChar(c))
A valid name returns Ok(()).
Challenge 2
A concert sells at most 6 tickets per order. Write the enum BookingError with these variants (derive Debug and PartialEq):
ZeroTicketsOverLimit { requested: u32, limit: u32 }SoldOut { available: u32 }
Then write fn book(available: u32, requested: u32) -> Result<u32, BookingError> that returns the number of tickets left after the order. Check in this order: 0 requested gives ZeroTickets, more than 6 gives OverLimit (with limit: 6), more than available gives SoldOut.
Challenge 3
Write a password checker from scratch. Define PasswordError (derive Debug and PartialEq) with the variants TooShort(usize), NoDigit, NoUppercase and ContainsUsername, and write fn check_password(password: &str, username: &str) -> Result<(), PasswordError>. Check in this order:
- fewer than 8 characters:
TooShort(<number of characters>) - no ASCII digit (
c.is_ascii_digit()):NoDigit - no uppercase letter (
c.is_uppercase()):NoUppercase - the password contains the username, ignoring case:
ContainsUsername