1. Две аудитории ошибок
Если смотреть на код глазами новичка, может показаться, что ошибка — это просто строка, которую мы показываем на экране. Но в Go ошибка — значение, и в ней обычно живут сразу две вещи: человекочитаемое объяснение и техническая причина, по которой программа не смогла продолжить. Именно поэтому одна и та же ошибка может быть важна и для человека (“почему не получилось?”), и для программы (“что именно сломалось, можно ли повторить, можно ли обработать по-особому?”).
В реальной разработке эти две аудитории часто конфликтуют. Пользователь не должен читать простыню из внутренних деталей (пути, SQL, адреса, внутренние типы). А разработчику, наоборот, эти детали — золото, потому что по ним он понимает, где и что произошло. Наша задача — научиться держать обе потребности одновременно, но аккуратно.
Для ориентира удобно держать в голове простую картинку:
flowchart TD
A[Низкий уровень: парсинг, работа с данными] -->|возвращает err| B[Бизнес-логика]
B -->|wrap: добавляет контекст| C[Граница приложения: main/Run]
C -->|user message| D[Пользователь]
C -->|reason chain| E[Логи/диагностика]
Ошибки путешествуют снизу вверх, обрастают контекстом, а на границе приложения мы решаем: что сказать пользователю и что оставить “внутри”.
2. Текст ошибки и контекст операции
Стиль текста ошибки
Текст ошибки — это то, что чаще всего увидит человек. Поэтому он должен быть коротким, понятным и “склеиваемым” в цепочку, если ошибка поднимается вверх по стеку вызовов. В Go принято писать сообщения ошибок строчными буквами, без точки в конце и без префиксов вроде Error: — потому что ошибки часто складываются в цепочку, и тогда текст должен читаться естественно.
Давайте начнём с простого примера из нашего учебного приложения “todo”. Пусть мы нормализуем заголовок задачи:
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
}
Здесь важно, что ErrEmptyTitle — не “сообщение пользователю”, а причина. Её текст всё равно должен быть человеческим, потому что его увидит разработчик, но она прежде всего служит для распознавания и диагностики.
Если мы хотим добавить контекст, мы обычно делаем это выше уровнем:
package todo
import "fmt"
func AddTask(rawTitle string) error {
title, err := NormalizeTitle(rawTitle)
if err != nil {
return fmt.Errorf("add task: %w", err)
}
_ = title // тут позже будет сохранение
return nil
}
Теперь, если заголовок пустой, человек (разработчик) увидит цепочку вроде add task: title is empty. Это читается как маршрут: “на этапе добавления задачи — заголовок пустой”.
Контекст операции как маршрут по коду
Одна из самых обидных ошибок новичка — вернуть “голую” причину без контекста. Например, где-то глубоко упал strconv.Atoi, и наверх прилетело просто invalid syntax. Формально это правда, но практически бесполезно: что именно пытались распарсить и где?
Поэтому хороший стиль — добавлять контекст операции по мере подъёма ошибки. Причём контекст — это не “ещё 200 символов”, а короткая подпись, которая отвечает на вопрос: “что мы делали, когда это сломалось?”
Сравним два подхода на примере парсинга id задачи.
Вариант без контекста (плохой для диагностики):
package todo
import "strconv"
func ParseID(s string) (int, error) {
return strconv.Atoi(s)
}
Вариант с контекстом (обычно лучше):
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
}
Если s = "abc", мы получим ошибку, которая одновременно полезна человеку (“мы пытались распарсить id и вот какое значение было”) и полезна коду (потому что причина сохранена через %w).
3. Wrapping и цепочка причин
Как работает %w и зачем он нужен
Важный поворот: оборачивать ошибку (wrapping) — это не то же самое, что “добавить текст”. В Go 1.13 в fmt.Errorf появился спецификатор %w, который делает возвращаемую ошибку “обёрткой” над исходной: создаётся цепочка причин, доступная через errors.Is, errors.As и errors.Unwrap.
Пример, который стоит буквально выучить руками:
package todo
import (
"errors"
"fmt"
)
var ErrNotFound = errors.New("not found")
func LoadTask(id int) error {
// представим, что в хранилище не нашли задачу
return fmt.Errorf("load task %d: %w", id, ErrNotFound)
}
Почему это важно? Потому что теперь внешний код может не парсить строку, а проверять причину правильно:
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("задача не найдена") // задача не найдена
}
}
errors.Is умеет “спускаться” по цепочке обёрток и искать совпадение причины. В простом случае это аналог сравнения с sentinel-ошибкой, но уже wrapper-aware.
Похожая история с errors.As: это “wrapper-aware type assertion”, то есть способ достать типизированную ошибку из цепочки. Даже если сейчас вы используете typed errors редко, важно понимать идею: %w — это не про красоту текста, а про доступность причины для кода.
Когда wrapping делать не стоит
Вот здесь начинается взрослая жизнь. Wrapping — это решение не “про удобство автора”, а “про контракт”. Если вы оборачиваете ошибку через %w, вы даёте вызывающему коду возможность зависеть от конкретной причины (например, от sql.ErrNoRows или от внутренней ошибки файловой системы). А значит — вы почти подписываете договор: “я и дальше буду возвращать эту причину”.
Классический пример из обсуждений Go: если ваш пакет использует database/sql как внутреннюю деталь, и вы возвращаете наружу ошибку так, что её можно распознать как sql.ErrNoRows, то пользователи вашего пакета начнут писать errors.Is(err, sql.ErrNoRows). Потом вы захотите заменить библиотеку — и внезапно это станет breaking change.
Важно сформулировать это так: wrap или не wrap — это API-решение, похожее на решение “экспортировать поле структуры или нет”. То есть иногда нужно оборачивать, чтобы код мог реагировать (например, not found), а иногда нужно спрятать внутренности и вернуть ошибку с тем же текстом, но без возможности unwrap.
Чтобы почувствовать разницу, сравним два варианта:
// Вариант A: сохраняем причину для кода (wrap)
return fmt.Errorf("load task: %w", err)
// Вариант B: сохраняем только текст (не wrap)
return fmt.Errorf("load task: %v", err)
Снаружи для человека они выглядят одинаково (текст тот же), но для программы это разные миры: в варианте A причина доступна через errors.Is/errors.As, в варианте B — нет.
4. Сообщение пользователю и причина для логов
На этом месте обычно рождается вечный вопрос: “Так что, пользователю показывать err.Error() или нет?” Ответ зависит от того, кто пользователь и какой слой приложения. Внутри бизнес-логики мы возвращаем ошибки как причины и контекст, и это почти всегда технический язык. А на границе приложения мы превращаем причины в короткие, стабильные и дружелюбные сообщения.
Важная идея: сообщение пользователю должно быть стабильным, потому что это часть UX. А “причина” (цепочка errors) должна быть богатой, потому что это часть диагностики. И эти вещи лучше не смешивать в одном и том же тексте.
Представим, что у нас есть три типовые причины:
- задача не найдена,
- ввод некорректный,
- внутренняя ошибка (непредвиденное).
Мы можем держать это как таблицу соответствий (это проще, чем пытаться “угадать по строке”):
| Причина (для кода) | Что сказать пользователю |
|---|---|
|
|
|
|
| всё остальное | |
Реализуем функцию “переводчика” в нашем пакете todo. Обратите внимание: мы используем errors.Is, потому что ошибка может быть обёрнута много раз.
package todo
import "errors"
func UserMessage(err error) string {
switch {
case err == nil:
return "ok"
case errors.Is(err, ErrNotFound):
return "не найдено"
case errors.Is(err, ErrEmptyTitle):
return "заголовок не должен быть пустым"
default:
return "внутренняя ошибка"
}
}
Теперь у нас появляется красивый контракт: внутренние функции возвращают диагностируемые ошибки, а внешний слой решает, что именно показывать пользователю.
5. Пример mini-todo
Сейчас мы соберём маленький, но жизненный кусочек приложения, где видно сразу всё: стиль текста, wrapping, и разделение “для пользователя / для диагностики”. Важно: примеры маленькие, как кубики LEGO. В жизни они окажутся в разных файлах, но смысл один — ошибка должна быть полезной на каждом уровне.
Начнём с модели-хранилища “в памяти” и ошибки “не найдено”:
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)}
}
Добавим метод чтения, который возвращает причину (в данном случае sentinel):
package todo
func (s MemoryStore) TitleByID(id int) (string, error) {
title, ok := s.titles[id]
if !ok {
return "", ErrNotFound
}
return title, nil
}
Теперь добавим слой “сервиса”, который даёт контекст операции. Тут как раз место для wrapping:
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
}
Если задача не найдена, ошибка станет get title 5: not found. Для диагностики это полезнее, чем просто not found, потому что теперь видно, на каком шаге мы были.
Осталось показать “границу приложения”. Пусть у нас есть Run() (условный верхний уровень), который печатает пользователю одно, а себе оставляет другое. Здесь мы для простоты выводим “диагностику” в stdout, но мысль такая: для пользователя — коротко, для разработчика — детально.
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)) // не найдено
fmt.Printf("debug: %v\n", err) // debug: get title 5: not found
return
}
fmt.Println("ok") // ok
}
В этом мини-примере уже видна правильная архитектурная привычка: мы не заставляем пользователя читать “get title 5”, но при этом не теряем информацию для диагностики.
6. Типичные ошибки
Ошибка №1: возвращать ошибки “без маршрута”.
Когда функция возвращает только первопричину вроде invalid syntax, без контекста операции, вы сами себе закладываете мину на будущее: через неделю вы увидите этот текст и будете гадать, что именно парсили и откуда пришло значение. Обычно достаточно одного аккуратного fmt.Errorf("parse id %q: %w", s, err), чтобы ошибка стала в разы полезнее.
Ошибка №2: думать, что %w — это “красивее печатает”.
%w нужен не для красоты текста, а для сохранения причины в цепочке, чтобы errors.Is/errors.As работали корректно. Если вам не нужно, чтобы внешний код мог распознать исходную причину, иногда правильнее использовать %v, оставив только текст.
Ошибка №3: wrap-нуть всё подряд и случайно выдать наружу внутренности.
Wrapping — это API-решение: если вы отдаёте наружу unwrap-доступ к ошибке другого пакета, вы позволяете пользователям зависеть от неё. Потом вы можете оказаться в ловушке совместимости, когда менять реализацию станет больно. В таких местах лучше явно решить: это часть контракта или внутренняя деталь?
Ошибка №4: показывать пользователю err.Error() как есть.
Техническая ошибка часто содержит лишние детали, пугающие формулировки или просто “мусор” с точки зрения UX. Гораздо надёжнее сделать маленький слой маппинга причин в короткие сообщения, а err (с контекстом и цепочкой) оставить для диагностики на границе приложения.
Ошибка №5: сравнивать ошибки через ==, когда уже есть wrapping.
Как только в проекте появился %w, прямое сравнение err == ErrNotFound перестаёт работать ожидаемо, потому что err уже не равен маркеру, а лишь содержит его в цепочке. В таких случаях почти всегда нужно переходить на errors.Is(err, ErrNotFound).
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ