Модуль: 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) bool

target должен быть указателем на переменную типа, реализующего 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 лучше для «собрать все».