JavaRush /Courses /Go SELF /Errors: text, wrapping, user messages

Errors: text, wrapping, user messages

Go SELF
Level 37, Lesson 2
Available

1. Two audiences for errors

If you look at code through a beginner’s eyes, it may seem like an error is just a string we show on the screen. But in Go, an error is a value, and it usually contains two things at once: a human-readable explanation and the technical reason why the program could not continue. That’s why the same error can matter both to a person (“why didn’t it work?”) and to the program (“what exactly broke, can we retry, can we handle it specially?”).

In real development these two audiences often conflict. The user should not read a wall of internal details (paths, SQL, addresses, internal types). And for the developer, those details are gold, because they show where and what happened. Our goal is to learn to satisfy both needs at the same time, but carefully.

As a mental model, it helps to keep a simple picture in mind:

flowchart TD
    A[Low level: parsing, working with data] -->|returns err| B[Business logic]
    B -->|wrap: adds context| C[Application boundary: main/Run]
    C -->|user message| D[User]
    C -->|reason chain| E[Logs/diagnostics]

Errors travel bottom-up, accumulate context, and at the application boundary we decide what to tell the user and what to keep “inside”.

2. Error text and operation context

Error text style

Error text is what a person will most often see. So it should be short, clear, and “chainable” into a sequence if the error bubbles up the call stack. In Go it’s customary to write error messages in lowercase, without a period at the end and without prefixes like Error:—because errors often form a chain, and then the text should read naturally.

Let’s start with a simple example from our training “todo” application. Suppose we normalize a task title:

package todo

import (
	"errors"
	"strings"
)

var ErrEmptyTitle = errors.New("title is empty")

func NormalizeTitle(title string) (string, error) {
	title = strings.TrimSpace(title)
	if title == "" {
		return "", ErrEmptyTitle
	}
	return title, nil
}

What matters here is that ErrEmptyTitle is not a “user message”, but a cause. Its text should still be human-readable because a developer will see it, but it primarily serves for recognition and diagnostics.

If we want to add context, we usually do it at a higher level:

package todo

import "fmt"

func AddTask(rawTitle string) error {
	title, err := NormalizeTitle(rawTitle)
	if err != nil {
		return fmt.Errorf("add task: %w", err)
	}

	_ = title // saving will be added later
	return nil
}

Now, if the title is empty, a person (developer) will see a chain like add task: title is empty. It reads like a route: “during task addition—the title is empty”.

Operation context as a route through the code

One of the most frustrating beginner mistakes is returning a “bare” cause with no context. For example, deep down strconv.Atoi failed, and all that bubbles up is invalid syntax. Formally that’s true, but practically useless: what exactly were we trying to parse, and where?

So a good style is to add operation context as the error rises. And context is not “200 more characters”, but a short label that answers: “what were we doing when this broke?”

Let’s compare two approaches using parsing a task id as an example.

Variant without context (bad for diagnostics):

package todo

import "strconv"

func ParseID(s string) (int, error) {
	return strconv.Atoi(s)
}

Variant with context (usually better):

package todo

import (
	"fmt"
	"strconv"
)

func ParseID(s string) (int, error) {
	id, err := strconv.Atoi(s)
	if err != nil {
		return 0, fmt.Errorf("parse id %q: %w", s, err)
	}
	return id, nil
}

If s = "abc", we’ll get an error that’s useful to a person (“we tried to parse an id and here is the value”) and useful to code (because the cause is preserved via %w).

3. Wrapping and the cause chain

How %w works and why it matters

An important turn: wrapping an error (wrapping) is not the same as “adding text”. In Go 1.13, fmt.Errorf got the %w verb, which makes the returned error a “wrapper” over the original: a cause chain is created, accessible via errors.Is, errors.As, and errors.Unwrap.

An example worth learning by heart:

package todo

import (
	"errors"
	"fmt"
)

var ErrNotFound = errors.New("not found")

func LoadTask(id int) error {
	// imagine the store did not find the task
	return fmt.Errorf("load task %d: %w", id, ErrNotFound)
}

Why does this matter? Because now outer code doesn’t need to parse a string; it can check the cause properly:

package main

import (
	"errors"
	"fmt"

	"example/todo"
)

func main() {
	err := todo.LoadTask(10)
	fmt.Println(err) // load task 10: not found

	if errors.Is(err, todo.ErrNotFound) {
		fmt.Println("task not found") // task not found
	}
}

errors.Is can “walk down” the wrapper chain and look for a matching cause. In the simple case it’s like comparing to a sentinel error, but wrapper-aware.

A similar story with errors.As: it’s a “wrapper-aware type assertion”, i.e., a way to extract a typed error from the chain. Even if you rarely use typed errors today, it’s important to understand the idea: %w is not about prettier text, but about making the cause available to code.

When you should not wrap

This is where adult life starts. Wrapping is not a decision “for author convenience”, but “about the contract”. If you wrap an error via %w, you give the caller the ability to depend on a specific cause (for example, sql.ErrNoRows or an internal filesystem error). And that means you’re almost signing an agreement: “I will keep returning this cause in the future”.

A classic example from Go discussions: if your package uses database/sql as an internal detail, and you return an error outward such that it can be recognized as sql.ErrNoRows, then users of your package will start writing errors.Is(err, sql.ErrNoRows). Later you’ll want to replace the library—and suddenly it becomes a breaking change.

It helps to phrase it like this: wrap or not wrap is an API decision, similar to “export a struct field or not”. That is, sometimes you should wrap so that code can react (for example, not found), and sometimes you should hide internals and return an error with the same text but without the ability to unwrap.

To feel the difference, compare two variants:

// Variant A: keep the cause for code (wrap)
return fmt.Errorf("load task: %w", err)
// Variant B: keep only the text (do not wrap)
return fmt.Errorf("load task: %v", err)

To a person they look the same from the outside (same text), but for a program they are different worlds: in variant A the cause is available via errors.Is/errors.As, in variant B it is not.

4. User message and cause for logs

At this point the eternal question usually appears: “So should we show the user err.Error() as-is or not?” The answer depends on who the user is and what layer of the application we’re in. Inside business logic, we return errors as causes plus context, and that is almost always technical language. But at the application boundary we turn causes into short, stable, and friendly messages.

Key idea: the user message must be stable because it’s part of UX. And the “cause” (the errors chain) must be rich because it’s part of diagnostics. And it’s better not to mix these things into the same text.

Imagine we have three typical causes:

  • task not found,
  • invalid input,
  • internal error (unexpected).

We can keep this as a mapping table (this is easier than trying to “guess from the string”):

Cause (for code) What to tell the user
ErrNotFound
not found
ErrEmptyTitle
title must not be empty
everything else
internal error

Let’s implement a “translator” function in our todo package. Note: we use errors.Is because the error can be wrapped many times.

package todo

import "errors"

func UserMessage(err error) string {
	switch {
	case err == nil:
		return "ok"
	case errors.Is(err, ErrNotFound):
		return "not found"
	case errors.Is(err, ErrEmptyTitle):
		return "title must not be empty"
	default:
		return "internal error"
	}
}

Now we get a nice contract: internal functions return diagnosable errors, and the outer layer decides what exactly to show the user.

5. Mini-todo example

Now we’ll assemble a small but realistic slice of an application where everything is visible at once: text style, wrapping, and the split “for the user / for diagnostics”. Important: the examples are small, like LEGO bricks. In real life they’ll end up in different files, but the point is the same—an error should be useful at every level.

Let’s start with an in-memory store model and a “not found” error:

package todo

import "errors"

var ErrNotFound = errors.New("not found")

type MemoryStore struct {
	titles map[int]string
}

func NewMemoryStore() MemoryStore {
	return MemoryStore{titles: make(map[int]string)}
}

Add a read method that returns a cause (in this case a sentinel):

package todo

func (s MemoryStore) TitleByID(id int) (string, error) {
	title, ok := s.titles[id]
	if !ok {
		return "", ErrNotFound
	}
	return title, nil
}

Now add a “service” layer that provides operation context. This is exactly where wrapping belongs:

package todo

import "fmt"

type Service struct {
	store MemoryStore
}

func NewService(store MemoryStore) Service {
	return Service{store: store}
}

func (s Service) GetTitle(id int) (string, error) {
	title, err := s.store.TitleByID(id)
	if err != nil {
		return "", fmt.Errorf("get title %d: %w", id, err)
	}
	return title, nil
}

If the task is not found, the error becomes get title 5: not found. For diagnostics this is more useful than just not found, because now you can see which step you were at.

What’s left is to show the “application boundary”. Suppose we have Run() (a conditional top level) that prints one thing to the user and keeps another for itself. Here, for simplicity, we print “diagnostics” to stdout, but the idea is: for the user—short; for the developer—detailed.

package main

import (
	"fmt"

	"example/todo"
)

func main() {
	store := todo.NewMemoryStore()
	svc := todo.NewService(store)

	_, err := svc.GetTitle(5)
	if err != nil {
		fmt.Println(todo.UserMessage(err)) // not found
		fmt.Printf("debug: %v\n", err)     // debug: get title 5: not found
		return
	}

	fmt.Println("ok") // ok
}

In this mini-example you can already see the right architectural habit: we don’t force the user to read “get title 5”, but we also don’t lose information for diagnostics.

6. Typical mistakes

Mistake #1: returning errors “without a route”.
When a function returns only the root cause like invalid syntax without operation context, you’re planting a mine for your future self: a week later you’ll see this text and guess what you were parsing and where the value came from. Usually one careful fmt.Errorf("parse id %q: %w", s, err) is enough to make the error dramatically more useful.

Mistake #2: thinking %w “prints nicer”.
%w is not for prettier text; it is for preserving the cause in the chain so that errors.Is/errors.As work correctly. If you don’t need outer code to recognize the original cause, sometimes it’s more correct to use %v and keep only the text.

Mistake #3: wrapping everything and accidentally leaking internals.
Wrapping is an API decision: if you give outward unwrap access to another package’s error, you allow users to depend on it. Later you may end up trapped by compatibility, where changing the implementation becomes painful. In such places, it’s better to explicitly decide: is this part of the contract or an internal detail?

Mistake #4: showing the user err.Error() as-is.
A technical error often contains unnecessary details, scary phrasing, or simply “noise” from a UX point of view. It’s much more reliable to build a small mapping layer from causes to short messages, and keep err (with context and the chain) for diagnostics at the application boundary.

Mistake #5: comparing errors with == once wrapping exists.
As soon as %w appears in a project, a direct comparison err == ErrNotFound stops working as expected, because err is no longer equal to the marker; it only contains it in the chain. In such cases you almost always need to switch to errors.Is(err, ErrNotFound).

1
Task
Go SELF, level 37, lesson 2
Locked
Task counter
Task counter
1
Task
Go SELF, level 37, lesson 2
Locked
Parsing input
Parsing input
1
Task
Go SELF, level 37, lesson 2
Locked
Message and Cause
Message and Cause
1
Task
Go SELF, level 37, lesson 2
Locked
Two similar texts
Two similar texts
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION