Go has very simple and efficient error handling mechanism. Error as value is very powerful concept and some people find it annoying. I think errors should be annoying so that we don’t ignore them.
Most notable aspects of go’s error handling I found myself are:
- It is just straight forward and simple, I didn’t have to do anything clever to tweak it and make it more erganomic.
- I got used to it quickly after a bit of skepticism. I believe It’s common for us to force a familiar paradigm when we try new languages and when it’s different, we tend to question it.
- I had to think more about error handling and it made me write more robust code. I always write prototypes in other languges by ignoring errors and it can slip to production, even if I ignore errors in go, there is an explicit
_
which reminds me that I’m ignoring an error.
Error as value
In go, a typical function defintion can give away if it returns an error or not. If a function returns an error, it’s usually the last return value. This makes it very easy to identify if a function can return an error or not.
example:
func ReadFile(filename string) ([]byte, error) {
data, err := ioutil.ReadFile
if err != nil {
return nil, err
}
return data, nil
}
It’s very clear that ReadFile
function can return an error. This makes it very easy to handle errors.
Compared to try catch
mechanism this is very simple and efficient. It’s very easy to understand and maintain. It’s easy to ignore errors in try catch
mechanism.
example in python:
def read_file(filename):
with open('file.txt') as f:
data = f.read()
By reading this function definition, we can’t say if it can return an error or not. We have to read the documentation or source code to understand if it can return an error or not.
We might even get away without handling the error. This is very dangerous as we might miss some critical errors.
Try catch tower of doom
In languages like python, java, etc, we have to use try catch
mechanism to handle errors. This leads to a lot of nested try catch
blocks which is called try catch tower of doom
.
example:
data = None
try:
with open('file.txt') as f:
data = f.read()
except FileNotFoundError as e:
print('File not found')
except Exception as e:
print('Some error occured')
try:
parsed_data = json.loads(data)
except json.JSONDecodeError as e:
print('Invalid json')
except Exception as e:
print('Some error occured')
This leads to a lot of nested try catch
blocks which is very hard to read and maintain.
Doing same in go feels cleaner, since the main code is not nested inside try catch
blocks and error handling is done separately. we can even wrap the error handling in a separate function.
example in go:
data, err := ioutil.ReadFile("file.txt")
// error handling is seperate and nested separately
if err != nil {
if errors.Is(err, os.ErrNotExist) {
fmt.Println("File not found")
}
fmt.Println("File not found")
}
// or we can wrap the error handling in a separate function
parsed_data, err := json.Unmarshal(data)
if err != nil {
handleErr(err)
}
func handleErr(err error) {
if errors.Is(err, os.ErrNotExist) {
fmt.Println("File not found")
}
fmt.Println("Some error occured")
}
One notable thing I like is that, the main code stays in the first level and error handlings seperate and would be naturally nested ( I fold these in IDE most of the time ). This makes it very easy to read and maintain.
A generic try catch block
for all of the code is not a good idea, as we might miss some critical errors. It’s better to handle errors separately and explicitly.
Comparing to Result type
Some languages like rust, zig, etc use Result
type to handle errors. This is also a very powerful concept. But I feel Result
type can also be non-erganomic due to deep nesting of match
blocks.
example in rust:
fn read_file(filename: &str) -> Result<String, io::Error> {
let mut file = File::open(filename)?;
let mut contents = String::new();
file.read_to_string(&mut contents)?;
Ok(contents)
}
fn main() {
// read file
match read_file("file.txt") {
// first level of nesting
Ok(data) => {
let parsed_data = json::parse(data);
match parsed_data {
// second level of nesting
Ok(data) => {
println!("Parsed data: {}", data);
},
Err(e) => {
match e {
json::Error::Io(e) => {
println!("IO error: {}", e);
},
_ => {
println!("Some error occured");
}
}
}
}
},
Err(e) => {
match e.kind() {
ErrorKind::NotFound => {
println!("File not found");
},
_ => {
println!("Some error occured");
}
}
}
}
}
This can lead to deep nesting of match
blocks which can be hard to read and maintain.
Maybe I’m missing the erganomics of Result
type, but again that’s a sign of how straight forward go’s error handling is, I didn’t need to do something clever to handle errors and to make sense of the code.
I started out with same pattern for error handling in go and it still makes sense, where as such a nested pattern is what I can think of in rust right now and I need to invest more time to learn and make the code better.
Conclusion
By comparing some of the error handling mechanisms in different languages, I find go’s error handling to be working best for me. It’s straight forward and simple, I don’t have to spend more time to make it more erganomic.
Yes, GO
can be boring and verbose at times. But it only forces you to write things that actually matters, the verbosity is not as extensive as Java yet it’s not as concise as Python. It’s a good balance between verbosity and conciseness.
I find some collection manipulation feature as well as Enums
with reciever functions would be nicer in go, and I’m looking forward for more features in future versions of go. But that’s another topic for another day.
It takes a bit of time to get used to this paradigm of error handling and once we discover the power of this paradigm, it’s hard to go back to try catch
mechanism.