Обработка ошибок в Go: значения, обёртки, паники

Ошибки в Go — обычные значения, а не исключения: их возвращают, оборачивают через %w, проверяют errors.Is/As сквозь цепочку и объединяют errors.Join. Разбираем sentinel и типизированные ошибки, когда уместны panic/recover, и знаменитый баг «типизированный nil в error != nil»

В Go нет try/catch. Ошибка — это обычное значение, которое функция возвращает наравне с результатом, а вызывающий по идиоме должен её обработать — или явно проигнорировать через _. Компилятор не заставляет проверять error (это не «неиспользованная переменная»), но и молча спрятать его за невидимым исключением нельзя: отказ от обработки виден в тексте как осознанный _ = .... Отсюда знаменитое if err != nil, которое одни считают ритуалом, а другие — главной честностью языка: поток ошибок проходит прямо через код, а не мимо него. Эта статья — про механику: как ошибки оборачивают через %w, как errors.Is/errors.As разбирают цепочку, что даёт errors.Join, когда уместны panic/recover — и почему error, внутри которого лежит nil, всё-таки не равен nil.

Это вторая статья мини-серии «Основы Go вглубь». Про сравнение подхода Go с исключениями Java/Python/Rust — отдельный разбор «Обработка ошибок в разных языках»; здесь остаёмся внутри Go и разбираем стандартную библиотеку до дна. Все примеры взяты из живого стенда — ссылка в конце.

Схема обработки ошибок в Go: цепочка вложенных обёрток-конвертов (поиск пользователя → валидация → ядро ErrNotFound) с зондами errors.Is (по значению) и errors.As (извлечение *ValidationError); справа — ловушка типизированного nil (*MyError с nil внутри → err != nil, «a bug!») и panic/recover (сеть ловит panic, но не fatal error); внизу — цепочка обёрток %w

В статье

Ошибки — значения, не исключения

error — это интерфейс из одного метода:

type error interface {
	Error() string
}

Любой тип, у которого есть метод Error() string, является ошибкой. Функции возвращают error последним значением, а вызывающий проверяет его сразу же:

f, err := os.Open("config.yaml")
if err != nil {
	return fmt.Errorf("открыть конфиг: %w", err)
}
defer f.Close()
// ... f гарантированно валиден: до сюда доходим только когда err == nil

Философия за этим — явный поток ошибок. В языках с исключениями сбой распространяется невидимо: любой вызов вправе «выстрелить» через throw, и понять, какие строки могут прервать выполнение, по тексту функции нельзя. В Go сбой возвращается как значение, и место, где он игнорируется, видно глазами — переменную err придётся либо проверить, либо демонстративно присвоить _. Цена — многословность (if err != nil повторяется часто); выгода — на каждом вызове видно, что может пойти не так и кто это обрабатывает. Как этот компромисс выглядит на фоне try/catch Java, Result<T, E> Rust и исключений Python — в статье «Обработка ошибок в разных языках».

Раз ошибка — обычное значение, её можно сохранить в переменную, сравнить, передать, объединить с другой и завернуть в контекст. Именно эти операции и разбираем дальше.

Sentinel-ошибки и обёртывание

Sentinel-ошибка — заранее объявленное экспортируемое значение, с которым вызывающий сравнивает результат, не разбирая текст сообщения:

// ErrNotFound — заранее объявленное значение; вызывающий проверяет
// именно этот случай, не завися от текста сообщения.
var ErrNotFound = errors.New("запись не найдена")

Классика стандартной библиотеки — io.EOF, sql.ErrNoRows. Проблема наивного подхода в том, что sentinel хочется вернуть с добавленным контекстом («при каком id не нашли?»), а голое errors.New контекста не несёт. Решение — обёртывание: fmt.Errorf с глаголом %w создаёт новую ошибку, у которой снаружи своё сообщение, а внутри сохранён исходный sentinel:

// LookupUser для неизвестного id возвращает ErrNotFound, обёрнутую
// в контекст через %w: обёртка добавляет сообщение, но сохраняет
// sentinel внутри цепочки.
func LookupUser(id string) error {
	if id == "" {
		return fmt.Errorf("поиск пользователя %q: %w", id, ErrNotFound)
	}
	return nil
}

Разница между %w и %v принципиальна. %v (или %s) вставляет текст ошибки в новое сообщение — исходная ошибка теряется, остаётся только строка. %w (wrap) сохраняет саму ошибку как звено цепочки: снаружи LookupUser("") вернёт поиск пользователя "": запись не найдена, но внутри по-прежнему живёт ErrNotFound, и его можно достать программно. Обёртки вкладываются друг в друга сколько угодно: каждый слой обработки добавляет свой контекст через %w, формируя цепочку от самой внешней ошибки к исходной. Как её разбирают — в следующем разделе.

Технически %w работает так: fmt.Errorf возвращает значение, у которого есть метод Unwrap() error, отдающий обёрнутую ошибку. На этом методе и строится весь механизм errors.Is/errors.As.

errors.Is и errors.As

Раз ошибка спрятана под обёрткой, прямое сравнение == перестаёт работать:

err := LookupUser("")     // обёрнутый ErrNotFound
err == ErrNotFound        // false! err — это обёртка, а не сам sentinel

err здесь — значение, возвращённое fmt.Errorf, а не ErrNotFound. Сравнение по == смотрит на верхний слой и видит обёртку, а не то, что внутри. Стоит кому-то добавить ещё один %w-слой — и код, полагавшийся на err == ErrNotFound, тихо ломается. Поэтому сравнивать нужно не ==, а errors.Is, который разворачивает всю цепочку (вызывая Unwrap слой за слоем) и ищет совпадение на каждом уровне:

func TestIsFindsSentinelThroughChain(t *testing.T) {
	err := LookupUser("") // вернёт обёрнутый ErrNotFound

	// Прямое == не сработало бы: err — обёртка, не сам sentinel.
	if err == ErrNotFound {
		t.Fatal("обёртка не должна быть равна sentinel по ==")
	}
	// errors.Is разворачивает цепочку и находит ErrNotFound.
	if !errors.Is(err, ErrNotFound) {
		t.Errorf("errors.Is не нашёл ErrNotFound в %v", err)
	}
}

Тест из стенда доказывает и то, что глубина обёртки не важна — Is дойдёт до sentinel через сколько угодно слоёв:

func TestIsThroughDoubleWrap(t *testing.T) {
	base := LookupUser("")                            // слой 1
	wrapped := fmt.Errorf("слой обработки: %w", base) // слой 2
	if !errors.Is(wrapped, ErrNotFound) {
		t.Errorf("Is не нашёл ErrNotFound сквозь двойную обёртку: %v", wrapped)
	}
}

errors.As решает другую задачу: не «эта ли конкретная ошибка внутри?», а «есть ли в цепочке ошибка такого типа, и если да — дай её мне». Он проходит цепочку, находит первое звено, присваиваемое целевому типу, и записывает его в переданный указатель — после чего доступны все поля типизированной ошибки:

func TestAsExtractsTypedError(t *testing.T) {
	err := ValidateUser("") // вернёт обёрнутый *ValidationError

	var verr *ValidationError
	if !errors.As(err, &verr) {
		t.Fatalf("errors.As не извлёк *ValidationError из %v", err)
	}
	if verr.Field != "name" { // поля доступны после извлечения
		t.Errorf("Field = %q, ожидали \"name\"", verr.Field)
	}
}

Мнемоника: Is — сравнение с известным значением (sentinel), As — извлечение по типу (типизированная ошибка, чтобы прочитать её поля). Второй аргумент As — обязательно указатель на переменную нужного типа (&verr), иначе будет паника на этапе выполнения.

errors.Join — несколько ошибок сразу

Иногда сбой не один: форма не прошла валидацию по трём полям, батч упал на нескольких элементах. До Go 1.20 приходилось склеивать сообщения строками, теряя возможность проверить каждую причину. errors.Join (Go 1.20) объединяет несколько ошибок в одну, сохраняя все как разворачиваемые звенья — так что errors.Is и errors.As находят любую из них:

// RegisterUser выполняет обе проверки и объединяет результаты через
// errors.Join: в возвращённой ошибке живут ОБЕ причины.
func RegisterUser(id, name string) error {
	// errors.Join(nil, nil) == nil, поэтому при успехе — чистый nil.
	return errors.Join(LookupUser(id), ValidateUser(name))
}

Две важные детали. Во-первых, errors.Join игнорирует nil-аргументы, а если все они nil — возвращает nil. Поэтому RegisterUser при успехе обеих проверок отдаёт настоящий nil, а не «пустую непустую» ошибку. Во-вторых, из объединённой ошибки достаются оба вложенных типа сразу:

func TestJoinFindsAnyError(t *testing.T) {
	err := RegisterUser("", "") // обе проверки провалятся

	if !errors.Is(err, ErrNotFound) { // нашёл sentinel из первой
		t.Errorf("Is не нашёл ErrNotFound в объединённой ошибке")
	}
	var verr *ValidationError
	if !errors.As(err, &verr) { // и типизированную из второй
		t.Errorf("As не нашёл *ValidationError в объединённой ошибке")
	}
}

Тот же Go 1.20 разрешил и несколько %w в одном fmt.Errorf (fmt.Errorf("...: %w и %w", e1, e2)) — под капотом это тоже даёт мультиобёртку, по которой Is/As обходят все ветви.

Типизированные ошибки

Sentinel отвечает на вопрос «да/нет: этот ли случай?». Когда же вызывающему нужны структурированные детали сбоя — какое поле, какое значение, какой код, — заводят собственный тип, реализующий error:

// ValidationError несёт структурированные детали, которые вызывающий
// достаёт через errors.As.
type ValidationError struct {
	Field  string
	Reason string
}

func (e *ValidationError) Error() string {
	return fmt.Sprintf("поле %q: %s", e.Field, e.Reason)
}

Дальше её возвращают, при необходимости обернув контекстом, а вызывающий достаёт через errors.As (см. выше) и читает поля.

Когда что выбирать:

  • Sentinel — когда важен только факт: «не найдено», «нет прав», «истёк дедлайн». Вызывающему нечего прочитать сверх самого факта, вся логика — в ветке if errors.Is(...). Дёшево, минимум кода.
  • Типизированная ошибка — когда вызывающему нужны данные для реакции: какое поле не прошло валидацию, сколько осталось попыток, какой HTTP-статус вернуть. Всё, что должно уехать наверх вместе с фактом сбоя, кладётся в поля структуры.

Практическое замечание про приёмник: метод Error() объявляют на указателе (*ValidationError), а не на значении, и errors.As тогда ищет *ValidationError. Это устоявшаяся идиома — она же ведёт нас прямиком к главной практической ловушке.

Ловушка «типизированный nil в error != nil»

Самая коварная ошибка вокруг error — та, где if err != nil срабатывает, хотя фактической ошибки нет. Корень — в том, что error это интерфейс, а интерфейсное значение хранит пару (тип, значение). Интерфейс равен nil только когда пусты оба компонента. Если положить в error nil-указатель конкретного типа, компонент «тип» окажется непустым (*MyError) — и интерфейс не будет равен nil, несмотря на nil внутри.

Стенд показывает это буквально (сверено на go1.26.3):

type MyError struct{ msg string }

func (e *MyError) Error() string { return e.msg }

// makeBad — ЛОВУШКА: nil-указатель *MyError, возвращённый как error.
// Интерфейс получает пару (тип=*MyError, значение=nil) — и это НЕ nil.
func makeBad() error {
	var p *MyError = nil // ошибки нет — указатель nil
	return p             // но тип пары = *MyError, значение = nil
}

// makeGood — как правильно: буквальный nil типа error.
func makeGood() error {
	return nil // пара (тип=nil, значение=nil) — настоящий nil-интерфейс
}

Запуск из корня модуля go-fundamentalsgo run ./errors/cmd/typed-nil (или go run ./cmd/typed-nil из папки errors/) — печатает:

badErr != nil  -> true   (внутри указатель nil, но тип пары = *MyError)
goodErr != nil -> false

badErr != nil даёт true — и ветка обработки ошибки срабатывает на ровном месте. goodErr, где вернули явный nil типа error, ведёт себя правильно. Механику интерфейсной пары (тип, значение) подробно разбирает сиблинг «Интерфейсы в Go» — здесь важен вывод, а не устройство.

У ловушки есть и второе дно. Error() объявлен на указателе (func (e *MyError) Error() string), поэтому прямой вызов метода на таком typed-nil — например, когда собирают сообщение "ошибка: " + err.Error() — обращается к полю через nil-получатель и паникует с runtime error: invalid memory address or nil pointer dereference. Важная тонкость: fmt.Println(err), %v, log.Println(err) от этого защищены — стандартный вывод для nil-указателя печатает <nil>, не вызывая метод; падает именно собственный код, дёргающий Error() напрямую. То есть typed-nil не только заводит в ложную ветку err != nil, но и способен уронить код на попытке достать текст ошибки вручную.

Откуда это берётся в реальном коде: функция объявляет типизированную переменную ошибки, что-то в неё пишет по ветке, а в конце возвращает — и по «успешной» ветке отдаёт nil-указатель как error.

func do() error {
	var e *MyError            // nil-указатель конкретного типа
	if somethingFailed() {
		e = &MyError{msg: "..."}
	}
	return e                  // ПЛОХО: даже при успехе вернётся не-nil error
}

Правило: возвращайте error как nil явно, а не типизированную nil-переменную. Объявляйте возвращаемое значение как error (не как *MyError), а по успешной ветке возвращайте буквальный return nil. Если без типизированной переменной не обойтись — проверяйте её на nil перед возвратом и возвращайте nil явно:

func do() error {
	if somethingFailed() {
		return &MyError{msg: "..."} // ошибка — возвращаем конкретный тип
	}
	return nil // успех — буквальный nil типа error
}

panic, recover и границы применимости

panic разворачивает стек, выполняя отложенные defer, и роняет программу, если её не перехватили. recover, вызванный напрямую из отложенной функции, останавливает разворачивание и возвращает значение, переданное в panic. Это выглядит как try/catch — но применять их вместо возврата ошибок в Go не принято.

Когда panic уместен:

  • Действительно исключительные, невосстановимые ситуации — нарушение инварианта, до которого при корректном коде дойти невозможно («такого не должно случиться никогда»).
  • Инициализация, где сбой означает, что программа не имеет права запускаться: regexp.MustCompile, template.Must — паникуют на битой константе, потому что чинить нечего, надо падать сразу при старте.
  • Граница горутины/пакета: внутри может паниковать сторонний код, а наружу принято отдавать аккуратный error — тогда панику перехватывают в defer и конвертируют.

Когда panic неуместен: для обычных, ожидаемых сбоев — «файл не найден», «валидация не прошла», «сеть недоступна». Это штатные ветки исполнения, их место — возвращаемое значение error, а не разворачивание стека.

Стенд демонстрирует именно приём с границы пакета — перехват паники и превращение её в error через именованный возвращаемый параметр:

// safeDivide: defer с recover перехватывает панику и превращает её
// в error через именованный возвращаемый параметр err.
func safeDivide(a, b int) (result int, err error) {
	defer func() {
		if r := recover(); r != nil {
			err = fmt.Errorf("восстановлено из паники: %v", r)
		}
	}()

	if b == 0 {
		panic(errors.New("деление на ноль"))
	}
	return a / b, nil
}

Запуск из корня модуля go-fundamentalsgo run ./errors/cmd/panic-recover (или go run ./cmd/panic-recover из папки errors/) — печатает:

safeDivide(10, 2) = 5, err = <nil>
safeDivide(10, 0) = 0, err = восстановлено из паники: деление на ноль
дошли до конца main — процесс не упал

Паника перехвачена, превращена в обычную ошибку, процесс дошёл до конца main. Три нюанса про recover, о которых легко забыть:

  • recover возвращает не-nil только при вызове прямо из отложенной функции во время разворачивания паники. Вызванный где угодно ещё — вернёт nil и ничего не сделает.
  • recover действует только в своей горутине. Паника в запущенной go func() не долетит до defer/recover вызывающей горутины: если у самой горутины нет собственного recover, непойманная паника роняет весь процесс — сколько бы defer ни стояло снаружи. Это классический источник инцидентов: воркер-пул или обработчик, где забыли recover в теле каждой горутины, обрушивает сервис целиком из-за одной задачи. Практика — на входе в каждую долгоживущую горутину ставить свой defer с recover, логировать панику и не давать одной задаче убить процесс.
  • recover ловит panic, но не fatal error рантайма. Одновременная запись в map из нескольких горутин — это fatal error: concurrent map writes, и она обрушивает процесс мимо всякого defer/recover. Почему так и как этого избегать — в сиблинге «Срезы и мапы: внутреннее устройство». Штатное завершение работы под сигналом — тоже не про recover, а про graceful shutdown.

Демо и версии

  • Код: живой стенд digital-cookbook/go-fundamentals/errors/. Пакет errorscookbook (errors.go) — sentinel, обёртывание %w, типизированная *ValidationError и errors.Join; тесты (errors_test.go) доказывают, что errors.Is/errors.As обходят всю обёрнутую цепочку, а Join находит любую из объединённых ошибок. Два запускаемых демо в cmd/: typed-nil (ловушка nil-интерфейса) и panic-recover (паника → error). Код в тексте — выжимки из стенда.
  • Версии: errors.New, sentinel и panic/recover — с самого Go 1. Обёртывание %w и функции errors.Is/errors.As/errors.Unwrap появились в Go 1.13. errors.Join и несколько %w в одном fmt.Errorf — в Go 1.20. Ловушка типизированного nil сверена на go1.26.3; собирайте на текущем стабильном релизе.
  • Смежные статьи: интерфейсы в Go (почему nil-интерфейс не nil), срезы и мапы (fatal error рантайма мимо recover), обработка ошибок в разных языках (Go на фоне исключений и Result), graceful shutdown.

Документация и первоисточники

  • Working with Errors in Go 1.13 — первоисточник по обёртыванию: глагол %w, errors.Is, errors.As, errors.Unwrap и мотивация «ошибки — значения».
  • Effective Go — Errors и раздел про panic/recover — идиомы обработки ошибок и границы применимости паники.
  • Go 1.20 Release Noteserrors.Join и поддержка нескольких %w в fmt.Errorf.
  • Proposal: Error Inspection (errors.Is/As/Unwrap) — дизайн-обоснование механизма разбора цепочки ошибок.
  • Package errors — актуальная документация стандартного пакета.

Обсуждение в Telegram

Присоединиться →

Комментарии