1. Why typed errors, if we have errors.New and fmt.Errorf
If you’re just getting used to Go, it’s perfectly natural to think like this: “Well, an error is just text. Why build a struct if you can write fmt.Errorf("invalid id") and go have some tea?” That thought is logical—until the application starts living a bit longer than one evening, and error handling becomes part of the UX (messages to the user) and part of the logic (different reactions to different causes).
The problem with “error as a string” is that a string is a poor fit for decision-making. If you want to distinguish “not found” from “invalid” from “file read problem,” then comparing strings is like determining a dog’s breed by its bark: sometimes it works, but the neighbors are already scared.
A typed error solves this very straightforwardly: the error remains a value, but becomes a structured value.
Here’s a small table to lock in the difference:
| Approach | Example | Pros | Cons |
|---|---|---|---|
| “Text only” | |
fast, simple | hard to distinguish causes; makes you want to parse strings |
| Typed error (struct) | |
can store context in fields; easy to branch on type | a bit more code; you need to think through the design |
| Wrapper with a cause | |
adds operation context and preserves the cause | you must decide whether the cause can be “exposed” to the outside |
What is a typed error
A typed error is a regular struct (struct) that has an Error() string method. No magic, no inheritance, no special keywords. In Go, “being an error” means only one thing: being able to describe yourself as a string.
Let’s start with a simple example. Imagine we continue our training app (a hypothetical CLI “tasker”), where there are operations on a task list. For example, the user enters a task ID as text, and we need to validate the input.
package tasker
import "fmt"
type InvalidIDError struct {
Input string // what the user entered
}
func (e InvalidIDError) Error() string {
return fmt.Sprintf("invalid id %q", e.Input)
}
Two things are important to notice here.
First: context is no longer only in the text. We have the Input field, and it’s available to code—not to a human, but to code. That means later you can, for example, show the user one message, log another, or highlight a specific field.
Second: we intentionally keep Error() short and “Go-style”: no period at the end and no “Error:”. This isn’t formality; it’s a habit that makes messages consistent.
Now such an error is used like a regular error:
package tasker
import "strconv"
func ParseID(s string) (int, error) {
id, err := strconv.Atoi(s)
if err != nil {
return 0, InvalidIDError{Input: s}
}
return id, nil
}
2. Designing typed errors: Error() and fields
Error() is for humans; fields are for the program
When you start writing typed errors, it’s very tempting to stuff everything into Error(): “the operation is such-and-such, the user is such-and-such, the id is such-and-such, stack trace, the moon in Capricorn.” But a good Error() is usually a compact line that’s pleasant to see in the console. Everything else is better stored in fields.
Why? Because Error() is an interface contract: “give me a string.” It does not promise a stable format for machine parsing. And, even more importantly, a human might want to see one thing, while the program needs to make a decision based on something else.
In Go, it’s explicitly emphasized that the responsibility of an error is to summarize context so the message is useful (for example, os.Open reports “open …: permission denied,” not just “permission denied”). Typed errors just take the next step: we summarize context in Error(), but preserve details in fields.
Example: “task not found” error in our tasker:
package tasker
import "fmt"
type TaskNotFoundError struct {
ID int
}
func (e TaskNotFoundError) Error() string {
return fmt.Sprintf("task %d not found", e.ID)
}
So far, it looks almost like “regular text,” but the power is that ID is available separately.
Typed errors for validation and domain rules
Let’s make a piece that’s a bit more “real life.” We have a task (Task) and the “mark done” command. The user passes an ID, and we must:
- parse the ID,
- find the task,
- mark it as done.
There are three different “classes” of errors here by meaning:
- invalid input (validation),
- task not found (not found),
- something broke internally (internal / I/O, etc.).
We’re not building a “full application error taxonomy” right now, but even in this small example you can see why typed errors are useful.
Let’s make three types:
package tasker
type ValidationError struct {
Field string
Msg string
}
func (e ValidationError) Error() string {
return "invalid " + e.Field + ": " + e.Msg
}
package tasker
import "fmt"
type TaskNotFoundError struct {
ID int
}
func (e TaskNotFoundError) Error() string {
return fmt.Sprintf("task %d not found", e.ID)
}
package tasker
type OpError struct {
Op string
Err error
}
func (e *OpError) Error() string {
return e.Op + ": " + e.Err.Error()
}
The first two are pure typed errors “about the domain.” The third is an operation wrapper: “we were doing X, and Y happened there.”
3. Value receiver vs. pointer receiver in Error()
Now there will be a bit where Go shows its “love of precision.” You can write Error() as a value method (func (e T) Error() string) or as a pointer method (func (e *T) Error() string). Both are possible, but they behave differently.
The idea is simple: if Error() is defined on *T, then only *T implements the error interface, and T does not. This is not “the compiler being pedantic”; it’s a direct consequence of the method set, which you’ve already touched earlier.
Mini demonstration:
package main
import "fmt"
type MyError struct{}
func (e *MyError) Error() string { return "boom" }
func main() {
// var err error = MyError{} // won't compile
var err error = &MyError{} // ok
fmt.Println(err) // boom
}
A practical rule for a beginner sounds like this: if the error is small, immutable, and doesn’t contain an “internal cause,” it’s convenient to define Error() on a value receiver and return a value. If the error contains a nested Err error, or if you want to avoid copying (rarely relevant for errors, but it happens), you can use a pointer receiver—but then be careful to return the pointer.
4. Error wrappers and Unwrap()
When errors bubble up the stack, we almost always want to add context. The simplest option is fmt.Errorf("what we were doing: %w", err), which provides wrapping and makes the cause available to errors.Is/errors.As.
But sometimes it’s more convenient to have your own wrapper with fields (for example, Op, ID, Filename)—and that’s where Unwrap() comes in.
In Go 1.13, a convention was established: if an error contains another error and you want outside code to be able to reach the cause, implement the method Unwrap() error, which returns the nested error. This is so standard that errors.Is and errors.As are built around it.
Let’s improve our OpError:
package tasker
type OpError struct {
Op string
Err error
}
func (e *OpError) Error() string { return e.Op + ": " + e.Err.Error() }
func (e *OpError) Unwrap() error { return e.Err }
Now we get an “error chain”: the outer layer says “which operation,” the inner says “what exactly went wrong.”
It’s very important to understand the philosophy of this decision: implementing Unwrap() is almost like “exporting a field”: you make the cause part of the contract. Sometimes that’s correct; sometimes it’s not. In materials about errors in Go, it’s emphasized separately that wrapping makes the internal error observable to code, which means you’re effectively promising to “preserve” that error type in the future.
To lock in the mechanics, here’s a small “layer cake” error diagram:
flowchart TD
A[CLI handler: done command] -->|calls| B[app.MarkDone]
B -->|calls| C[storage.FindByID]
C -->|returns| E1[TaskNotFoundError]
B -->|wraps| E2["OpError{Op:'mark done', Err:E1}"]
A -->|prints err| OUT[stderr/stdout]
5. Example: wrapping errors in tasker
Now let’s assemble a mini fragment “from the command handler to the storage.” No extra architecture—just to see typed errors in motion.
Suppose we have a storage interface (we won’t discuss it deeply; errors are what matters to us):
package tasker
type Storage interface {
MarkDone(id int) error
}
Application-level function:
package tasker
func MarkDone(s Storage, rawID string) error {
id, err := ParseID(rawID)
if err != nil {
return &OpError{Op: "parse id", Err: err}
}
if err := s.MarkDone(id); err != nil {
return &OpError{Op: "mark done", Err: err}
}
return nil
}
Notice how “pleasant” this is to read: each wrapper adds meaning—“where we were”—and the cause is preserved.
Storage can return a domain error if the task doesn’t exist:
package tasker
type InMemoryStorage struct {
done map[int]bool
}
func (s *InMemoryStorage) MarkDone(id int) error {
if _, ok := s.done[id]; !ok {
return TaskNotFoundError{ID: id}
}
s.done[id] = true
return nil
}
Now from the outside you print err and see something like:
- mark done: task 10 not found
And inside (in the chain) you still have a concrete TaskNotFoundError type, and you can distinguish it from ValidationError. This will be useful for careful user-facing messages (but we intentionally won’t go deeper into the extraction mechanism from the chain in this lecture).
Why this is better than comparing strings
So your brain doesn’t perceive typed errors as “just another style,” let’s honestly compare two realities.
In reality #1, you return fmt.Errorf("task not found"). In the command handler, you need to understand: is this “not found” or did something “break”? And you start doing something like strings.Contains(err.Error(), "not found"). It looks like programming, but in fact you’re building a house out of wet cardboard: change the text and you break the logic.
In reality #2, you have TaskNotFoundError{ID: ...}. The message text can change, but the type remains the type, and the ID field remains ID. That’s why the Go community so actively uses errors as values and as structs, not only as strings.
By the way, you can see the same approach in the standard library live: network operation errors are structs that contain other errors, and sometimes code “peels” them by layers to understand the real cause. This isn’t “complexity for complexity’s sake”; it’s a way to make error handling manageable.
6. Common mistakes when designing typed errors
Mistake #1: store all context only in Error(), then try to parse the string.
This is a common trap: you seemingly made a typed error, but you still shoved details into the text and then compare strings or search substrings. If an error must carry data for logic, that data must live in fields (ID, Field, Op, Filename), and Error() should be a human-readable summary.
Mistake #2: choose a pointer receiver for Error(), but return a value rather than a pointer.
When Error() is declared on *T, the value T{...} does not implement error. In the best case, the compiler catches it immediately; in the worst case, you start “treating” the wrong place. If you made func (e *MyErr) Error() string, return &MyErr{...}.
Mistake #3: make Error() too long and unstable.
Sometimes you want to put half a log in there: JSON, fields, hints, “call your administrator.” As a result, the error starts living its own life: somewhere it’s printed to the user, somewhere it ends up in tests, somewhere it’s compared as a string. It’s much calmer to keep Error() short and provide details via fields and/or via a separate message rendering at the application boundary.
Mistake #4: add Unwrap() automatically.
Unwrap() makes the internal error observable from the outside; it’s almost an API promise. If the cause is an implementation detail and you don’t want external code to depend on it, it’s better not to expose it via Unwrap() (or to wrap differently). In materials about errors in Go, it’s emphasized that the decision to “wrap or not wrap” affects your code’s contract.
Mistake #5: spawn typed errors for every little thing without a reason.
A typed error is justified when it helps you make a decision or deliver correct UX: highlight a field, distinguish not-found from validation, show a clear message. If an error is never analyzed and carries no useful data, it’s simpler and more honest to keep fmt.Errorf/errors.New —your code will be smaller and clearer.
GO TO FULL VERSION