1. Why choose at all: “marker” or “type”?
Errors in Go are not just “a message to show to the user”. They are also a contract between functions: what exactly the caller is allowed to recognize and how it is allowed to branch behavior. If you do not think through this contract, you will quickly slide into strings.Contains(err.Error(), "..."), and that is almost like fixing a spaceship with duct tape: sometimes it holds, but it is embarrassing and scary.
Imagine you are writing a small CLI for tasks (our training app), and you need to distinguish situations: “the user typed nonsense” and “there is no task with such an id”. In one case you want to suggest the correct input, in the other you want to say “not found”. And here you hit a fork: how exactly do you give the calling code a way to understand that?
In Go, there are two most popular “recognizable” approaches:
- Sentinel error: a pre-declared “marker” error value (var ErrNotFound = errors.New("not found")), which is then checked via errors.Is.
- Typed error: the error is recognized by a specific type, which can be extracted via errors.As (for example, *strconv.NumError from strconv.Atoi).
Next we will learn to choose between them deliberately.
2. Sentinel errors: recognize the situation, not the details
A sentinel error is when you create one error value and use it as a “label”. It is like a "NOT FOUND" stamp in the error’s passport: the calling code does not care what the story is or who is to blame—it cares that this is exactly the class of situation for which it should choose a clear behavior. In Go, this has historically been a very common and simple way to make an error checkable.
How to declare a sentinel and why you must not call errors.New inside a function
A sentinel works only if it is the same value, not “a new error with the same text every time”. In Go, two errors with the same text are not “the same error”, but two different values that are not equal to each other. That is why marker errors are declared at the package level, once, and then reused.
Let’s make two examples: a good one and a bad one. They are intentionally short—so you can literally see where “recognizability” breaks.
package main
import (
"errors"
"fmt"
)
var ErrNotFound = errors.New("not found")
func good() error {
return ErrNotFound
}
func main() {
err := good()
fmt.Println(err == ErrNotFound) // true
}
And now “looks similar, but no”:
package main
import (
"errors"
"fmt"
)
var ErrNotFound = errors.New("not found")
func bad() error {
return errors.New("not found") // new error each time
}
func main() {
err := bad()
fmt.Println(err == ErrNotFound) // false
}
That is why a sentinel is exactly a value (one object), not just text.
Sentinel + wrapping: why "ctx: %w" is better than returning ErrX directly
If you return a sentinel directly, people will be tempted to write err == ErrNotFound. But as soon as you want to add context via wrapping, such comparisons will start to break, and you will get “API drift”: someone already depends on ==, someone uses errors.Is, and you end up with a fun zoo.
So it is better to return an error that wraps the sentinel, so external users immediately get used to errors.Is.
package main
import (
"errors"
"fmt"
)
var ErrPermission = errors.New("permission denied")
func checkPermission(ok bool) error {
if !ok {
return fmt.Errorf("check permission: %w", ErrPermission)
}
return nil
}
func main() {
fmt.Println(checkPermission(false)) // check permission: permission denied
}
Here the string became “human-friendly”, but the cause remains checkable.
When a sentinel starts getting in the way
A sentinel is a great tool, but it has a cost: it answers “what class of situation is this?” well, but answers “what are the details?” poorly. If you need to distinguish dozens of varieties of “not found”, you will either start multiplying ErrNotFoundUser, ErrNotFoundTask, ErrNotFoundProject, or you will slide into passing details through text (and hello again, strings.Contains).
Another subtle problem: if you wrap and expose foreign errors (from another library), you are effectively promising: “this specific low-level error will be part of my contract forever”. Wrapping makes the cause available for programmatic checks, and therefore part of your API. If it is an internal implementation detail, it is better not to expose it.
3. Typed errors: you need details, not just a label
A typed error is the approach “I recognize the error by its type”. In everyday speech it sounds like: “this is not just a ‘parsing error’, this is specifically *strconv.NumError — meaning we parsed a number and got non-digit input”. This approach is especially appropriate when an error carries useful technical details and you want to extract them without analyzing a string.
Important: here we are not designing our own complex error types as a full model (that is a separate topic). But we must be able to read and use typed errors from the standard library—they are already all around us.
How a typed error is recognized: errors.As(err, &target)
errors.As means “give me an error of this type, if it exists in the chain”. It walks the error chain and tries to find a value of the desired type. If it finds one, it writes it into the target variable (that is why we pass &target).
Mini demo (we intentionally add wrapping to see that As still pulls out the cause):
package main
import (
"errors"
"fmt"
"strconv"
)
func main() {
_, err := strconv.Atoi("12x")
err = fmt.Errorf("parse id: %w", err)
var numErr *strconv.NumError
if errors.As(err, &numErr) {
fmt.Println("typed cause:", numErr.Error()) // typed cause: strconv.Atoi: parsing "12x": invalid syntax
}
}
Notice: we did not compare strings, we did not search for substrings. We said: “if there is a *strconv.NumError inside, extract it”.
Example from the app: parse a task id and want “smart” output
Imagine our CLI accepts a command like "done 10" (mark the task as done). We parse "10" via strconv.Atoi. If the user enters "done ten", then Atoi returns a typed error *strconv.NumError.
We write a function parseTaskID that adds context while preserving the typed cause:
package main
import (
"fmt"
"strconv"
)
func parseTaskID(s string) (int, error) {
id, err := strconv.Atoi(s)
if err != nil {
return 0, fmt.Errorf("parse task id %q: %w", s, err)
}
return id, nil
}
At this level we are not yet deciding what to show the user. We just honestly keep the cause.
Now at the top level we can produce more useful diagnostics (for ourselves, for logs, or simply for debugging while learning): if the cause is exactly *strconv.NumError, then the user entered a non-number.
When a typed error turns into a leak of implementation details
Typed errors are powerful, but they often “bind” external code to a specific library. Today you use strconv.Atoi, tomorrow you decide to parse differently—and suddenly external code can no longer recognize the error in the same way.
With wrapping there is an even sharper corner: if you wrap an error from a foreign package using "%w", you allow external code to write errors.Is(err, sql.ErrNoRows) (or use errors.As for specific types), and you thereby lock that foreign type/value in as part of your contract. If you are not ready to support that as part of your API, it is better not to make such a cause “checkable” at the external level.
4. How to choose: sentinel or typed
If you are thinking “can I always use typed because it is cooler?”, you are not the first. Many people think that until they try living with it for a year or two while changing implementations. And if you are thinking “let’s always use sentinel because it is simpler”, you are also not the first—and you will also hit a wall when you need details.
To avoid guessing, it helps to keep a simple criteria table in mind:
| Question | If the answer is “yes” | What to usually choose |
|---|---|---|
| Is it enough for external code to know the class of situation (not found, validation, no permission) without details? | Yes | Sentinel error + errors.Is |
| Does external code need cause details (for example, what exactly failed to parse, what format was expected)? | Yes | Typed error + errors.As |
| Did the error come from a library that is an implementation detail, and you do not want to promise it externally? | Yes | Do not wrap a foreign error as part of the contract; more often, “repackage” it into your own format (or at least do not use "%w") |
| Is the error part of your public contract and must be recognized stably even if the implementation changes? | Yes | A sentinel at your level (or your own typed error, but those must be designed carefully) |
| Do you want the calling code to be able to write a simple check without “internals”? | Yes | Sentinel (as the minimal contract) |
And there is a very practical “rule of three questions” that you can almost say out loud (yes, programmers talk to code—this is normal as long as the code does not answer):
First question: “Does the caller need to branch behavior based on this error?” If no—plain text is enough. If yes—keep going.
Second question: “Is branching by the class (validation/not found) sufficient?” If yes—sentinel. If no—you probably need typed.
Third question: “Am I ready to promise this type/value externally as part of the API?” If no—do not make this error recognizable at this level (or make it recognizable via your own sentinel class, and keep details inside).
5. Mixed approach: sentinel class and typed cause
Life rarely forces you to choose strictly one. Often the best design looks like this: “externally we expose the class via a sentinel, and internally we preserve the technical cause as a typed error”. This lets the top level tell the user “invalid input”, while a developer (or logs) can see what exactly broke, without losing the chain of causes. This approach pairs well with wrapping and Is/As checks.
Add two error “classes” to the app
For our task CLI, we will create two markers: ErrValidation and ErrNotFound. We do not make them super-detailed: they are behavior classes.
package main
import "errors"
var (
ErrValidation = errors.New("validation error")
ErrNotFound = errors.New("not found")
)
These errors are convenient because the UI layer (for now it is just main) can decide: print an input hint or report “not found”.
parseTaskID: combine the class and the typed cause
Now we implement parseTaskID so that on a parsing error it returns both the class and the cause. The simplest way is errors.Join, but even without it you can wrap the class around the cause. We will use errors.Join because it neatly stores multiple causes in one error and remains checkable via Is/As.
package main
import (
"errors"
"fmt"
"strconv"
)
func parseTaskID(s string) (int, error) {
id, err := strconv.Atoi(s)
if err != nil {
// Class + specific cause (typed) are preserved together
return 0, fmt.Errorf("parse task id: %w", errors.Join(ErrValidation, err))
}
return id, nil
}
An important effect: errors.Is(err, ErrValidation) will be true, and at the same time errors.As(...) can also work, because the *strconv.NumError inside is preserved.
Top level: behavior by sentinel and diagnostics by typed
Let’s write a small demo main that simulates the "done <id>" command and shows two levels of handling.
package main
import (
"errors"
"fmt"
"strconv"
)
func main() {
// Imagine the user entered "done ten"
rawID := "ten"
_, err := parseTaskID(rawID)
if err == nil {
fmt.Println("ok")
return
}
// User-facing behavior — by CLASS (sentinel)
if errors.Is(err, ErrValidation) {
fmt.Println("input error: id must be an integer") // input error: id must be an integer
}
// Developer diagnostics — by TYPE (typed)
var numErr *strconv.NumError
if errors.As(err, &numErr) {
fmt.Println("debug:", numErr.Error()) // debug: strconv.Atoi: parsing "ten": invalid syntax
}
}
Notice the “separation of roles”. Sentinel answers: “what should we tell the user?”, while a typed error answers: “what exactly broke inside?”. This is very Go-like: the person gets a clear message, and the code does not lose the structure of the cause.
6. Common mistakes when choosing sentinel vs typed
Mistake #1: “I distinguish errors by string because it is faster”.
At first it looks like strings.Contains(err.Error(), "not found") is a quick hack. Then you change the error text (or the library changes it), and suddenly your business logic starts behaving differently. In Go, it is idiomatic to distinguish errors via errors.Is (for markers) and errors.As (for types), because it relies on structure, not on text.
Mistake #2: creating a sentinel inside a function.
This is a classic trap: “I return errors.New("not found"), why does errors.Is not work?”. Because it is a new object every time. A sentinel must be a single value declared once at the package level, otherwise recognizability disappears.
Mistake #3: returning a sentinel directly and encouraging err == ErrX.
Today you return ErrNotFound, tomorrow you want to add context—and all == comparisons break. It is much more stable to return fmt.Errorf("ctx: %w", ErrNotFound) from the start so consumers get used to errors.Is.
Mistake #4: accidentally making typed errors part of the public contract.
You wrapped an error from a foreign package using "%w", and suddenly external code started checking its type or a sentinel from that foreign library. Now you are effectively obligated to keep that library (or at least that error type), otherwise you will break users. Wrapping in this form becomes an “API promise”, and it must be treated carefully.
Mistake #5: using errors.As where errors.Is is enough.
errors.As is a tool to “extract details”, not to “check a class”. If you just need to understand “is this validation or not” — use a sentinel and errors.Is. Otherwise the code quickly becomes overloaded with details that do not affect behavior, and reading turns into archaeology.
GO TO FULL VERSION