Better Way of Error Handling
Everyone hates bugs.
Everyone hates bugs. Bugs not only distracts my focus, but also ruins our lifestyle. I really hate working overtime just because I was careless. Bugs can come from many sources such as type mismatches or poor error handling.
Today, I'll focus on how to handle errors properly, so that I can liberate myself from the bug trap.
Exception Handling
When I first learned about error handling, I believed that wrapping all my logic in a try-catch block was the essence of good error handling.
I saw countless examples with try-catch wrapping huge chunks of code.
Think of an API server, for example — it's tempting to just wrap the entire handler like this:
from fastapi import FastAPI
app = FastAPI()
@app.get("/")
def some_work():
try:
some_val_from_db = fetch_db()
some_result = some_action(some_val_from_db)
return {"result": some_result}
except:
raise HTTPException()
That's the easiest way to handle errors. And yes — it works. But just because it's easy, doesn't mean it's good. In fact, this is wrong and dangerous approach to build strong application.
Once I studied Clang, I recognize that there are no try-catch in C. Everyone knows world's most complicated and sophiscated system is linux, and basically, it is almost written in C. At that point, I wonder how linux and c written programs handle errors.
Learning from C: Error as Value
Later, when I studied C, I discovered something enlightening: There is no try-catch in C.
And yet - the world's most complex and robust system, Linux, is mostly written in C.
So I asked myself:
How do C programs - and even linux itself - handle errors?
The answer? It is so simple and easiest way to achieve.
By returning error values.
Here is a classic example of C:
#define ERROR -1
int some_func(int param)
{
// If param is invalid, return -1 as an error code.
if (param < 0){
return ERROR;
}
// ... Do many things..
return 0;
}
This idea is so simple and cool, And it turns out, the Go programming language inherits this philosophy.
func divider(a int, b int) (float64, error) {
if b == 0 {
return 0.0, fmt.Error("divider can't be zero")
}
return float64(a) / float64(b), nil
}
Designing Error Boundaries
One of the most outlooked, yet essential, parts of building successful application is explicitly defining where my responsiblities starts and end - especially when it comes to handle errors.
I like the word "taking responsiblity" when I write codes in my workspaces. By taking responsiblity of my codebase, I clearly define specs and logics of all functions and classes, relationships between modules, dependency direction, and even lintering across my codes.
The word "responsible", in the context of programming, also implies a kind of contract between my code and the outside world. In sophiscated system that has lots of layers, contract is the most important thing to build robust software. They define how modules interact, what they expect, and what they return.
The caller should not know how the callee works internally. It only needs to know:
- What inputs are required
- What will be returned
- What kind of exceptions or errors might occur
Writing good code is about defining clear, explicit contracts between caller and callee. And handling errors as values is one powerful way to express those contracts.
Here is improved version of divider function written in python, inspired by Go-style error handling:
from typing import Optional, Tuple
def divider(numerator: float, denominator: float) -> Tuple[float, Optional[Exception]]:
"""
Safely divides two floating point numbers.
Returns:
A tuple of (result, error):
- result: the quotient, or 0.0 if error occurs.
- error: None if successful, or an Exception instance if failed.
"""
if denominator == 0.0:
return 0.0, ValueError("division by zero")
return numerator / denominator, None
By reading the signature of this function, we can understand spec of this function. This approach -Error as Value - lets caller understand
- What the function does
- What parameters it requires
- What error might occur
This design forces the caller to handle errors explicitly and safely.
from typing import Literal
def calculator(left: float, right: float, operator: Literal['+'. '-', '*', '/']) -> float | None:
"""
Returns values.
If there is Exception, this function returns `None`
"""
match operator:
# assuming other cases are being.
case "/":
value, err = divider(left, right)
if err:
return None
return value
return None
In this function, the contract is again visible:
- If an error occurs, the result is None.
- If not, a valid float is returned.
This makes failure modes explicit, and that's what robust software is built on.
How To Prevent Myself from Chaostic Outside Worlds
A good software system is resilient — not just when things go right, but especially when the outside world misbehaves. In the real world, we often depend on things we don't control:
- External APIs
- Third-party databases
- Network I/O
- Cloud providers
- Even the filesystem
In explicit error handling language like Go, this kinds of I/O from external networks always return errors that can be occured. But language like Python, can be vulnerable from these kinds of "assualts".
Here is safe version of json converter from filesystem.
import collections
from typing import Optional, Tuple, Any
import os
import json
def load_from_json(filepath: str) -> Tuple[dict[str, Any], Optional[Exception]]:
"""
Safely loads a JSON file and returns (parsed_data, error).
Returns an empty dict with default str values if loading or parsing fails.
"""
base: dict[str, Any] = collections.defaultdict(str)
try:
with open(filepath, "r") as f:
content = f.read()
except Exception as ex:
return dict(base), ex # File I/O failure
try:
base = json.loads(content)
except Exception as ex:
return dict(base), ex # JSON parse failure
return dict(base), None # Success
Conclusion
In this post, I explored how to build safe and robust software in the presence of various risks — especially those coming from the outside world and from layered complexity within my own codebase.
The idea of "error as value" is a powerful tool in writing safe, predictable, and maintainable code. Instead of relying on implicit exceptions that might explode at runtime, treating errors as explicit return values brings clarity and control.
Whether it's I/O, external APIs, or even internal module boundaries, this pattern allows us to define clear contracts between caller and callee, and to isolate failures gracefully — without letting them propagate like wildfire.
By taking responsibility for errors, rather than ignoring or hiding them, we become better engineers. We write better systems. And ultimately, we build software that earns trust.