Recoverable Errors with Result
Most errors aren't serious enough to require the program to stop entirely. Sometimes when a function fails, it's for a reason that you can easily interpret and respond to. For example, if you try to open a file and that operation fails because the file doesn't exist, you might want to create the file instead of terminating the process.
The Result enum is defined as having two variants, Ok and Err, as follows:
enum Result<T, E> {
Ok(T),
Err(E),
}
The T and E are generic type parameters. T represents the type of the value that will be returned in a success case within the Ok variant, and E represents the type of the error that will be returned in a failure case within the Err variant.
Let's call a function that returns a Result value because the function could fail. Here we try to open a file:
import std.fs.File
fn main() {
let greetingFileResult = File.open("hello.txt")
}
The return type of File.open is a Result<T, E>. The generic parameter T has been filled in by the implementation of File.open with the type of the success value, std.fs.File, which is a file handle. The type of E used in the error value is std.io.Error. This return type means the call to File.open might succeed and return a file handle that we can read from or write to. The function call also might fail: For example, the file might not exist, or we might not have permission to access the file.
We need to add code to take different actions depending on the value File.open returns. Here's one way to handle the Result using a match expression:
import std.fs.File
fn main() {
let greetingFileResult = File.open("hello.txt")
let greetingFile = match greetingFileResult {
Ok(file) -> file,
Err(error) -> panic!("Problem opening the file: \(error)"),
}
}
Note that, like nullable types, the Result enum and its variants have been brought into scope by the prelude, so we don't need to specify Result. before the Ok and Err variants in the match arms.
When the result is Ok, this code will return the inner file value out of the Ok variant, and we then assign that file handle value to the variable greetingFile. After the match, we can use the file handle for reading or writing.
The other arm of the match handles the case where we get an Err value from File.open. In this example, we've chosen to call the panic! macro. If there's no file named hello.txt in our current directory and we run this code, we'll see the following output from the panic! macro:
thread 'main' panicked at src/main.ox:9:23:
Problem opening the file: Os { code: 2, kind: NotFound, message: "No such file or directory" }
Matching on Different Errors
The code above will panic! no matter why File.open failed. However, we want to take different actions for different failure reasons. If File.open failed because the file doesn't exist, we want to create the file and return the handle to the new file. If File.open failed for any other reason--for example, because we didn't have permission to open the file--we still want the code to panic!. For this, we add an inner match expression:
import std.fs.File
import std.io.ErrorKind
fn main() {
let greetingFileResult = File.open("hello.txt")
let greetingFile = match greetingFileResult {
Ok(file) -> file,
Err(error) -> match error.kind() {
ErrorKind.NotFound -> match File.create("hello.txt") {
Ok(fc) -> fc,
Err(e) -> panic!("Problem creating the file: \(e)"),
},
_ -> panic!("Problem opening the file: \(error)"),
},
}
}
The type of the value that File.open returns inside the Err variant is io.Error, which is a struct provided by the standard library. This struct has a method, kind, that we can call to get an io.ErrorKind value. The enum io.ErrorKind is provided by the standard library and has variants representing the different kinds of errors that might result from an io operation. The variant we want to use is ErrorKind.NotFound, which indicates the file we're trying to open doesn't exist yet.
Alternatives to Using
matchwithResult<T, E>That's a lot of
match! Thematchexpression is very useful but also very much a primitive. In Chapter 13, you'll learn about closures, which are used with many of the methods defined onResult<T, E>. These methods can be more concise than usingmatchwhen handlingResult<T, E>values in your code.For example, here's another way to write the same logic using closures and the
unwrapOrElsemethod:import std.fs.File import std.io.ErrorKind fn main() { let greetingFile = File.open("hello.txt").unwrapOrElse { error -> if error.kind() == ErrorKind.NotFound { File.create("hello.txt").unwrapOrElse { error -> panic!("Problem creating the file: \(error)") } } else { panic!("Problem opening the file: \(error)") } } }Although this code has the same behavior as the nested
match, it doesn't contain anymatchexpressions and is cleaner to read.
Shortcuts for Panic on Error
Using match works well enough, but it can be a bit verbose and doesn't always communicate intent well. The Result<T, E> type has many helper methods defined on it to do various, more specific tasks.
The unwrap Method
The unwrap method is a shortcut method implemented just like the match expression we wrote earlier. If the Result value is the Ok variant, unwrap will return the value inside the Ok. If the Result is the Err variant, unwrap will call the panic! macro for us:
import std.fs.File
fn main() {
let greetingFile = File.open("hello.txt").unwrap()
}
If we run this code without a hello.txt file, we'll see an error message from the panic! call that the unwrap method makes:
thread 'main' panicked at src/main.ox:4:49:
called `Result.unwrap()` on an `Err` value: Os { code: 2, kind: NotFound, message: "No such file or directory" }
The expect Method
Similarly, the expect method lets us also choose the panic! error message. Using expect instead of unwrap and providing good error messages can convey your intent and make tracking down the source of a panic easier:
import std.fs.File
fn main() {
let greetingFile = File.open("hello.txt")
.expect("hello.txt should be included in this project")
}
We use expect in the same way as unwrap: to return the file handle or call the panic! macro. The error message used by expect in its call to panic! will be the parameter that we pass to expect, rather than the default panic! message that unwrap uses:
thread 'main' panicked at src/main.ox:5:10:
hello.txt should be included in this project: Os { code: 2, kind: NotFound, message: "No such file or directory" }
In production-quality code, most developers choose expect rather than unwrap and give more context about why the operation is expected to always succeed. That way, if your assumptions are ever proven wrong, you have more information to use in debugging.
Propagating Errors
When a function's implementation calls something that might fail, instead of handling the error within the function itself, you can return the error to the calling code so that it can decide what to do. This is known as propagating the error and gives more control to the calling code, where there might be more information or logic that dictates how the error should be handled than what you have available in the context of your code.
For example, here's a function that reads a username from a file. If the file doesn't exist or can't be read, this function will return those errors to the code that called the function:
import std.fs.File
import std.io
import std.io.Read
fn readUsernameFromFile(): Result<String, io.Error> {
let usernameFileResult = File.open("hello.txt")
var usernameFile = match usernameFileResult {
Ok(file) -> file,
Err(e) -> return Err(e),
}
var username = String.new()
match usernameFile.readToString(&mut username) {
Ok(_) -> Ok(username),
Err(e) -> Err(e),
}
}
This function can be written in a much shorter way, but we're going to start by doing a lot of it manually in order to explore error handling; at the end, we'll show the shorter way.
The return type of the function is Result<String, io.Error>. This means the function is returning a value of the type Result<T, E>, where the generic parameter T has been filled in with the concrete type String and the generic type E has been filled in with the concrete type io.Error.
If this function succeeds without any problems, the code that calls this function will receive an Ok value that holds a String--the username that this function read from the file. If this function encounters any problems, the calling code will receive an Err value that holds an instance of io.Error that contains more information about what the problems were.
This pattern of propagating errors is so common that Oxide provides the question mark operator ? to make this easier.
The ? Operator Shortcut
Here's an implementation of readUsernameFromFile that has the same functionality, but this implementation uses the ? operator:
import std.fs.File
import std.io
import std.io.Read
fn readUsernameFromFile(): Result<String, io.Error> {
var usernameFile = File.open("hello.txt")?
var username = String.new()
usernameFile.readToString(&mut username)?
Ok(username)
}
The ? placed after a Result value is defined to work in almost the same way as the match expressions that we defined to handle the Result values earlier. If the value of the Result is an Ok, the value inside the Ok will get returned from this expression, and the program will continue. If the value is an Err, the Err will be returned from the whole function as if we had used the return keyword so that the error value gets propagated to the calling code.
There is a difference between what the match expression does and what the ? operator does: Error values that have the ? operator called on them go through the from function, defined in the From trait in the standard library, which is used to convert values from one type into another. When the ? operator calls the from function, the error type received is converted into the error type defined in the return type of the current function. This is useful when a function returns one error type to represent all the ways a function might fail, even if parts might fail for many different reasons.
The ? operator eliminates a lot of boilerplate and makes this function's implementation simpler. We could even shorten this code further by chaining method calls immediately after the ?:
import std.fs.File
import std.io
import std.io.Read
fn readUsernameFromFile(): Result<String, io.Error> {
var username = String.new()
File.open("hello.txt")?.readToString(&mut username)?
Ok(username)
}
Or even more concisely using std.fs.readToString:
import std.fs
fn readUsernameFromFile(): Result<String, io.Error> {
fs.readToString("hello.txt")
}
Reading a file into a string is a fairly common operation, so the standard library provides the convenient fs.readToString function that opens the file, creates a new String, reads the contents of the file, puts the contents into that String, and returns it.
Where to Use the ? Operator
The ? operator can only be used in functions whose return type is compatible with the value the ? is used on. This is because the ? operator is defined to perform an early return of a value out of the function.
Let's look at the error we'll get if we use the ? operator in a main function with a return type that is incompatible:
import std.fs.File
fn main() {
let greetingFile = File.open("hello.txt")?
}
This code opens a file, which might fail. The ? operator follows the Result value returned by File.open, but this main function has the return type of (), not Result. When we compile this code, we get an error message:
error[E0277]: the `?` operator can only be used in a function that returns `Result` or `Option`
--> src/main.ox:4:48
|
3 | fn main() {
| --------- this function should return `Result` or `Option` to accept `?`
4 | let greetingFile = File.open("hello.txt")?
| ^ cannot use the `?` operator in a function that returns `()`
To fix the error, you have two choices. One choice is to change the return type of your function to be compatible with the value you're using the ? operator on. The other choice is to use a match or one of the Result<T, E> methods to handle the Result<T, E> in whatever way is appropriate.
The ? operator can also be used with nullable types (T?). The behavior is similar: If the value is null, the null will be returned early from the function at that point. If the value is Some, the value inside the Some is the resultant value of the expression, and the function continues:
fn lastCharOfFirstLine(text: &str): Char? {
text.lines().next()?.chars().last()
}
This function returns Char? because it's possible that there is a character there, but it's also possible that there isn't. This code takes the text string slice argument and calls the lines method on it, which returns an iterator over the lines in the string. Because this function wants to examine the first line, it calls next on the iterator to get the first value from the iterator. If text is the empty string, this call to next will return null, in which case we use ? to stop and return null from lastCharOfFirstLine.
Note that you can use the ? operator on a Result in a function that returns Result, and you can use the ? operator on a nullable type in a function that returns a nullable type, but you can't mix and match. The ? operator won't automatically convert a Result to a nullable type or vice versa; in those cases, you can use methods like the ok method on Result or the okOr method on nullable types to do the conversion explicitly.
main Can Return Result
The main function can also return a Result<(), E>:
import std.error.Error
import std.fs.File
fn main(): Result<(), Box<dyn Error>> {
let greetingFile = File.open("hello.txt")?
Ok(())
}
The Box<dyn Error> type is a trait object. For now, you can read Box<dyn Error> to mean "any kind of error." Using ? on a Result value in a main function with the error type Box<dyn Error> is allowed because it allows any Err value to be returned early.
When a main function returns a Result<(), E>, the executable will exit with a value of 0 if main returns Ok(()) and will exit with a nonzero value if main returns an Err value.
Now that we've discussed the details of calling panic! or returning Result, let's look at Oxide's ergonomic operators for working with nullable types.
Nullable Operators (?? and !!)
Oxide provides two ergonomic operators for working with nullable types (T?) that make common patterns more concise and readable. These operators complement the ? try operator and give you fine-grained control over how you handle the absence of values.
The Null Coalescing Operator (??)
The null coalescing operator ?? provides a default value when the left-hand side is null. This is one of the most common patterns when working with optional values.
Basic Usage
let username = maybeUsername ?? "Guest"
let port = configPort ?? 8080
let config = Config.load() ?? Config.default()
When maybeUsername contains a value, that value is used. When it's null, the right-hand side ("Guest") is used instead.
How It Works
The ?? operator desugars to method calls on the underlying type:
// Oxide
let name = optionalName ?? "Anonymous"
// Desugars to (conceptually):
let name = optionalName.unwrapOr("Anonymous")
For complex right-hand side expressions, Oxide uses lazy evaluation:
// Oxide
let config = loadConfig() ?? computeExpensiveDefault()
// Desugars to:
let config = loadConfig().unwrapOrElse { computeExpensiveDefault() }
This means computeExpensiveDefault() is only called if loadConfig() returns null.
Chaining ??
You can chain multiple ?? operators to provide a sequence of fallbacks:
let username = primaryName ?? secondaryName ?? "Guest"
This tries primaryName first, then secondaryName, and finally falls back to "Guest" if both are null.
?? with Different Types
The right-hand side of ?? must be the same type as the value inside the nullable type:
let count: Int? = Some(5)
let value = count ?? 0 // OK: Int? ?? Int -> Int
let maybeString: String? = null
let text = maybeString ?? "default".toString() // OK: String? ?? String -> String
CRITICAL:
??works withT?(Option) ONLY, NOTResult<T, E>This is an intentional design decision.
Result<T, E>contains typed error information that should not be silently discarded:// This will NOT compile: let value: Result<Int, Error> = Err(someError) let result = value ?? 0 // ERROR: ?? only works with T?Why? If
??worked withResult, it would silently discard error information, making debugging difficult and hiding potential issues in your code.For Result, use explicit methods:
// Provide a default value (discards the error) let value = riskyOperation().unwrapOr(default) // Handle the error with a closure let value = riskyOperation().unwrapOrElse { err -> log("Error occurred: \(err)") computeDefault() } // Convert Result to nullable (discards error info) let maybeValue: Int? = riskyOperation().ok() let value = maybeValue ?? default
Practical Examples
Configuration with defaults:
struct ServerConfig {
host: String,
port: Int,
maxConnections: Int,
}
fn loadServerConfig(env: &Environment): ServerConfig {
ServerConfig {
host: env.get("HOST") ?? "localhost".toString(),
port: env.get("PORT")?.parse().ok() ?? 3000,
maxConnections: env.get("MAX_CONN")?.parse().ok() ?? 100,
}
}
Safe dictionary access:
fn getUserDisplayName(users: &HashMap<String, User>, id: &str): String {
let user = users.get(id)
let displayName = user?.displayName ?? user?.username ?? "Unknown User".toString()
displayName
}
Combining with if let:
fn processItem(item: Item?): String {
// Use ?? when you just need a value
let name = item?.name ?? "unnamed".toString()
// Use if let when you need to do more complex processing
if let item = item {
processFullItem(item)
} else {
processDefault()
}
}
The Force Unwrap Operator (!!)
The force unwrap operator !! extracts the value from a nullable type, panicking if the value is null. Use this when you are certain a value exists and want to express that certainty explicitly.
Basic Usage
let user = findUser(id)!! // Panics if null
let first = nonEmptyList.first()!! // Panics if null
How It Works
The !! operator desugars to an unwrap call:
// Oxide
let user = findUser(id)!!
// Desugars to:
let user = findUser(id).unwrap()
When to Use !!
Use !! when:
- You have verified the value exists:
let items = vec![1, 2, 3]
if !items.isEmpty() {
// We KNOW this won't be null because we checked isEmpty()
let first = items.first()!!
println!("First item: \(first)")
}
- In tests where you want to fail fast:
#[test]
fn testUserCreation() {
let user = createUser("test@example.com")!!
assertEq!(user.email, "test@example.com")
}
- In prototypes where proper error handling is deferred:
// Quick prototype - will add proper error handling later
fn main() {
let config = Config.loadFromFile("config.toml")!!
let server = Server.new(config)!!
server.run()!!
}
- When the value being null would indicate a bug:
// If we reach this code, currentUser MUST be set by the auth middleware
let user = getCurrentUser()!!
Warning: Use !! Sparingly
The !! operator is a clear signal that "if this is null, something has gone fundamentally wrong." Overuse of !! defeats the purpose of nullable types:
// BAD: Using !! everywhere
fn processUser(id: String): String {
let user = findUser(id)!! // What if user doesn't exist?
let profile = user.profile()!! // What if profile is incomplete?
let address = profile.address()!! // What if address is optional?
format!("User lives at \(address)")
}
// BETTER: Handle the nullable cases appropriately
fn processUser(id: String): String? {
let user = findUser(id)?
let profile = user.profile()?
let address = profile.address()?
Some(format!("User lives at \(address)"))
}
// Or using ?? for defaults
fn processUser(id: String): String {
let user = findUser(id) ?? return "Unknown user".toString()
let address = user.profile()?.address() ?? "No address".toString()
format!("User lives at \(address)")
}
Combining ??, !!, and ?
These three operators serve different purposes and can be combined effectively:
| Operator | Purpose | When to Use |
|---|---|---|
? | Propagate null/error to caller | When caller should handle the absence |
?? | Provide a fallback value | When you have a sensible default |
!! | Assert value exists (panic if not) | When null indicates a bug |
Example: User Authentication Flow
fn authenticateAndLoadProfile(token: String?): Result<UserProfile, AuthError> {
// Use ? to propagate - caller handles missing token
let token = token.okOr(AuthError.MissingToken)?
// Use ? to propagate - caller handles invalid token
let userId = validateToken(token)?
// Use ?? for default - missing profile is OK, use default
let profile = loadProfile(userId) ?? UserProfile.default()
Ok(profile)
}
fn loadUserDashboard(user: User): Dashboard {
// Use !! - user MUST have a primary account after login
let primaryAccount = user.primaryAccount()!!
// Use ?? - optional secondary accounts
let secondaryAccounts = user.secondaryAccounts() ?? vec![]
Dashboard {
main: AccountView.new(primaryAccount),
others: secondaryAccounts.map { AccountView.new(it) },
}
}
Operator Precedence
Understanding precedence is important when combining these operators:
| Precedence | Operator | Description |
|---|---|---|
| 2 (high) | ? !! | Try operator, force unwrap (postfix) |
| 14 (low) | ?? | Null coalescing |
This means:
// a?.b ?? c is parsed as (a?.b) ?? c
let value = user?.name ?? "Anonymous"
// a!! ?? b would be unusual but parses as (a!!) ?? b
// (If a!! succeeds, ?? never evaluates; if a!! panics, we never reach ??)
// await and ?? interact predictably
let data = await fetchData() ?? defaultData // (await fetchData()) ?? defaultData
Best Practices
- Prefer
??over!!when a default makes sense:
// Good
let timeout = configuredTimeout ?? Duration.fromSecs(30)
// Avoid if null is actually possible
let timeout = configuredTimeout!! // Panic if not configured!
- Use
?to propagate,??to provide defaults:
fn loadConfig(path: String?): Result<Config, Error> {
let path = path ?? "config.toml".toString() // Default path
let content = fs.readToString(&path)? // Propagate file errors
parseConfig(&content) // Propagate parse errors
}
- Document why you're using
!!:
// The auth middleware guarantees currentUser is set for all authenticated routes
let user = getCurrentUser()!!
- In libraries, prefer returning
T?orResultover panicking:
// Library code - let the caller decide
public fn findById(id: &str): User? {
self.users.get(id).cloned()
}
// Application code - can use !! when appropriate
let user = db.findById(requiredId)!!
Summary
Oxide's ?? and !! operators make working with nullable types more ergonomic:
| Operator | Desugars To | Behavior on null |
|---|---|---|
x ?? default | x.unwrapOr(default) | Returns default |
x!! | x.unwrap() | Panics |
x? | Early return | Returns null from function |
Remember:
??only works with nullable types (T?), notResult<T, E>!!should be used sparingly and indicates "null here is a bug"?propagates the absence to the caller
These operators, combined with guard let, if let, and pattern matching, give you complete control over how to handle values that might be absent.