1. Why extract error context at the application boundary
When you write a program, sooner or later a boundary appears where your code meets the real world: a user typed something, an HTTP client sent something, a file suddenly turned out to be “not there,” and the network remembered that it is, in fact, a network. And right here a timeless dilemma shows up: what to show the user and what to leave for the developer. The user wants it short and to the point; the developer wants maximum detail to fix the bug and not cry.
Imagine you are building the simplest “task tracker”: a user creates a task but enters an empty title. Inside the code the error might look like validation failed: title is empty. But the user is better off seeing: “Task title cannot be empty.” And if this is an HTTP API, then you also want a neat JSON response with error fields.
In both cases we solve the same problem: translate an error into a UX message at the boundary without losing context for diagnostics.
How the error chain works
It is important to understand why errors.As can “pull out” the needed error even if several layers of context are wrapped around it. In Go this is done through a cause chain, where the outer layer adds context but does not destroy the root cause.
Go 1.13 formalized this approach: if an error “wraps” another one, it should be able to “unwrap” it (Unwrap()), and the standard functions (errors.Is, errors.As) can walk the chain automatically.
Conceptually, it looks like this:
flowchart TD
A[app/usecase returns error] --> B[wrap with context: fmt.Errorf %w]
B --> C[one more layer: OpError or another wrapper]
C --> D[application boundary: CLI/HTTP]
D --> E[errors.As extracts typed error]
E --> F[build user message / HTTP response]
The key idea: inner code can add context like “where it failed” and “what we were doing,” and at the boundary we can recognize the error type and use its fields to provide a reasonable response to the user.
errors.As: finding a typed error and why &target is required
errors.As solves the problem: “find, in the error chain, an error that can be assigned to a variable of a certain type.” And if it is found, place it into the variable so we can read fields and make a decision.
From this comes the main syntactic nuance: in errors.As(err, &target) we pass the address of the variable. Not the address of the error and not a “double pointer as a philosophy of life,” but simply “where to write the found value.”
A very small but illustrative example:
package main
import (
"errors"
"fmt"
)
type ValidationError struct {
Field string
Msg string
}
func (e ValidationError) Error() string {
return "invalid " + e.Field + ": " + e.Msg
}
func main() {
err := fmt.Errorf("create task: %w", ValidationError{Field: "title", Msg: "empty"})
var ve ValidationError
if errors.As(err, &ve) {
fmt.Println(ve.Field) // title
}
}
Here &ve literally means: “take the address of variable ve so that errors.As can write the found error there.”
Pointer errors: when the chain contains *T
If the error in your chain has type *MyError, then the target variable must be of type *MyError, and in errors.As you pass &target. It looks scarier than it is: technically it is “the address of a pointer variable,” but semantically it is the same thing—“where to write.”
package main
import (
"errors"
"fmt"
)
type NotFoundError struct {
ID int
}
func (e *NotFoundError) Error() string {
return fmt.Sprintf("task %d not found", e.ID)
}
func main() {
err := fmt.Errorf("get: %w", &NotFoundError{ID: 7})
var nf *NotFoundError
if errors.As(err, &nf) {
fmt.Println(nf.ID) // 7
}
}
2. Mini task tracker: typed errors, wrapping, and translating to UX
To avoid discussing errors in a vacuum (in a vacuum very little works, except manager promises), let’s build a small application skeleton. Let it be a mini task tracker where you can create a task. No database, no HTTP—just functions and errors to practice the mechanics.
Application skeleton: model and “in-memory” storage
Let’s start with the model and an “in-memory” store:
package main
type Task struct {
ID int
Title string
Done bool
}
var tasks []Task
var nextID = 1
ValidationError: an error with context stored in fields
Now let’s define a typed error for validation. Note: we put context into fields instead of trying to “stuff the whole universe into a string.”
package main
import "fmt"
type ValidationError struct {
Field string
Msg string
}
func (e ValidationError) Error() string {
return fmt.Sprintf("invalid %s: %s", e.Field, e.Msg)
}
And we’ll write the AddTask function that validates data and returns an error:
package main
import "fmt"
func AddTask(title string) (Task, error) {
if title == "" {
return Task{}, ValidationError{Field: "title", Msg: "must not be empty"}
}
t := Task{ID: nextID, Title: title}
nextID++
tasks = append(tasks, t)
fmt.Println("created task:", t.ID) // created task: 1
return t, nil
}
So far everything is simple. But the real power starts when we add wrapping so we do not lose context like “which step failed.” This is especially important when the same error can happen in different operations.
Wrapper OpError and Unwrap(): operation context without losing the cause
Let’s have a wrapper that adds the operation name (for example, add task or save task). Such a type is sometimes called OpError. It is useful when you want logs to show “where it failed” while preserving the root cause.
This is a classic pattern: the error stores another error inside, and Unwrap() returns it. Exactly through this mechanism errors.As can “reach” the desired type deep in the chain.
package main
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 let’s update AddTask so it adds context:
package main
import "fmt"
func AddTask(title string) (Task, error) {
if title == "" {
base := ValidationError{Field: "title", Msg: "must not be empty"}
return Task{}, &OpError{Op: "AddTask", Err: base}
}
t := Task{ID: nextID, Title: title}
nextID++
tasks = append(tasks, t)
fmt.Println("created task:", t.ID) // created task: 1
return t, nil
}
From the outside, the error looks like AddTask: invalid title: must not be empty. That is useful for the developer. But the user in a CLI (or the client over HTTP) does not necessarily need to see AddTask—it does not help them.
Presenter at the boundary: converting an error into a UX format
Now we are getting to the point: at the application boundary we need a layer that converts any error into a clear and stable message. “Stable” means: if tomorrow you rename AddTask to CreateTask, the user message must not suddenly change from “Title is empty” to CreateTask: invalid title…
Let’s create a UserError struct that reflects what matters to UX. For HTTP it can easily be turned into JSON (code, message, fields); for CLI it is just text.
package main
type UserError struct {
Code string // "validation", "not_found", "internal"
Message string // short for the user
Fields map[string]string // validation details (if any)
}
Now let’s write ToUserError(err error) UserError. Inside it, we will use errors.As to pull typed errors by type.
package main
import "errors"
func ToUserError(err error) UserError {
var ve ValidationError
if errors.As(err, &ve) {
return UserError{
Code: "validation",
Message: "Check the data you entered",
Fields: map[string]string{ve.Field: ve.Msg},
}
}
return UserError{
Code: "internal",
Message: "Could not complete the operation",
}
}
The philosophy here is important: if the error is unknown, we do not expose its technical text. The technical text can contain internal details you do not want to turn into a public contract.
Two audiences: short for the user, detailed for yourself
The developer still needs details. But giving details to the user is a bad idea. That is why a good boundary pattern looks like this:
- build UserError and show Message (and Fields if it is validation);
- separately log the original err (with the entire wrapper chain).
For simplicity, instead of a logger we will use printing:
package main
import "fmt"
func handleErrorAtBoundary(err error) {
ux := ToUserError(err)
fmt.Println("USER:", ux.Message) // USER: Check the data you entered
fmt.Println("DEBUG:", err.Error()) // DEBUG: AddTask: invalid title: must not be empty
}
In a CLI, “USER” usually goes to stderr, and “DEBUG” goes to logs. In HTTP, “USER” becomes the response body, and “DEBUG” is a server log. But the mechanics are the same: one message for the user, another for diagnostics.
Mini CLI: glue everything together in main
Let’s assemble a minimal program: read title from stdin, try to create a task, and on error generate a UX message.
package main
import (
"fmt"
)
func main() {
var title string
fmt.Scan(&title)
_, err := AddTask(title)
if err != nil {
handleErrorAtBoundary(err)
return
}
fmt.Println("OK") // OK
}
If the user enters an empty string (or nothing at all), we will see something like:
- for the user: “Check the data you entered”;
- for the developer: AddTask: invalid title: must not be empty.
NotFoundError: an example with errors.As and *T
To see a more “real-life” example, let’s add a function that fetches a task by ID. Here it is convenient to make the “not found” error a pointer type so we can practice the pattern var nf *NotFoundError; errors.As(err, &nf).
package main
import "fmt"
type NotFoundError struct {
ID int
}
func (e *NotFoundError) Error() string {
return fmt.Sprintf("task %d not found", e.ID)
}
Lookup:
package main
func FindTask(id int) (Task, error) {
for _, t := range tasks {
if t.ID == id {
return t, nil
}
}
return Task{}, &NotFoundError{ID: id}
}
Now let’s extend ToUserError:
package main
import "errors"
func ToUserError(err error) UserError {
var ve ValidationError
if errors.As(err, &ve) {
return UserError{
Code: "validation",
Message: "Check the data you entered",
Fields: map[string]string{ve.Field: ve.Msg},
}
}
var nf *NotFoundError
if errors.As(err, &nf) {
return UserError{
Code: "not_found",
Message: "Task not found",
}
}
return UserError{
Code: "internal",
Message: "Could not complete the operation",
}
}
Now we have clear UX branching: validation → one message, not found → another, everything else → “internal error.”
Why we do not build UX based on err.Error()
It is very tempting to do this:
if strings.Contains(err.Error(), "not found") { ... }
It feels fast, but it is a road to pain and legacy. The error text can change due to refactoring, localization, formatting changes, or even changes in the standard library.
Typed errors exist specifically so that logic is based on types and fields, not strings. So the sane plan is: the Error() text is for logs or for a developer to quickly see “what happened at all,” while for UX and branching we use errors.As/errors.Is and typed error fields.
3. Common mistakes when working with errors.As and UX messages
Mistake #1: passing a variable to errors.As instead of its address.
People often write errors.As(err, target) instead of errors.As(err, &target). As a result, the code either does not compile or never matches. The mental model is simple: errors.As must “write what it found” into a variable, so it needs “where to write,” i.e., the address.
Mistake #2: mixing up value errors and pointer errors.
If your error implements Error() on *T, then the actual error value will be *T. Then, in errors.As, the target must be var t *T, and you must pass &t. If you do var t T, then errors.As will not find a match, and you will spend a long time suspecting the universe of conspiring against you.
Mistake #3: showing the user the “raw” err.Error() with all the kitchen details.
Inside an error there can be operation names, file paths, internal IDs, and system error text. This does not make the user happier and sometimes also leaks extra information you would rather not expose. Good UX is a separate stable layer of messages; details live in logs and in the wrapper chain.
Mistake #4: forgetting a fallback for unknown errors.
If you only handle ValidationError and NotFoundError, and print everything else as-is, then at the first “real” crash the user will see something like dial tcp [::1]:... and decide they accidentally opened the developer terminal. Unknown errors need a neutral response (“could not complete the operation”), and details belong in the debug channel.
Mistake #5: trying to build UX logic on type assertions instead of errors.As.
A type assertion (err.(T)) only works if the outer error type is exactly T. But once you start wrapping errors via %w or via a wrapper with Unwrap(), the outer type is almost always different. Therefore, at the application boundary, type assertions often produce false “not recognized,” while errors.As walks the chain and finds what you need.
GO TO FULL VERSION