To Panic or Not to Panic
Now that we've covered how to use panic!, Result<T, E>, nullable types T?, and the operators ??, !!, and ?, we need to discuss when to use each approach. This is a critical design decision that impacts both the robustness and usability of your code.
The Core Decision
When you have a situation that could fail, you face a choice:
- Panic - Stop execution immediately (unrecoverable error)
- Return Result - Let the caller decide what to do (recoverable error)
- Use T? - Let the caller handle absence of values (nullable types)
The key principle is: Returning Result is the default choice for library code. Use panic! only when you're certain the situation is truly unrecoverable or when panic would serve debugging better than error handling.
Why Result is the Default
When you return Result<T, E>, you're saying to the caller: "This operation can fail, and I'm giving you the information you need to decide how to handle it." This respects the caller's knowledge of their own situation:
// Bad: Making the decision for the caller
fn loadConfig(): Config {
File.open("config.toml")!! // Panics if file missing
}
// Good: Letting the caller decide
fn loadConfig(): Result<Config, Error> {
let content = fs.readToString("config.toml")?
parseConfig(&content)
}
// Now the caller can choose:
fn main(): Result<(), Box<dyn Error>> {
// Option 1: Propagate the error
let config = loadConfig()?
// Option 2: Use a default
let config = loadConfig().unwrapOr(Config.default())
// Option 3: Custom handling
let config = match loadConfig() {
Ok(cfg) -> cfg,
Err(e) -> {
eprintln!("Warning: {}", e)
Config.default()
}
}
Ok(())
}
When to Use panic!
Use panic! in these specific situations:
1. Examples and Prototypes
When writing example code or prototyping, panicking makes sense because your focus is on illustrating the happy path, not writing production-grade error handling:
// Example code - clarity is the goal
fn demonstrateStringParsing() {
let numbers = vec!["1", "2", "3"]
let parsed: Vec<Int> = numbers.iter()
.map { Int.parse(it)!! }
.collect()
println!("Numbers: {:?}", parsed)
}
The !! here signals to readers: "In this example, we know these conversions will succeed. In real code, you'd handle errors."
2. Tests
In tests, panic indicates test failure. Using !! or .expect() is exactly right:
#[test]
fn testUserCreation() {
let user = User.new("alice@example.com")!!
assertEq!(user.email, "alice@example.com")
}
If user creation fails, the test should fail. There's no recovery.
3. When You Have More Information Than the Compiler
Sometimes you know something the compiler doesn't. You've verified through logic that a value will be present, but the type system can't express that guarantee. In these cases, use expect() with a detailed message explaining your reasoning:
fn parseIpAddress(): IpAddr {
// We hardcoded this address, so we know it's valid
"127.0.0.1".parse()
.expect("Hardcoded IP address is always valid")
}
fn loadAuthenticatedUser(currentUserId: UserId?): User {
// The auth middleware guarantees currentUserId is set after authentication
let userId = currentUserId!!
// This should never fail in a correctly functioning system
database.findUser(userId)
.expect("User must exist; auth middleware verifies this")
}
The message is crucial—it explains why you believe the panic can't happen, helping future maintainers understand the assumption.
4. Invalid State That Violates Contracts
When your function has a contract (documented preconditions), breaking that contract indicates a programmer error that should be caught immediately:
/// Creates a Guess from a value.
///
/// # Panics
///
/// Panics if the value is less than 1 or greater than 100.
struct Guess {
value: Int,
}
extension Guess {
public fn new(value: Int): Guess {
guard value >= 1 && value <= 100 else {
panic!("Guess must be between 1 and 100, got {}", value)
}
Guess { value }
}
}
// Client code violation leads to panic during development
let guess = Guess.new(150) // Panics - contract violation caught immediately
This is different from recoverable errors like "file not found"—it's a programming mistake.
5. External Code in Unexpected State
When calling external code that's out of your control and it returns an invalid state you can't fix:
fn processWebResponse(response: HttpResponse): Result<Data, Error> {
// HttpStatus is supposed to be one of a defined set
// If we get an undefined status, that's a library bug, not our error
let status = match response.status() {
HttpStatus.Ok -> HttpStatus.Ok,
HttpStatus.NotFound -> HttpStatus.NotFound,
HttpStatus.ServerError -> HttpStatus.ServerError,
unknown -> panic!("HTTP library returned undefined status: {}", unknown),
}
// ... continue processing
Ok(Data {})
}
6. Security-Critical Operations
When operating on invalid data would compromise security, panic rather than silently continuing:
fn processBuffer(buffer: &[UInt8], maxSize: UIntSize): Result<Vec<UInt8>, Error> {
guard buffer.len() <= maxSize else {
panic!("Buffer size {} exceeds maximum {}", buffer.len(), maxSize)
}
// Panic happened during development, not at runtime in production
// because this contract violation is a security issue
Ok(process(buffer))
}
When to Return Result
Use Result<T, E> in these situations:
1. Expected Failure
When failure is an expected part of normal operation, return Result:
// File might not exist - this is expected
fn readConfigFile(path: String): Result<String, Error> {
fs.readToString(path)
}
// Network request might fail - this is expected
fn fetchUserData(id: String): Result<UserData, HttpError> {
http.get(&format!("/users/{}", id))?.json()
}
// User input might be invalid - this is expected
fn parseUserInput(input: String): Result<Command, ParseError> {
Command.parse(&input)
}
2. Errors You Can't Control
When the error comes from outside your code and you can't prevent it, return Result:
fn downloadFile(url: String): Result<Vec<UInt8>, DownloadError> {
let response = http.get(&url)? // Network failure
let bytes = response.bytes()? // Response reading failure
Ok(bytes)
}
The caller can retry, use a fallback, or notify the user.
3. Library Code
Libraries should return Result to give callers maximum flexibility. Application code can panic; libraries can't know the right error handling strategy:
// Good library code
public fn findUserById(id: String): Result<User, NotFoundError> {
self.database.find(id)
.okOr(NotFoundError { id })
}
// Caller decides:
// - In a CLI tool: panic to stop
// - In a web server: return HTTP 404
// - In a batch job: log and continue
4. Recoverable State Issues
When continuing with a reasonable fallback makes sense:
fn loadServerConfig(env: &Environment): Result<Config, ConfigError> {
// User might not set these - use defaults
let host = env.get("HOST").unwrapOr("localhost".toString())
let port = env.get("PORT")
?.parse()
.unwrapOr(8080)
let workers = env.get("WORKERS")
?.parse()
.unwrapOr(4)
Ok(Config { host, port, workers })
}
5. Parsing and Validation
When converting user input or external data:
fn parseJson(data: String): Result<JsonValue, ParseError> {
// User data is inherently unreliable
// Return error, don't panic
JsonParser.parse(&data)
}
fn parseDate(dateStr: String): Result<Date, ParseError> {
// User might enter invalid date - expected failure
Date.parse(&dateStr)
}
Using Nullable Types (T?)
The nullable type T? represents values that might be absent, and it's useful for:
1. Optional Data
When a value might not exist but absence isn't an error:
struct UserProfile {
name: String,
email: String,
phoneNumber: String?, // Not everyone provides a phone
bio: String?, // Bio is optional
}
fn getDisplayName(user: UserProfile): String {
user.bio ?? "No bio provided".toString()
}
2. Collection Operations
Getting values from collections that might not contain them:
fn getFirstElement<T>(items: Vec<T>): T? {
items.first()
}
fn getByKey<K, V>(map: HashMap<K, V>, key: K): V? {
map.get(&key)
}
// Using it:
let users: Vec<User> = vec![...]
if let firstUser = users.first() {
println!("First user: {}", firstUser.name)
}
3. Graceful Degradation
When you can continue with a default:
fn getUserPreference(userId: String, key: String): String {
let value = database.getUserPref(userId, key)
value ?? "default_preference".toString()
}
Decision Tree
Here's a practical decision tree:
Is the error unexpected or indicates a programming bug?
├─ Yes, and continuing would be unsafe/incorrect
│ └─ Use panic! or !!
├─ Yes, but caller should know about it
│ └─ Use Result<T, E>
└─ No, absence is normal and expected
└─ Use T? or Result/Option
More specifically:
Can callers handle this situation better than you can?
├─ Yes (they have more context)
│ └─ Return Result or use T?
└─ No (it's a fundamental programming error)
├─ Is the value missing, or is the entire operation wrong?
│ ├─ Just missing → Use T?
│ └─ Operation invalid → Use panic!
└─ Are you in library code?
├─ Yes → Always return Result or T?
└─ No → panic! is OK if it simplifies code
Pattern: Graceful Error Handling with Three Levels
Many applications use three levels of error handling:
fn main(): Result<(), Box<dyn Error>> {
let config = loadConfigOrDefault() // Level 1: Defaults
let client = createClient(&config)? // Level 2: Return error
client.run() // Level 3: Panic on unexpected
}
// Level 1: Use ?? for optional configuration
fn loadConfigOrDefault(): Config {
let configPath = env.var("CONFIG").okOr(()).ok() ?? "config.toml".toString()
fs.readToString(configPath)
.ok()
.flatMap { parseConfig(it) }
.ok()
?? Config.default()
}
// Level 2: Return errors from operations that can fail
fn createClient(config: &Config): Result<Client, Error> {
let database = Database.connect(&config.dbUrl)?
let cache = Cache.new(&config.cacheUrl)?
Ok(Client { database, cache })
}
// Level 3: Panic if invariants are violated
extension Client {
fn run(): Result<()> {
guard self.database.isConnected() else {
panic!("Database connection lost during operation")
}
// ... continue
Ok(())
}
}
Guidelines Summary
| Situation | Use | Reasoning |
|---|---|---|
| Expected failure (file, network, parse) | Result<T, E> | Caller can handle |
| Value might be absent | T? | Absence is normal |
| Example code | !! or .expect() | Clarity over robustness |
| Test code | .expect() or !! | Panic = test failure |
| You have more info than compiler | .expect("why...") | Document your assumption |
| Contract violation | panic! | Programmer error |
| Library code, general | Result<T, E> | Respect caller's context |
| Application code, main logic | Result<T, E> | Robust production code |
| Prototype/spike solution | !! | Speed over perfection |
| Security-critical code | panic! or Result | Never silently ignore |
Real-World Examples
Web Server - Mixed Strategies
fn handleRequest(req: HttpRequest): Result<HttpResponse, Box<dyn Error>> {
// Level 1: Optional headers - use ??
let contentType = req.header("Content-Type") ?? "application/octet-stream".toString()
// Level 2: Expected failures - use Result
let userId = parseUserId(&req.body())?
// Level 3: Invariants - use expect
let user = database.findUser(userId)
.okOr(UserNotFound)?
// Level 3: Contract violations - panic
guard user.isActive else {
panic!("Attempting to process inactive user {}", userId)
}
Ok(HttpResponse.ok().body(format!("Hello {}", user.name)))
}
Data Processing - Graceful Degradation
fn processData(input: Vec<DataPoint>): Result<Summary, ProcessError> {
// Return error if no data - expected failure
guard !input.isEmpty() else {
return Err(ProcessError.EmptyInput)
}
let results: Vec<Int> = input.iter()
.map { it.parse() }
.collect<Result<Vec<Int>, ProcessError>>()? // Propagate parse errors
let avg = results.iter().sum<Int>() / results.len() as Int
let maxValue = results.iter().max() // Returns Option
.copied()
?? 0 // Default if empty (shouldn't happen after guard, but defensive)
Ok(Summary { average: avg, maximum: maxValue })
}
Configuration - All Three Levels
struct AppConfig {
database: String,
port: Int,
logLevel: LogLevel,
}
fn loadAppConfig(): AppConfig {
let dbUrl = env.var("DATABASE_URL") // Returns Result
.ok() // Convert to Option
?? "sqlite:memory:".toString() // Level 1: Default
let port = env.var("PORT")
.ok()
.flatMap { it.parse().ok() }
?? 8080 // Level 1: Default
let logLevel = env.var("LOG_LEVEL")
.ok()
.flatMap { LogLevel.parse(it) }
?? LogLevel.Info // Level 1: Default
AppConfig { database: dbUrl, port, logLevel }
}
fn initializeApp(config: AppConfig): Result<App, Error> {
let db = Database.connect(&config.database)? // Level 2: Return error
let cache = Cache.new()? // Level 2: Return error
Ok(App { db, cache, config })
}
extension App {
fn run(): Result<(), Error> {
guard self.db.isHealthy() else {
panic!("Database failed health check before main loop")
}
// ... continue with main event loop
Ok(())
}
}
Summary
The decision between panic!, Result, and T? is fundamental to Rust/Oxide's error handling philosophy:
- Default to
Result- Especially in library code and functions that can fail for external reasons - Use
T?- For values that might not exist but absence isn't an error - Panic for contracts - When callers violate documented preconditions
- Expect with explanation - When you know better than the compiler but need to document why
- Minimize
!!- It should be rare in production code
Remember: The goal is to write code that's both safe and clear about what can go wrong and how to handle it. Your error handling strategy is part of your API's contract with users.