DEV Community

Mykhailo Krainik
Mykhailo Krainik

Posted on

Comparing Error Handling in Rust and Go

In this article, we’ll compare Rust and Go’s error handling mechanisms by taking a hands-on approach and implementing a problem in both languages. Our task is to read information from an unreliable file and show default information in case of an error. We’ll assume that our read_file function will always give an error, forcing us to handle it and return the default result from another function. Through this exercise, we'll explore the differences and similarities between the two languages and discuss the pros and cons of each approach. Let's dive in!

The first step in implementing error handling in Rust is to define an error type which can include any necessary data fields. In the example, the CustomError structure has one field called Io which can contain data of type String

enum CustomError {
    Io(String),
}
Enter fullscreen mode Exit fullscreen mode

Next, you would typically implement the Display trait for the error type, which allows the error to be printed in a user-friendly way. This can be useful for debugging and user feedback.

impl fmt::Display for CustomError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match self {
            CustomError::Io(msg) => write!(f, "I/O Error: {}", msg),
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

In Rust, the language does not know how to automatically display your custom data structures for printing, and it is up to the programmer to provide an implementation of the std::fmt::Display trait to indicate how the data should be displayed. The Display trait provides a fmt method that takes a formatter object and outputs the data in a desired format.

CustomError::Io(msg) => write!(f, "I/O Error: {}", msg),
In the specific case of CustomError::Io(msg), it means that when a CustomError instance has the Io variant, it should be formatted as an I/O error with the message msg, using the write!() macro from Rust's standard library. The {} placeholder inside the format string is a placeholder that will be replaced by the actual message msg when the string is printed.

The next step is to define the read_file function with error handling.

fn read_file() -> Result<Bytes> {
    Err(CustomError::Io("File Not Found".to_string()))
}
Enter fullscreen mode Exit fullscreen mode

In the example, the read_file() function returns a Result<Bytes, CustomError> type, indicating that it can return either a byte array Bytes or an instance of CustomError, where Result<Bytes, CustomError> is an alias of the Result<T, E = CustomError> = std::result::Result<T, E>.

It can be written in full notation as follows:

fn read_file() -> std::result::Result<Bytes, CustomError> {
    Err(CustomError::Io("File Not Found".to_string()))
}
Enter fullscreen mode Exit fullscreen mode

The Result type is commonly used for error handling. This type has two type parameters, Ok and Err, and can be used to return a value or an error from a function. The error variant of Result typically includes an instance of the error type, such as CustomError.

Let’s see how these pieces come together in a full implementation. First, we call the read_file() function and handle any errors that may occur. If an error is returned, we then call the default_content() function, which returns an array of bytes. Finally, we print the resulting content after all of these steps.

use std::fmt;

enum CustomError {
    Io(String),
}

impl fmt::Display for CustomError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match self {
            CustomError::Io(msg) => write!(f, "I/O Error: {}", msg),
        }
    }
}

type Bytes = Vec<u8>;
type Result<T, E = CustomError> = std::result::Result<T, E>;

fn default_content() -> Bytes {
    b"Hello World".to_vec()
}

fn read_file() -> Result<Bytes> {
    Err(CustomError::Io("File Not Found".to_string()))
}

fn main() {
    let content = match read_file() {
        Err(err) => {
            println!("{}", err);
            default_content()
        }
        Ok(file) => file,
    };

    let content = String::from_utf8_lossy(&content);
    println!("Content: {}", content);
}
Enter fullscreen mode Exit fullscreen mode

The output of the code is

I/O Error: File Not Found
Content: Hello World
Enter fullscreen mode Exit fullscreen mode

Next, I will attempt to implement the same approach using Go.

package main

import (
 "fmt"
)

type CustomError struct {
 msg string
}

func (e CustomError) Error() string {
 return fmt.Sprintf("I/O Error: %s", e.msg)
}

type Bytes []byte

func default_content() Bytes {
 return []byte("Hello World")
}

func read_file() (Bytes, error) {
 return Bytes{}, CustomError{"File Not Found"}
}

func main() {
 tryFileContent, err := read_file()
 var content Bytes

 if err != nil {
  fmt.Println(err)
  content = default_content()
 } else {
  content = tryFileContent
 }

 fmt.Printf("Content: %s", string(content))
}
Enter fullscreen mode Exit fullscreen mode

The output of the code is

I/O Error: File Not Found
Content: Hello World
Enter fullscreen mode Exit fullscreen mode

In the Go code, everything should be pretty understandable. Instead of the Result type, Go uses the functionality of returning multiple values.

return Bytes{}, CustomError{"File Not Found"}
Enter fullscreen mode Exit fullscreen mode

Let’s move on to the next step and implement the parse_line method which will validate the structure of the content based on specific rules.

KEY0 => VALUE0
KEY1 => VALUE1
Enter fullscreen mode Exit fullscreen mode
fn parse_line(input: &str) -> Result<Vec<(&str, &str)>> {
    let mut pairs = Vec::new();
    for (i, line) in input.lines().enumerate() {
        let mut parts = line.split("=>").map(str::trim).filter(|t| !t.is_empty());
        match (parts.next(), parts.next()) {
            (Some(key), Some(value)) => pairs.push((key, value)),
            _ => {
                return Err(CustomError::FormatError {
                    line: i + 1,
                    msg: "Invalid format".to_string(),
                });
            }
        }
    }
    Ok(pairs)
}
Enter fullscreen mode Exit fullscreen mode

The crucial aspect of this function is the Err return when a value of any element is empty. In such a scenario, parse_line will generate a new error of type CustomError::FormatError. This error will include an error message and the line number where the error occurred.

Now let’s take a look at the full implementation of the code, which includes two error handling mechanisms

use std::fmt;

enum CustomError {
    Io(String),
    FormatError { line: usize, msg: String },
}

impl fmt::Display for CustomError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match self {
            CustomError::Io(msg) => write!(f, "I/O Error: {}", msg),
            CustomError::FormatError { line, msg } => {
                write!(f, "Parsing Error: {} on the line {}", msg, line)
            }
        }
    }
}

type Bytes = Vec<u8>;
type Result<T, E = CustomError> = std::result::Result<T, E>;

fn default_content() -> Bytes {
    b"KEY0 => VALUE0 \n KEY1 => VALUE1".to_vec()
}

fn read_file() -> Result<Bytes> {
    Err(CustomError::Io("File Not Found".to_string()))
}

fn parse_line(input: &str) -> Result<Vec<(&str, &str)>> {
    let mut pairs = Vec::new();
    for (i, line) in input.lines().enumerate() {
        let mut parts = line.split("=>").map(str::trim).filter(|t| !t.is_empty());
        match (parts.next(), parts.next()) {
            (Some(key), Some(value)) => pairs.push((key, value)),
            _ => {
                return Err(CustomError::FormatError {
                    line: i + 1,
                    msg: "Invalid format".to_string(),
                });
            }
        }
    }
    Ok(pairs)
}

fn main() {
    let content = match read_file() {
        Err(err) => {
            println!("{}", err);
            default_content()
        }
        Ok(file) => file,
    };

    let content_str = String::from_utf8_lossy(&content);

    match parse_line(&content_str) {
        Ok(pairs) => {
            for (key, value) in pairs {
                println!("Content: {} = {}", key, value);
            }
        }
        Err(err) => println!("{}", err),
    }
}
Enter fullscreen mode Exit fullscreen mode

To add new error handling, we simply needed to extend the functionality of the CustomError enum and the implementation of the Display trait for each error case. This illustrates how Rust’s error handling mechanism provides a concise and consistent way of dealing with errors compared to other programming languages.

In case

fn default_content() -> Bytes {
    b"KEY0 => VALUE0 \n KEY1 => ".to_vec()
}
The output of the code is

Parsing Error: Invalid format on line 2
In case

fn default_content() -> Bytes {
    b"KEY0 => VALUE0 \n KEY1 => VALUE1".to_vec()
}
Enter fullscreen mode Exit fullscreen mode

The output of the code is

Content: KEY0 = VALUE0
Content: KEY1 = VALUE1
Enter fullscreen mode Exit fullscreen mode

Moving on, let’s explore how this approach can be implemented in Go

package main

import (
 "fmt"
 "strings"
)

type FormatError struct {
 Line uint32
 Msg  string
}

type CustomError struct {
 Io string
}

func (e FormatError) Error() string {
 return fmt.Sprintf("Parsing Error: %v on the line %v", e.Msg, e.Line)
}

func (e CustomError) Error() string {
 return fmt.Sprintf("I/O Error: %v", e.Io)
}

type Pair struct {
 Key   string
 Value string
}

func defaultContent() []byte {
 return []byte("KEY0 => VALUE0 \n KEY1 => VALUE1")
}

func readFile() ([]byte, error) {
 return nil, CustomError{Io: "File Not Found"}
}

func parseLine(input string) ([]Pair, error) {
 lines := strings.Split(input, "\n")
 var result []Pair

 for i, line := range lines {
  line = strings.TrimSpace(line)
  if len(line) == 0 {
   continue
  }

  parts := strings.Split(line, "=>")

  key := strings.TrimSpace(parts[0])
  value := strings.TrimSpace(parts[1])

  if key == "" {
   return nil, FormatError{Line: uint32(i + 1), Msg: "Invalid format"}
  }
  if value == "" {
   return nil, FormatError{Line: uint32(i + 1), Msg: "Invalid format"}
  }

  result = append(result, Pair{key, value})
 }

 return result, nil
}

func main() {
 content, err := readFile()
 if err != nil {
  fmt.Println(err)
  content = defaultContent()
 }

 strContent := string(content)

 pairs, err := parseLine(strContent)
 if err != nil {
  fmt.Println(err)
  return
 }

 for _, pair := range pairs {
  fmt.Printf("Content: %v = %v\n", pair.Key, pair.Value)
 }
}
Enter fullscreen mode Exit fullscreen mode

When implementing the same approach in Go, we will need new data structures and functions to handle new error types.

Let’s see a few examples of how it works in this code.

In case

func defaultContent() []byte {
 return []byte("KEY0 => VALUE0 \n KEY1 => ")
}
Enter fullscreen mode Exit fullscreen mode

The output of the code is

Parsing error: invalid format on line 2
In case

func defaultContent() []byte {
 return []byte("KEY0 => VALUE0 \n KEY1 => VALUE1")
}
Enter fullscreen mode Exit fullscreen mode

The output of the code is

Content: KEY0 = VALUE0
Content: KEY1 = VALUE1
Enter fullscreen mode Exit fullscreen mode

Looking at all the examples, Rust's error handling can be more verbose, but it also allows for more expressive and informative error messages, as well as more powerful error handling mechanisms. In real-world software development, there may be errors that lead to a high level of complexity in function calls, but Rust’s easy mechanism for handling errors makes our lives as software developers happier.

Top comments (0)