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:

  1. Panic - Stop execution immediately (unrecoverable error)
  2. Return Result - Let the caller decide what to do (recoverable error)
  3. 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

SituationUseReasoning
Expected failure (file, network, parse)Result<T, E>Caller can handle
Value might be absentT?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 violationpanic!Programmer error
Library code, generalResult<T, E>Respect caller's context
Application code, main logicResult<T, E>Robust production code
Prototype/spike solution!!Speed over perfection
Security-critical codepanic! or ResultNever 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:

  1. Default to Result - Especially in library code and functions that can fail for external reasons
  2. Use T? - For values that might not exist but absence isn't an error
  3. Panic for contracts - When callers violate documented preconditions
  4. Expect with explanation - When you know better than the compiler but need to document why
  5. 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.