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)) - Found
  • Ok(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 != nil before using pointers
  • Check if err != nil for errors
  • Easy to forget checks

Rust:

  • Can’t use Option<T> without handling None
  • Can’t use Result<T, E> without handling Err
  • 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.