Обёртки ошибок в Go: errors.Is, errors.As и wrapping на практике
В Go ошибки — это значения, и с версии 1.13 их можно оборачивать в цепочки. Разбираем, как fmt.Errorf с %w, errors.Is и errors.As работают вместе, чем отличаются друг от друга и где обёртка создаёт больше проблем, чем решает.

Зачем вообще оборачивать ошибки
Функция вернула os.ErrNotExist, но вызывающий код получил строку "open config.yaml: file does not exist". Сравнение через == не срабатывает, потому что ошибка обёрнута вызовом fmt.Errorf. До Go 1.13 с этим боролись строковыми проверками или кастомными интерфейсами. Сейчас в стандартной библиотеке есть три инструмента: глагол %w в fmt.Errorf, функция errors.Is и функция errors.As. Вместе они закрывают большинство сценариев — но порождают новые ошибки проектирования, если пользоваться ими бездумно.
Как устроена цепочка: fmt.Errorf и %w
Глагол %w появился в Go 1.13. Он делает ровно одну вещь: возвращает значение, реализующее метод Unwrap() error, внутри которого хранится исходная ошибка. Это и есть обёртка (wrapping).
// Go 1.21+
package main
import (
"errors"
"fmt"
"os"
)
func readConfig(path string) error {
_, err := os.Open(path)
if err != nil {
// %w сохраняет оригинальную ошибку внутри новой
return fmt.Errorf("readConfig: не удалось открыть %s: %w", path, err)
}
return nil
}
func main() {
err := readConfig("/tmp/missing.yaml")
// errors.Is пройдёт по цепочке Unwrap и найдёт os.ErrNotExist
fmt.Println(errors.Is(err, os.ErrNotExist)) // true
}
Если заменить %w на %v, цепочка разрывается: Unwrap не реализован, errors.Is вернёт false. Это принципиальная разница, о которой часто забывают при рефакторинге.
Множественные обёртки (Go 1.20+)
Начиная с Go 1.20, fmt.Errorf принимает несколько %w в одном вызове. В этом случае возвращённая ошибка реализует метод Unwrap() []error (слайс, а не одиночное значение). errors.Is и errors.As обходят всё дерево.
// Go 1.20+
err := fmt.Errorf("операция провалилась: %w и %w", errTimeout, errPermission)
fmt.Println(errors.Is(err, errTimeout)) // true
fmt.Println(errors.Is(err, errPermission)) // true
Множественные обёртки удобны, когда ошибка действительно вызвана двумя независимыми причинами — например, при параллельном вызове нескольких сервисов. Но если вы оборачиваете две ошибки просто потому, что «обе произошли», подумайте, не стоит ли вернуть их по отдельности: дерево ошибок усложняет обработку выше по стеку.
errors.Is — сравнение по значению
errors.Is(err, target) рекурсивно вызывает Unwrap и на каждом уровне сравнивает ошибку с target. Сравнение начинается с обычного ==, но если ошибка реализует метод Is(target error) bool, вызывается он.
Типичное применение — проверка на sentinel-ошибки стандартной библиотеки: os.ErrNotExist, os.ErrPermission, io.EOF, sql.ErrNoRows.
if errors.Is(err, os.ErrNotExist) {
// файла нет — можно создать
}
if errors.Is(err, sql.ErrNoRows) {
// запись не найдена — вернуть 404
}
Ключевое: errors.Is не разбирает строку ошибки. Оно идёт по цепочке обёрток, а не ищет подстроку в err.Error().
errors.As — извлечение по типу
errors.As(err, &target) делает то же самое, но ищет не конкретное значение, а ошибку определённого типа. Если находит — записывает её в target, и вы получаете доступ к полям.
var pathErr *os.PathError
if errors.As(err, &pathErr) {
fmt.Println("путь:", pathErr.Path)
fmt.Println("операция:", pathErr.Op)
}
Важно передавать указатель на указатель (&pathErr), иначе errors.As запаникует. Это частая ошибка у тех, кто привык к type assertion.
Is vs As: когда что использовать
| Критерий | errors.Is | errors.As |
|---|---|---|
| Что ищем | Конкретное значение (sentinel) | Любую ошибку заданного типа |
| Что получаем | bool — совпало или нет | bool + заполненную переменную с полями ошибки |
| Типичный случай | os.ErrNotExist, io.EOF | *os.PathError, *json.SyntaxError, свой тип |
| Можно кастомизировать | Метод Is(error) bool | Метод As(any) bool (Go 1.20+) |
| Опасность | Сравниваете не то значение | Забыли передать указатель на указатель |
Правило простое: если вам нужны поля ошибки — используйтеerrors.As. Если достаточно знать «это та самая ошибка или нет» —errors.Is.
Кастомный тип с Unwrap, Is и As
Свой тип ошибки получает обёртку, если реализует Unwrap. А методы Is и As позволяют управлять логикой сравнения.
// Go 1.21+
package main
import (
"errors"
"fmt"
)
// AppError — доменная ошибка с кодом и оригинальной причиной
type AppError struct {
Code int
Message string
Err error // оригинальная ошибка
}
func (e *AppError) Error() string {
return fmt.Sprintf("[%d] %s: %v", e.Code, e.Message, e.Err)
}
// Unwrap позволяет errors.Is и errors.As пройти глубже
func (e *AppError) Unwrap() error {
return e.Err
}
func main() {
orig := fmt.Errorf("connection refused")
appErr := &AppError{Code: 503, Message: "сервис недоступен", Err: orig}
// Оборачиваем ещё раз
wrapped := fmt.Errorf("handler: %w", appErr)
// errors.As найдёт *AppError через цепочку
var target *AppError
if errors.As(wrapped, &target) {
fmt.Println("код:", target.Code) // 503
}
}
Где обёртка создаёт проблемы
Утечка деталей реализации
%w делает внутреннюю ошибку частью публичного контракта. Если пакет storage оборачивает *pgconn.PgError, а вызывающий код начинает проверять его через errors.As, вы больше не можете заменить Postgres на SQLite без поломки. Общее правило: оборачивайте через %w только те ошибки, которые являются частью вашего API. Всё остальное — через %v или собственный sentinel.
Двойная обёртка одного и того же
Каждый слой добавляет контекст, и в итоге err.Error() выглядит так: "handler: service: repo: open /data/file.db: no such file or directory". Четыре уровня обёртки несут одну полезную мысль. Добавляйте контекст только там, где он реально помогает отладке — обычно на границе слоя.
Сравнение строк вместо Is/As
Код strings.Contains(err.Error(), "not found") ломается при локализации, при изменении формулировки в зависимости от версии библиотеки, при любом рефакторинге сообщений. Если вы ловите себя на строковой проверке — это сигнал, что библиотека не экспортирует нужный sentinel или тип, и стоит завести свой.
Чек-лист: оборачивать или нет
- Вызывающий код должен программно различать эту ошибку? Да — оборачивайте через
%wили возвращайте типизированную ошибку. - Ошибка — деталь реализации, которую вы вправе поменять? Да — используйте
%vили собственный sentinel, чтобы не привязывать потребителя к внутренностям. - Контекст, который вы добавляете, помогает при чтении лога? Нет — не оборачивайте лишний раз, пробросьте как есть.
- Ошибка может быть
nil? Проверьте перед обёрткой —fmt.Errorf("...: %w", nil)вернёт ошибку с текстом"...: %!w(<nil>)"в ранних версиях, а в Go 1.21+ —nil-обёртку, которая ведёт себя неочевидно. - Нужен ли вам множественный
%w? Только если причин действительно несколько и вызывающий код должен проверять каждую отдельно.
Sentinel-ошибка — заранее объявленная переменная уровня пакета (например, var ErrNotFound = errors.New("not found")), с которой сравнивают через errors.Is. Термин пришёл из статьи Дэйва Чейни «Don't just check errors, handle them gracefully».
Что делать дальше
Пройдите по своему проекту и найдите места, где ошибки оборачиваются через %v, хотя выше по стеку есть errors.Is-проверка — это скрытый баг. Затем — наоборот: найдите %w, которые экспортируют внутреннюю зависимость наружу. Для автоматизации попробуйте линтер errorlint из набора golangci-lint: он ловит как использование == вместо errors.Is, так и обёртки без %w там, где они скорее всего нужны. Актуальные флаги смотрите в документации errorlint — они менялись между версиями.
Контент-конвейер: Из одной идеи — текст, изображение, озвучка и ролик. Один заказ вместо четырёх.
Спросить за 60 ₽Источники: Go Blog — Working with Errors in Go 1.13, Стандартная библиотека: пакет errors — официальная документация
Частые вопросы
Чем %w отличается от %v в fmt.Errorf?
Что будет, если обернуть nil через %w?
Можно ли использовать errors.As с интерфейсом, а не со структурой?
Стоит ли оборачивать все ошибки через %w на всякий случай?
Работают ли errors.Is и errors.As с другой версией Go, например 1.12?
Как автоматически находить места, где нужен errors.Is вместо ==?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.