Модуль: Core Go · Уровень: Senior
TL;DR#
В Go ошибка — это обычное значение, реализующее интерфейс error с единственным методом Error() string. Ошибки конструируются (errors.New, fmt.Errorf), оборачиваются с сохранением причины через %w, образуя цепочку (или дерево при errors.Join/нескольких %w), и инспектируются через errors.Is (сравнение по идентичности/семантике) и errors.As (извлечение конкретного типа). panic предназначен для программных багов и неинвариантных состояний, а не для ожидаемых ошибок. Стандартные ошибки намеренно не несут stacktrace — это сознательный компромисс между стоимостью и информативностью.
Теория#
error — это интерфейс#
type error interface {
Error() string
}Всё. Любой тип с методом Error() string является ошибкой. Это значит, что ошибка — это значение первого класса: её можно возвращать, передавать, хранить в полях, сравнивать, оборачивать. Идиома Go — «errors are values»: обработка ошибок выражается обычным потоком управления, а не отдельным механизмом исключений.
Под капотом значение типа error — это интерфейсный двусловный заголовок (iface): пара (itab, data), где itab хранит указатель на таблицу методов конкретного типа, а data — указатель на сами данные. Из этого следуют два важных момента:
nilинтерфейсerror— это(nil, nil). А вот интерфейс, в который положилиnil-указатель конкретного типа, — это(itab≠nil, nil), и он не равенnil. Это классическая ловушка typed-nil (см. gotchas).- Сравнение
err1 == err2сравнивает обе половины заголовка: типы и значения данных. Для sentinel-ошибок это работает, потому что сравниваются указатели на один и тот же глобальный объект.
errors.New и fmt.Errorf#
var ErrNotFound = errors.New("not found")
err := fmt.Errorf("loading user %d: %w", id, ErrNotFound)errors.New возвращает *errorString — указатель на структуру с одним полем-строкой:
// runtime/стандартная библиотека, упрощённо
type errorString struct { s string }
func (e *errorString) Error() string { return e.s }
func New(text string) error { return &errorString{text} }Ключевая деталь: возвращается указатель. Поэтому две ошибки с одинаковым текстом — это разные значения (errors.New("x") != errors.New("x")). Именно благодаря указательной семантике sentinel-ошибки сравниваются по идентичности, а не по содержимому, и текст можно дублировать без коллизий.
fmt.Errorf форматирует строку как fmt.Sprintf, но при наличии глагола %w создаёт обёртку, реализующую Unwrap. Без %w это обычная необорачивающая ошибка (*errorString-подобная), а причина превращается в простой текст и теряется как значение.
Sentinel errors и их минусы#
Sentinel — это экспортированная переменная-ошибка, с которой сравнивают:
var ErrNoRows = errors.New("sql: no rows in result set") // database/sql
var EOF = errors.New("EOF") // ioИспользование: if errors.Is(err, io.EOF) { ... }.
Минусы sentinel-подхода:
- Жёсткая связь по API. Sentinel становится частью публичного контракта пакета. Удалить или переименовать его — ломающее изменение.
- Импорт-зависимость. Чтобы проверить ошибку, нужно импортировать пакет ради переменной — это создаёт зависимости, иногда нежелательные (например, бизнес-слой импортирует
database/sql). - Нет полезной нагрузки. Sentinel не несёт контекста (какой id не найден, какое поле невалидно). Для этого нужны кастомные типы.
- Хрупкость при сравнении по тексту. Если кто-то проверяет
err.Error() == "EOF"(а такое бывает), любое изменение текста ломает код. Поэтому всегдаerrors.Is, а не сравнение строк.
Wrapping через %w#
if err != nil {
return fmt.Errorf("repo.GetUser(%d): %w", id, err)
}%w сохраняет исходную ошибку как значение внутри обёртки. Результат реализует интерфейс:
interface { Unwrap() error }Это позволяет потом «развернуть» цепочку и найти причину через Is/As. С Go 1.20 допускается несколько %w в одной строке:
err := fmt.Errorf("op failed: %w and %w", err1, err2)В этом случае обёртка реализует Unwrap() []error (множественный вариант), и цепочка превращается в дерево.
%w vs %v: %v вставляет только текст ошибки. Цепочка при этом теряется — errors.Is/As не смогут добраться до причины, потому что значение причины не сохранено, только её строковое представление. Правило: оборачивай через %w, если хочешь, чтобы вызывающий мог программно реагировать на причину; используй %v, если намеренно скрываешь детали (opaque error, см. ниже).
errors.Unwrap#
func Unwrap(err error) errorВозвращает err.Unwrap(), если тип реализует Unwrap() error; иначе nil. Важно: errors.Unwrap работает только с одиночным Unwrap() error. Для дерева (Unwrap() []error) одношаговый errors.Unwrap не определён и вернёт nil — обходить дерево умеют только Is/As.
errors.Is — сравнение по цепочке#
func Is(err, target error) boolСемантика: «есть ли в цепочке err ошибка, равная target или считающая себя равной target».
Алгоритм под капотом (упрощённо):
func Is(err, target error) bool {
if target == nil { return err == target }
isComparable := reflectlite.TypeOf(target).Comparable()
for {
if isComparable && err == target { return true }
// 1) кастомный метод Is
if x, ok := err.(interface{ Is(error) bool }); ok && x.Is(target) {
return true
}
// 2) разворачиваем дальше
switch x := err.(type) {
case interface{ Unwrap() error }:
err = x.Unwrap()
if err == nil { return false }
case interface{ Unwrap() []error }:
for _, e := range x.Unwrap() {
if Is(e, target) { return true } // рекурсивно по дереву
}
return false
default:
return false
}
}
}Ключевые моменты:
- Сначала прямое сравнение
==(только еслиtargetсравним — иначе сравнение паникует, поэтому проверяетсяComparable()). - Затем, если у текущей ошибки есть метод
Is(error) bool, он вызывается. Это позволяет реализовать «семантическое» равенство: например, тип ошибки может считать себя равным целому классу sentinel. - Затем разворачивание: одиночное
Unwrap() error— итеративно; множественноеUnwrap() []error— рекурсивный обход всех веток дерева (DFS).
Пример кастомного Is:
type HTTPError struct{ Code int }
func (e *HTTPError) Error() string { return fmt.Sprintf("http %d", e.Code) }
func (e *HTTPError) Is(target error) bool {
t, ok := target.(*HTTPError)
return ok && t.Code == e.Code
}errors.As — извлечение типа из цепочки#
func As(err error, target any) booltarget должен быть указателем на переменную типа, реализующего error (или на интерфейс). As идёт по цепочке и при первом совпадении типа присваивает значение в *target.
Алгоритм под капотом (упрощённо):
func As(err error, target any) bool {
// target — ненулевой указатель; *target присваиваемо к error
val := reflectlite.ValueOf(target)
targetType := val.Type().Elem()
for err != nil {
if reflectlite.TypeOf(err).AssignableTo(targetType) {
val.Elem().Set(reflectlite.ValueOf(err))
return true
}
// кастомный метод As
if x, ok := err.(interface{ As(any) bool }); ok && x.As(target) {
return true
}
switch x := err.(type) {
case interface{ Unwrap() error }:
err = x.Unwrap()
case interface{ Unwrap() []error }:
for _, e := range x.Unwrap() {
if As(e, target) { return true } // рекурсия по дереву
}
return false
default:
return false
}
}
return false
}Использование:
var perr *os.PathError
if errors.As(err, &perr) {
log.Printf("op=%s path=%s", perr.Op, perr.Path)
}Тонкость: As использует рефлексию и паникует при невалидном target (nil, не указатель, или тип, не реализующий error). Это контракт-ошибка, обнаруживаемая на этапе разработки, поэтому это panic, а не возврат false.
Is vs As: Is отвечает на вопрос «это та самая ошибка / тот класс ошибки?» (проверка по значению/семантике), As — «есть ли в цепочке ошибка такого типа, и дай мне её поля» (извлечение данных).
errors.Join (Go 1.20) — мульти-ошибки#
func Join(errs ...error) errorОбъединяет несколько ошибок в одну. nil-аргументы отбрасываются; если все nil — возвращает nil. Результат реализует Unwrap() []error:
type joinError struct { errs []error }
func (e *joinError) Error() string {
// тексты через '\n'
}
func (e *joinError) Unwrap() []error { return e.errs }errors.Is/As обходят все ветки. Типичное применение — аккумуляция ошибок в цикле:
var errs error
for _, item := range items {
if err := process(item); err != nil {
errs = errors.Join(errs, err)
}
}
return errs // nil, если ошибок не былоВажно: errors.Join склеивает тексты через \n, а не «a: b», как fmt.Errorf. И одношаговый errors.Unwrap к join-ошибке вернёт nil.
Кастомные типы ошибок#
Когда ошибке нужна полезная нагрузка — это структура:
type ValidationError struct {
Field string
Value any
Err error // опционально — обёрнутая причина
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("validation failed on %q: %v", e.Field, e.Err)
}
func (e *ValidationError) Unwrap() error { return e.Err } // делает тип частью цепочкиСоглашения:
- Реализуйте
Unwrap, если внутри есть причина — тогда тип встроится в цепочку. - Тип-ошибка обычно объявляется как
*T(указатель-ресивер), потому что: (а) часто несёт состояние, (б) сравнение по идентичности через==работает по указателю, (в)Asприсваивает в*T. - Для семантического сравнения добавьте метод
Is/As.
Opaque errors#
Opaque (непрозрачная) ошибка — намеренно скрывает свой тип и причину, предоставляя только поведенческий контракт. Вместо «проверь, это ли тип X», вызывающему предлагают «проверь поведение»:
type temporary interface { Temporary() bool }
func IsTemporary(err error) bool {
var t temporary
return errors.As(err, &t) && t.Temporary()
}Преимущество: вызывающий не привязывается ни к конкретному типу, ни к sentinel — только к поведению (можно ли повторить операцию). Это самый слабосвязанный из трёх стилей: opaque (поведение) < sentinel (значение) < type assertion (тип).
Почему panic не для обычных ошибок#
panicразворачивает стек, запускаетdefer-ы и (безrecover) роняет программу. Это дорого и нелокально по управлению.- Ожидаемые ситуации (файл не найден, валидация не прошла, сеть отвалилась) — это нормальный поток, а не баг. Их место — возвращаемое значение
error, которое вызывающий обязан обработать явно. panicуместен для программных инвариантов: невозможное состояние, индекс вне диапазона, nil-разыменование, «этот код недостижим». То есть для багов разработчика, а не для ожидаемых сбоев среды.- Использование
panic/recoverкак try/catch — антипаттерн: теряется явность, компилятор не помогает с проверкой, control flow становится непредсказуемым. Исключение — узкие границы (например, рекурсивный парсер ловитrecoverна своей границе и превращает вerror).
Стоимость stacktrace#
- Стандартные ошибки (
errors.New,fmt.Errorf) не захватывают stacktrace. Это сознательный дизайн: захват стека (runtime.Callers) стоит времени и памяти, а большинство ошибок обрабатывается близко к месту возникновения. Контекст добавляется руками через wrapping. github.com/pkg/errors(а сейчас чаще его идеи в собственных типах) при создании/обёртке вызываетruntime.Callersи сохраняет PC-кадры; форматирование%+vпечатает полный стек. Цена — аллокация массива PC и работа по символизации при печати.- Компромисс: если оборачивать через
%wдисциплинированно на каждой границе, текстовая цепочкаop1: op2: op3: root causeсама по себе даёт «логический трейс» без накладных расходов на runtime-стек. - Senior-нюанс: захватывайте стек один раз — у корня (в момент первого возникновения), а не на каждой обёртке, иначе получите дублирование и лишние аллокации.
Подводные камни / gotchas#
typed-nil в error#
type MyErr struct{}
func (*MyErr) Error() string { return "boom" }
func do() error {
var p *MyErr // nil-указатель
return p // НО! интерфейс становится (*MyErr, nil) != nil
}
// if do() != nil --> TRUE, хотя «ошибки нет»Всегда возвращайте литеральный nil, а не nil-указатель типа.
%w vs %v — потеря цепочки#
fmt.Errorf("ctx: %v", ErrNotFound) // НЕ оборачивает — errors.Is(.., ErrNotFound)==false
fmt.Errorf("ctx: %w", ErrNotFound) // оборачивает — errors.Is(.., ErrNotFound)==trueДвойное оборачивание / лишний контекст#
Не добавляйте к каждой ошибке имя пакета и функции, если оно не несёт информации — получите шум вроде service: handler: repo: db: query: not found. Оборачивайте на границах подсистем со значимым контекстом.
errors.As с неправильным target#
var perr os.PathError // НЕ указатель на нужный тип
errors.As(err, perr) // паника: target должен быть указателем
errors.As(err, &perr) // окСравнение sentinel через == после wrapping#
if err == io.EOF { ... } // сломается, если err обёрнута
if errors.Is(err, io.EOF) { ... } // правильноОсобый случай: bufio.Reader и др. могут возвращать io.EOF напрямую (без обёртки), поэтому исторически == io.EOF работало. Но полагаться на это нельзя — всегда Is.
errors.Join и Unwrap()#
Одношаговый errors.Unwrap(joinErr) вернёт nil, потому что join реализует Unwrap() []error, а не Unwrap() error. Обход — только через Is/As или ручной type-assert на interface{ Unwrap() []error }.
Метод Is, ломающий контракт#
Если кастомный Is написан неверно (например, всегда возвращает true), он отравит всю цепочку — errors.Is начнёт совпадать с чем попало. Is должен быть консервативным.
Сравнение ошибок по тексту#
if err.Error() == "not found" — хрупко: текст не часть контракта, локали/форматирование меняются. Только Is/As.
Аллокации на горячем пути#
Каждый fmt.Errorf с %w — это аллокация (структура-обёртка + форматированная строка). На горячих путях, где ошибка ожидаема и частая (например, io.EOF в цикле чтения), предпочитайте sentinel и сравнение Is без переоборачивания.
Вопросы на собеседовании#
В: Чем %w отличается от %v в fmt.Errorf?
О: %w сохраняет исходную ошибку как значение и делает результат оборачивающим (Unwrap() error), благодаря чему errors.Is/As могут добраться до причины по цепочке. %v подставляет только строковое представление — значение причины теряется, программная инспекция цепочки невозможна. %w допустим только в fmt.Errorf; с Go 1.20 можно несколько %w, и тогда результат реализует Unwrap() []error.
В: Как работает errors.Is под капотом?
О: Цикл по цепочке: на каждом шаге (1) прямое сравнение err == target (если target сравним по типу), (2) вызов кастомного err.Is(target), если метод есть, (3) разворачивание через Unwrap() error (итеративно) либо Unwrap() []error (рекурсивный DFS по всем веткам дерева). Возвращает true при первом совпадении.
В: В чём разница между errors.Is и errors.As?
О: Is отвечает «равна ли (семантически) какая-то ошибка в цепочке заданному target-значению/классу» — для sentinel и поведенческих проверок. As ищет в цепочке ошибку конкретного типа и присваивает её в переданный указатель, давая доступ к полям. Is — про идентичность/семантику, As — про тип и извлечение данных.
В: Что такое typed-nil и почему return p (nil-указатель) ломает err != nil?
О: Интерфейс — это пара (тип, значение). Литеральный nil интерфейса — (nil, nil). Если вернуть nil-указатель конкретного типа, интерфейс становится (тип≠nil, nil) и не равен nil, потому что тип-часть заполнена. Поэтому возвращать всегда нужно nil, а не типизированный nil-указатель.
В: Почему стандартные ошибки не несут stacktrace и когда он нужен?
О: Захват стека (runtime.Callers) стоит CPU и аллокаций, а большинство ошибок обрабатывается локально; авторы Go выбрали добавлять контекст вручную через wrapping, что даёт «логический трейс» дёшево. Stacktrace полезен в крупных асинхронных системах для диагностики; тогда используют типы с захватом стека (pkg/errors-стиль), но захватывают его один раз у корня, а не на каждой обёртке.
В: Минусы sentinel-ошибок? О: Становятся частью публичного API (ломкие при изменении), требуют импорта пакета ради сравнения (нежелательные зависимости), не несут контекста (нет полезной нагрузки), провоцируют сравнение по тексту. Альтернативы: кастомные типы (для данных) и поведенческие/opaque-ошибки (для слабой связности).
В: Что делает errors.Join и как с ним работают Is/As?
О: Объединяет несколько ошибок в одну, отбрасывая nil; результат реализует Unwrap() []error и печатает тексты через \n. errors.Is/As рекурсивно обходят все вложенные ошибки (дерево). Одношаговый errors.Unwrap к join-ошибке вернёт nil. Удобно для аккумуляции ошибок в цикле.
В: Когда уместен panic, а когда error?
О: error — для ожидаемых сбоев (IO, сеть, валидация, «не найдено»): это нормальный поток, который вызывающий обязан обработать. panic — для программных инвариантов и невозможных состояний (баг разработчика, недостижимый код). Использовать panic как исключения — антипаттерн; допустимо ловить recover только на узкой внутренней границе и конвертировать в error.
В: Как реализовать поведенческую (opaque) проверку ошибки?
О: Объявить интерфейс с нужным методом (например, Temporary() bool) и проверять через errors.As на этот интерфейс, затем вызвать метод. Вызывающий привязывается к поведению, а не к типу или sentinel — минимальная связанность.
На что копают на senior+#
- Внутреннее устройство интерфейса error. Ожидают объяснение
(itab, data), typed-nil, почемуerrors.Newвозвращает указатель и как это связано со сравнением sentinel по идентичности. - Дерево vs цепочка. Понимание разницы
Unwrap() error(линейная цепочка) иUnwrap() []error(дерево, Go 1.20), и того, чтоerrors.Unwrapне обходит дерево, аIs/Asобходят рекурсивно. Follow-up: «нарисуй обход дляJoin(a, Wrap(b))». - Кастомные Is/As. Когда писать
Is/As-методы, и чем опасен неправильныйIs(отравление цепочки). Follow-up: «как сделать, чтобы тип ошибки совпадал с целым классом sentinel». - Стоимость и стратегия stacktrace. Где захватывать стек (у корня, один раз), почему не на каждой обёртке, как сочетать с структурными логами и observability. Follow-up: «сколько аллокаций на один
fmt.Errorf("%w")и как это влияет на горячий путь». - Дизайн API ошибок. Выбор между sentinel / типом / поведением для публичного пакета, обратная совместимость, что считать частью контракта (можно ли менять текст, можно ли добавить новый
%w). Follow-up: «клиент сделалerr == io.EOF— как ты эволюционируешь API, не сломав его». - Контекст и безопасность. Не протекают ли в текст ошибки чувствительные данные при обёртке; opaque-ошибки на границе сервиса для сокрытия внутренних деталей от клиента, но с сохранением полной цепочки в логах.
- Конкурентность. Аккумуляция ошибок из горутин (
errors.Joinпод мьютексом или через канал), сравнение сerrgroup(который возвращает первую ошибку), и почемуJoinлучше для «собрать все».