Go to Rust Series: ← String vs &str | Series Overview
The Problem: Nil and Errors
Tony Hoare (inventor of null references) called it his “billion-dollar mistake.”
Go’s approach: nil values and explicit error returns
Rust’s approach: Option<T> for absence, Result<T, E> for errors
Go: Nil Pointers
Go:
func findUser(id int) *User {
if id == 0 {
return nil
}
return &User{ID: id, Name: "Alice"}
}
func main() {
user := findUser(0)
if user != nil {
fmt.Println(user.Name)
} else {
fmt.Println("User not found")
}
}
Problem: Easy to forget nil check:
user := findUser(0)
fmt.Println(user.Name) // PANIC! nil pointer dereference
Rust: Option<T>
Rust:
fn find_user(id: u32) -> Option<User> {
if id == 0 {
None
} else {
Some(User { id, name: String::from("Alice") })
}
}
fn main() {
let user = find_user(0);
match user {
Some(u) => println!("{}", u.name),
None => println!("User not found"),
}
}
If you forget to handle None:
let user = find_user(0);
println!("{}", user.name); // ERROR! Can't access fields of Option<User>
Compiler error:
error[E0609]: no field `name` on type `Option<User>`
The compiler forces you to handle the None case.
Option<T> Basics
Definition:
enum Option<T> {
Some(T),
None,
}
Creating Options:
let some_number: Option<i32> = Some(5);
let no_number: Option<i32> = None;
Pattern matching:
match some_number {
Some(n) => println!("Number: {}", n),
None => println!("No number"),
}
Unwrapping Options
Method 1: unwrap() (Panics if None)
Rust:
let x: Option<i32> = Some(5);
let value = x.unwrap(); // Returns 5
let y: Option<i32> = None;
let value = y.unwrap(); // PANIC!
Similar to Go’s nil dereference, but explicit.
Method 2: expect() (Panic with Message)
Rust:
let x: Option<i32> = None;
let value = x.expect("x should have a value"); // PANIC with custom message
Method 3: unwrap_or() (Provide Default)
Rust:
let x: Option<i32> = None;
let value = x.unwrap_or(0); // Returns 0 if None
println!("{}", value); // 0
Go equivalent:
var x *int
value := 0
if x != nil {
value = *x
}
Method 4: unwrap_or_else() (Lazy Default)
Rust:
let x: Option<i32> = None;
let value = x.unwrap_or_else(|| expensive_computation());
Only calls function if None.
Go: Error Handling
Go:
func divide(a, b float64) (float64, error) {
if b == 0 {
return 0, errors.New("division by zero")
}
return a / b, nil
}
func main() {
result, err := divide(10, 0)
if err != nil {
fmt.Println("Error:", err)
} else {
fmt.Println("Result:", result)
}
}
Problem: Easy to ignore errors:
result, _ := divide(10, 0) // Ignoring error!
fmt.Println(result) // 0 (wrong, but no panic)
Rust: Result<T, E>
Rust:
fn divide(a: f64, b: f64) -> Result<f64, String> {
if b == 0.0 {
Err(String::from("division by zero"))
} else {
Ok(a / b)
}
}
fn main() {
let result = divide(10.0, 0.0);
match result {
Ok(val) => println!("Result: {}", val),
Err(e) => println!("Error: {}", e),
}
}
Can’t ignore Result:
let result = divide(10.0, 0.0);
println!("{}", result); // ERROR! Can't print Result directly
Compiler warning:
warning: unused `Result` that must be used
Result<T, E> Basics
Definition:
enum Result<T, E> {
Ok(T),
Err(E),
}
Creating Results:
let success: Result<i32, String> = Ok(42);
let failure: Result<i32, String> = Err(String::from("error"));
The ? Operator: Error Propagation
Go: Manual Error Propagation
Go:
func processFile() error {
file, err := openFile()
if err != nil {
return err
}
defer file.Close()
data, err := readFile(file)
if err != nil {
return err
}
err = processData(data)
if err != nil {
return err
}
return nil
}
Repetitive if err != nil checks.
Rust: ? Operator
Rust:
fn process_file() -> Result<(), String> {
let file = open_file()?; // Returns early if Err
let data = read_file(&file)?;
process_data(&data)?;
Ok(())
}
The ? operator:
- Returns the value if
Ok(value) - Returns early with
Err(e)if error
Equivalent to:
fn process_file() -> Result<(), String> {
let file = match open_file() {
Ok(f) => f,
Err(e) => return Err(e),
};
let data = match read_file(&file) {
Ok(d) => d,
Err(e) => return Err(e),
};
match process_data(&data) {
Ok(_) => Ok(()),
Err(e) => Err(e),
}
}
Combining Option and Result
Rust:
fn parse_number(s: &str) -> Option<Result<i32, std::num::ParseIntError>> {
if s.is_empty() {
None
} else {
Some(s.parse::<i32>())
}
}
Or more commonly:
fn parse_number(s: &str) -> Result<Option<i32>, std::num::ParseIntError> {
if s.is_empty() {
Ok(None)
} else {
Ok(Some(s.parse::<i32>()?))
}
}
Real-World Example: Database Query
Go
Go:
func getUserByID(id int) (*User, error) {
row := db.QueryRow("SELECT * FROM users WHERE id = ?", id)
var user User
err := row.Scan(&user.ID, &user.Name)
if err == sql.ErrNoRows {
return nil, nil // Not found (nil, nil is ambiguous!)
}
if err != nil {
return nil, err // Database error
}
return &user, nil
}
func main() {
user, err := getUserByID(123)
if err != nil {
log.Fatal(err)
}
if user == nil {
fmt.Println("User not found")
} else {
fmt.Println(user.Name)
}
}
Problem: (nil, nil) is ambiguous. Is it “not found” or “no error”?
Rust
Rust:
fn get_user_by_id(id: i32) -> Result<Option<User>, DatabaseError> {
let row = db.query_row("SELECT * FROM users WHERE id = ?", &[&id]);
match row {
Ok(user) => Ok(Some(user)),
Err(DatabaseError::NotFound) => Ok(None), // Not found
Err(e) => Err(e), // Database error
}
}
fn main() {
match get_user_by_id(123) {
Ok(Some(user)) => println!("{}", user.name),
Ok(None) => println!("User not found"),
Err(e) => eprintln!("Database error: {}", e),
}
}
Clear distinction:
Ok(Some(user))- FoundOk(None)- Not found (no error)Err(e)- Database error
Combinator Methods
Option Methods
Rust:
let x: Option<i32> = Some(5);
// map: transform the value if Some
let doubled = x.map(|n| n * 2); // Some(10)
// and_then: chain operations
let result = x.and_then(|n| {
if n > 0 {
Some(n * 2)
} else {
None
}
});
// filter: keep only if predicate true
let filtered = x.filter(|&n| n > 3); // Some(5)
// or: provide alternative Option
let y: Option<i32> = None;
let z = y.or(Some(10)); // Some(10)
Go equivalent (manual):
var x *int = pointer(5)
// map
var doubled *int
if x != nil {
val := *x * 2
doubled = &val
}
// and_then
var result *int
if x != nil && *x > 0 {
val := *x * 2
result = &val
}
Result Methods
Rust:
let x: Result<i32, String> = Ok(5);
// map: transform Ok value
let doubled = x.map(|n| n * 2); // Ok(10)
// map_err: transform Err value
let result = x.map_err(|e| format!("Error: {}", e));
// and_then: chain operations
let result = x.and_then(|n| {
if n > 0 {
Ok(n * 2)
} else {
Err(String::from("negative"))
}
});
// or: provide alternative Result
let y: Result<i32, String> = Err(String::from("error"));
let z = y.or(Ok(10)); // Ok(10)
Common Patterns
Pattern 1: Default Values
Go:
func getConfig() *Config {
// ...
return nil
}
config := getConfig()
if config == nil {
config = &Config{Port: 8080}
}
Rust:
fn get_config() -> Option<Config> {
// ...
None
}
let config = get_config().unwrap_or(Config { port: 8080 });
Pattern 2: Early Return
Go:
func process() error {
val, err := step1()
if err != nil {
return err
}
err = step2(val)
if err != nil {
return err
}
return nil
}
Rust:
fn process() -> Result<(), Error> {
let val = step1()?;
step2(val)?;
Ok(())
}
Pattern 3: Collecting Results
Go:
func processAll(items []string) error {
for _, item := range items {
if err := process(item); err != nil {
return err // Stop on first error
}
}
return nil
}
Rust:
fn process_all(items: &[String]) -> Result<Vec<Output>, Error> {
items
.iter()
.map(|item| process(item))
.collect() // Stops on first Err
}
Converting Between Types
Option → Result:
let x: Option<i32> = Some(5);
let result: Result<i32, &str> = x.ok_or("value missing");
Result → Option:
let x: Result<i32, String> = Ok(5);
let option: Option<i32> = x.ok(); // Discards error
Testing and Assertions
Go:
func TestDivide(t *testing.T) {
result, err := divide(10, 2)
if err != nil {
t.Errorf("Unexpected error: %v", err)
}
if result != 5 {
t.Errorf("Expected 5, got %f", result)
}
}
Rust:
#[test]
fn test_divide() {
let result = divide(10.0, 2.0);
assert_eq!(result, Ok(5.0));
let error = divide(10.0, 0.0);
assert!(error.is_err());
}
The Mental Shift
Go:
- Check
if x != nilbefore using pointers - Check
if err != nilfor errors - Easy to forget checks
Rust:
- Can’t use
Option<T>without handlingNone - Can’t use
Result<T, E>without handlingErr - Compiler enforces handling
Conclusion
| Aspect | Go | Rust |
|---|---|---|
| Null values | nil |
Option<T> |
| Errors | error type |
Result<T, E> |
| Enforcement | Convention | Compiler |
| Propagation | if err != nil { return err } |
? operator |
| Ignoring | Easy to ignore | Compiler warning |
| Explicitness | Implicit nil checks | Explicit pattern matching |
Go’s approach:
- Simple and familiar
- Easy to ignore errors (dangerous)
- Runtime panics possible
Rust’s approach:
- More verbose
- Impossible to ignore (compiler enforced)
- No null pointer exceptions
The verdict:
Rust’s Option<T> and Result<T, E> are more verbose but prevent entire classes of bugs that Go allows. The ? operator makes error propagation as concise as Go’s if err != nil.
Go to Rust Series: ← String vs &str | Series Overview
Final verdict: Rust’s Option and Result are more explicit and safer than Go’s nil and error handling. The learning curve is steeper, but the guarantees are stronger.
Series Conclusion
After 3 months with Rust, I returned to Go with:
- Deeper appreciation for explicit memory management
- Respect for Rust’s compile-time guarantees
- Recognition that Go’s simplicity is valuable for many use cases
Choose Rust when: Memory safety, zero-cost abstractions, and predictable performance matter most.
Choose Go when: Developer productivity, simplicity, and fast iteration matter most.
Both languages are excellent. Neither is universally “better.” Choose based on your constraints, team, and project requirements.