Введение
В большинстве языков, к которым ты привык, ошибки — это исключения: где-то в
глубине кода что-то падает, вылетает throw, и стек раскручивается
вверх, пока кто-нибудь его не поймает через try/catch. Go выбрал
принципиально другой путь. Здесь ошибка — это просто значение, такое же, как
число или строка. Функция, которая может завершиться неудачей, возвращает ошибку
последним значением, а вызывающий код обязан на месте решить, что с ней делать.
Никакой «магии» невидимого потока управления: ты всегда видишь, где ошибка может
возникнуть и где её обрабатывают. Именно поэтому Go-код так «многословен» на
проверки if err != nil — и именно поэтому его так легко читать и
сопровождать спустя год. В этой главе мы разберём весь инструментарий работы с
ошибками: от базового интерфейса error до обёртки ошибок и
современных функций errors.Is, errors.As и
errors.Join.
Ошибка — это обычное значение
В основе всего лежит крошечный встроенный интерфейс. Любой тип, у которого
есть метод Error() string, считается ошибкой. Это всё, что нужно
знать про «устройство» ошибок в Go.
// Так error объявлен в стандартной библиотеке (пакет builtin).
type error interface {
Error() string
}
Из этого определения вытекает мощное следствие: раз ошибка — это интерфейс,
её значением может быть что угодно, лишь бы был метод Error().
Простая текстовая ошибка, структура с кучей полей, целая цепочка вложенных
ошибок — всё это одинаково укладывается в тип error. А ещё ошибку,
как любое значение, можно сохранить в переменную, сравнить, передать в функцию и
вернуть из неё. Отсутствие ошибки — это nil. Отсюда главная идиома
языка.
Идиома «if err != nil»
Функция возвращает результат и ошибку, а вызывающий сразу же проверяет ошибку.
Если она не nil — что-то пошло не так, и дальше по «счастливому
пути» идти нельзя. Аналогия: представь, что каждый вызов функции — это дверь, а
err — записка на ручке. Прежде чем войти, ты читаешь записку: если
там «всё хорошо» (nil) — проходишь, если предупреждение — реагируешь
и не открываешь дверь дальше.
package main
import (
"errors"
"log"
)
// Sqrt считает квадратный корень. Отрицательный вход — это ошибка,
// поэтому вторым значением функция возвращает error.
func Sqrt(f float64) (float64, error) {
if f < 0 {
return 0, errors.New("norgate math: корень из отрицательного числа")
}
// ... настоящая реализация ...
return 42, nil
}
func main() {
result, err := Sqrt(-10)
if err != nil {
// Ошибка есть — обрабатываем её и НЕ используем result.
log.Fatalln(err)
}
log.Println("результат:", result)
}
Обрати внимание на порядок: сначала объявляем result, err, затем
сразу проверяем err, и только внутри «счастливой ветки» имеем право
доверять result. Когда ошибка не nil, второе значение
(result) обычно бессмысленно — это нулевое значение типа. Поэтому
никогда не используй результат до проверки ошибки.
Куда девать ошибку: печать, логи, завершение
Обнаружив ошибку, у тебя есть несколько вариантов действий, и они отличаются «жёсткостью». Самый мягкий — просто напечатать и продолжить. Пожёстче — записать в лог. Самый радикальный — остановить программу.
// Три способа среагировать на ошибку, от мягкого к жёсткому.
// 1) Напечатать в stderr и продолжить работу.
if err != nil {
fmt.Println("что-то пошло не так:", err)
}
// 2) Записать в лог (добавит дату/время) и продолжить.
if err != nil {
log.Println("ошибка при открытии файла:", err)
}
// 3) Залогировать и НЕМЕДЛЕННО завершить процесс с кодом 1.
// log.Fatalln = log.Println + os.Exit(1).
if err != nil {
log.Fatalln(err)
}
Общее правило: log.Fatalln и panic уместны только на
самом верхнем уровне программы (обычно в main) или в самых
безнадёжных ситуациях. Внутри библиотечных функций так делать нельзя — ты
отбираешь у вызывающего право решать, что делать с ошибкой. Библиотека
возвращает ошибку, а не убивает процесс за спиной пользователя.
errors.New и fmt.Errorf
Создать ошибку из текста можно двумя способами. errors.New —
когда сообщение статическое. fmt.Errorf — когда в текст нужно
подставить значения (форматирование как у Printf).
// Статичный текст.
err1 := errors.New("файл не найден")
// Текст с подстановкой значений.
name := "config.yaml"
line := 42
err2 := fmt.Errorf("разбор %s: ошибка на строке %d", name, line)
Часто повторяющиеся, «опознаваемые» ошибки удобно объявлять один раз как
переменные уровня пакета — их называют сигнальными ошибками
(sentinel errors). Имя по соглашению начинается с Err. Такую ошибку
можно потом сравнивать в вызывающем коде и точно понимать, что именно
случилось.
// Сигнальные ошибки пакета — объявлены один раз, используются много где.
var (
ErrNotFound = errors.New("не найдено")
ErrPermission = errors.New("доступ запрещён")
)
func findUser(id int) (*User, error) {
u, ok := storage[id]
if !ok {
return nil, ErrNotFound // возвращаем именно эту переменную
}
return u, nil
}
Кастомные типы ошибок
Строка — это хорошо, но иногда ошибке нужны поля: код статуса, имя
поля формы, координаты, вложенная причина. Тогда объявляют собственный тип и
реализуют у него метод Error(). Такой тип несёт структурированные
данные, которые потом можно программно прочитать.
package main
import (
"fmt"
"log"
)
// Собственный тип ошибки с дополнительными полями.
type NorgateMathError struct {
lat, long string
err error
}
// Реализуем метод Error() — теперь *NorgateMathError является error.
func (n *NorgateMathError) Error() string {
return fmt.Sprintf("ошибка norgate math: %v %v %v", n.lat, n.long, n.err)
}
func Sqrt(f float64) (float64, error) {
if f < 0 {
cause := fmt.Errorf("корень из отрицательного числа: %v", f)
return 0, &NorgateMathError{"50.2289 N", "99.4656 W", cause}
}
return 42, nil
}
func main() {
if _, err := Sqrt(-10.23); err != nil {
log.Println(err)
}
}
Метод объявлен на указателе (*NorgateMathError), поэтому и
возвращаем мы &NorgateMathError{...} — со взятием адреса. Это
частая практика: указательный получатель дешевле копировать и позволяет
сравнивать ошибки по идентичности. Так же устроены и многие ошибки стандартной
библиотеки — например, *os.PathError или *net.OpError
хранят внутри путь, операцию и вложенную причину.
Обёртка ошибок: глагол %w (Go 1.13)
Реальная программа состоит из слоёв: обработчик вызывает сервис, сервис —
репозиторий, репозиторий — драйвер БД. Ошибка рождается глубоко внизу, но
осмысленной она станет, только если по пути наверх к ней добавят контекст: «где»
и «при чём» это произошло. До Go 1.13 контекст добавляли через
%v — но тогда исходная ошибка превращалась в строку, и связь с ней
терялась. В Go 1.13 появился глагол %w (от wrap): он
сохраняет исходную ошибку внутри новой, образуя цепочку.
package main
import (
"errors"
"fmt"
)
var ErrNotFound = errors.New("не найдено")
func findUser(id int) error {
// %w оборачивает ErrNotFound, добавляя контекст, но НЕ теряя её.
return fmt.Errorf("findUser(%d): %w", id, ErrNotFound)
}
func main() {
err := findUser(42)
fmt.Println("ошибка:", err) // findUser(42): не найдено
// %w сохранил связь — errors.Is находит ErrNotFound в цепочке.
fmt.Println("это ErrNotFound?", errors.Is(err, ErrNotFound)) // true
// Для сравнения: %v связь теряет.
errV := fmt.Errorf("findUser: %v", ErrNotFound)
fmt.Println("через %v:", errors.Is(errV, ErrNotFound)) // false
}
Разница простая: %v — только текст, связь потеряна;
%w — текст плюс сохранённая ссылка на исходную ошибку. Запомни
правило: если ты хочешь, чтобы вызывающий код мог «докопаться» до первопричины —
оборачивай через %w. Если исходная ошибка внутренняя и наружу её
показывать не надо — используй %v.
errors.Is — ищем конкретную ошибку в цепочке
Раньше писали if err == ErrNotFound. Но как только ошибку
обернули хотя бы раз, прямое сравнение ломается: снаружи уже другая ошибка.
errors.Is разворачивает всю цепочку по %w и сравнивает
на каждом уровне — поэтому работает сквозь любую глубину обёрток.
package main
import (
"errors"
"fmt"
)
var ErrPermission = errors.New("доступ запрещён")
func readFile() error { return ErrPermission }
func loadConfig() error {
if err := readFile(); err != nil {
return fmt.Errorf("loadConfig: %w", err) // 1-я обёртка
}
return nil
}
func startApp() error {
if err := loadConfig(); err != nil {
return fmt.Errorf("startApp: %w", err) // 2-я обёртка
}
return nil
}
func main() {
err := startApp()
fmt.Println("полная ошибка:", err) // startApp: loadConfig: доступ запрещён
// Несмотря на две обёртки, Is находит ErrPermission в глубине.
if errors.Is(err, ErrPermission) {
fmt.Println("→ обнаружена проблема с правами доступа")
}
}
errors.As — извлекаем ошибку нужного типа
errors.Is отвечает на вопрос «это та самая ошибка?», а
errors.As — на вопрос «есть ли в цепочке ошибка такого
типа, и если да — дай мне её», чтобы прочитать поля. Это современная замена
старому приведению типа err.(*MyError), которое не умеет заглядывать
под обёртки.
package main
import (
"errors"
"fmt"
)
// Структурированная ошибка с полями.
type ValidationError struct {
Field string
Msg string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("поле %q: %s", e.Field, e.Msg)
}
func validate() error {
return &ValidationError{Field: "email", Msg: "неверный формат"}
}
func handleRequest() error {
if err := validate(); err != nil {
return fmt.Errorf("handleRequest: %w", err) // обернули
}
return nil
}
func main() {
err := handleRequest()
// Достаём *ValidationError из-под обёртки и читаем поля.
var ve *ValidationError
if errors.As(err, &ve) {
fmt.Printf("→ поле=%s, причина=%s
", ve.Field, ve.Msg)
}
}
Важная деталь: во второй аргумент errors.As передаётся
адрес переменной нужного типа (&ve). Функция «заполнит»
эту переменную найденной ошибкой и вернёт true.
errors.Unwrap — как устроена цепочка
Обе функции выше, Is и As, внутри просто вызывают
errors.Unwrap в цикле, снимая по одному слою. Напрямую
Unwrap нужен редко, но он показывает механику. Чтобы твой
собственный тип стал частью цепочки, у него объявляют метод
Unwrap() error, возвращающий вложенную ошибку.
package main
import (
"errors"
"fmt"
)
var ErrDB = errors.New("сбой базы данных")
type QueryError struct {
Query string
Err error
}
func (e *QueryError) Error() string {
return fmt.Sprintf("запрос %q: %v", e.Query, e.Err)
}
// Метод Unwrap делает QueryError частью цепочки для Is/As/Unwrap.
func (e *QueryError) Unwrap() error { return e.Err }
func main() {
err := fmt.Errorf("сервис недоступен: %w",
&QueryError{Query: "SELECT 1", Err: ErrDB})
// Разворачиваем цепочку по одному слою.
for err != nil {
fmt.Printf(" %v
", err)
err = errors.Unwrap(err)
}
}
errors.Join — несколько ошибок сразу (Go 1.20)
Иногда нужно вернуть не одну ошибку, а сразу список — например, все нарушения
валидации формы, а не только первое. До Go 1.20 для этого писали свои типы или
склеивали строки. errors.Join делает это штатно: объединяет
несколько ошибок в одну, игнорирует nil-ы, печатает каждую с новой
строки, а errors.Is/errors.As находят любую из
объединённых.
package main
import (
"errors"
"fmt"
)
var (
ErrEmptyName = errors.New("имя не заполнено")
ErrBadEmail = errors.New("неверный email")
ErrTooYoung = errors.New("возраст меньше 18")
)
type Form struct {
Name string
Email string
Age int
}
func (f Form) Validate() error {
var errs []error // копим ВСЕ нарушения, а не первое
if f.Name == "" {
errs = append(errs, ErrEmptyName)
}
if f.Email == "" {
errs = append(errs, ErrBadEmail)
}
if f.Age < 18 {
errs = append(errs, ErrTooYoung)
}
// Join вернёт nil, если errs пуст — можно возвращать напрямую.
return errors.Join(errs...)
}
func main() {
err := Form{Name: "", Email: "", Age: 15}.Validate()
fmt.Println(err)
fmt.Println("пустое имя?", errors.Is(err, ErrEmptyName)) // true
fmt.Println("молод? ", errors.Is(err, ErrTooYoung)) // true
}
panic и recover — для исключительных ситуаций
У Go всё же есть механизм, похожий на исключения: panic
раскручивает стек, а recover (вызванный внутри
defer) его останавливает. Но пользоваться этим для обычной
обработки ошибок нельзя. panic — это про
«программист облажался» (обращение по nil-указателю, выход за границы среза) или
про действительно неустранимые ситуации на старте программы. Всё, что может
случиться в нормальной жизни (файл не найден, сеть отвалилась) — это ошибки, а не
паники.
package main
import "fmt"
// safeDivide перехватывает панику деления на ноль и превращает её в ошибку.
func safeDivide(a, b int) (result int, err error) {
defer func() {
if r := recover(); r != nil {
// Превращаем панику в обычную ошибку — граница «модуля».
err = fmt.Errorf("восстановлено после паники: %v", r)
}
}()
return a / b, nil // при b == 0 будет паника
}
func main() {
if _, err := safeDivide(10, 0); err != nil {
fmt.Println(err) // восстановлено после паники: integer divide by zero
}
fmt.Println("программа продолжает работать")
}
Типичное место для recover — на границе, где нельзя дать всей
программе рухнуть из-за одной задачи: например, HTTP-сервер оборачивает каждый
обработчик, чтобы паника в одном запросе не убила весь процесс. Именно так делает
встроенный net/http.
Кейс из реального проекта
Рассмотрим типичное трёхслойное приложение: обработчик HTTP → сервис (бизнес-
логика) → репозиторий (доступ к данным). Ошибка рождается в репозитории, а
каждый слой добавляет свой контекст через %w. Наверху обработчик
одной проверкой errors.Is решает, какой HTTP-код вернуть.
package main
import (
"errors"
"fmt"
)
// Сигнальная ошибка доменного уровня.
var ErrUserNotFound = errors.New("пользователь не найден")
// --- Слой репозитория: работает с «хранилищем» ---
func repoGetUser(id int) (string, error) {
users := map[int]string{1: "Аня", 2: "Борис"}
name, ok := users[id]
if !ok {
// Оборачиваем сигнальную ошибку, добавляя технический контекст.
return "", fmt.Errorf("repo.GetUser id=%d: %w", id, ErrUserNotFound)
}
return name, nil
}
// --- Слой сервиса: бизнес-логика ---
func serviceProfile(id int) (string, error) {
name, err := repoGetUser(id)
if err != nil {
// Ещё один слой контекста, исходная причина сохранена.
return "", fmt.Errorf("service.Profile: %w", err)
}
return "Профиль: " + name, nil
}
// --- Слой обработчика: превращает ошибку в ответ ---
func handleProfile(id int) (status int, body string) {
profile, err := serviceProfile(id)
if err != nil {
// Одна проверка Is сквозь ВСЕ слои обёрток.
if errors.Is(err, ErrUserNotFound) {
return 404, "нет такого пользователя"
}
return 500, "внутренняя ошибка"
}
return 200, profile
}
func main() {
for _, id := range []int{1, 99} {
status, body := handleProfile(id)
fmt.Printf("id=%d → %d %q
", id, status, body)
}
// id=1 → 200 "Профиль: Аня"
// id=99 → 404 "нет такого пользователя"
}
Ценность подхода: технический контекст (repo.GetUser id=99) для
логов сохраняется на каждом слое, но принятие решения («это 404») остаётся в
одном месте — обработчике, и не зависит от того, сколько обёрток по пути. Именно
так строят обработку ошибок в продакшене на Go.
Типичные ошибки
Игнорировать ошибку через _
Самая опасная привычка — глушить ошибку: result, _ := doWork().
Компилятор промолчит, а баг проявится в проде в самый неподходящий момент. Если
ошибка тебе действительно не важна — оставь короткий комментарий, почему её можно
проигнорировать. Но в 99% случаев её нужно хотя бы залогировать. Линтер
errcheck ловит такие места автоматически.
Оборачивать через %v вместо %w, когда нужна цепочка
Если написать fmt.Errorf("...: %v", err), исходная ошибка
превратится в строку, и вызывающий код уже не сможет проверить её через
errors.Is. Помни: %w — когда причина должна остаться
доступной программно; %v — когда наружу её выносить не надо.
Сравнивать ошибки по тексту
Соблазн написать if strings.Contains(err.Error(), "not found")
велик, но это хрупко: текст может измениться, локализоваться, обрасти контекстом.
Сравнивай ошибки по идентичности (errors.Is) или по типу
(errors.As), а не по подстроке.
Возвращать nil-указатель кастомной ошибки
Классическая ловушка: функция возвращает *MyError, внутри пишет
var e *MyError; return e (то есть nil-указатель), а вызывающий
проверяет if err != nil — и проверка срабатывает, хотя ошибки нет!
Причина в том, что интерфейс error с nil-указателем внутри сам по
себе не nil. Возвращай тип error и явный
nil, когда ошибки нет.
Практика
- Напиши функцию
ParsePort(s string) (int, error), которая преобразует строку в номер порта. Верни осмысленные ошибки для пустой строки, для нечислового ввода и для значения вне диапазона 1–65535. Используйfmt.Errorfс подстановкой значения. - Объяви сигнальную ошибку
ErrEmptyи функцию, которая оборачивает её через%wна двух уровнях вложенности. Убедись, чтоerrors.Isнаходит её сквозь обе обёртки. - Создай кастомный тип
HTTPErrorс полямиCode intиMessage string, реализуйError(). Напиши код, который черезerrors.Asдостаёт этот тип из-под обёртки и печатаетCode. - Реализуй валидатор структуры
Order{Items int; Total float64}, который черезerrors.Joinвозвращает сразу все нарушения (пустой заказ, отрицательная сумма). - Напиши функцию
Must[...]-подобную обёртку, которая перехватываетpanicчерезrecoverи превращает её в ошибку, и покажи на примере деления на ноль.
Итог
Ошибки в Go — это значения, а не исключения, и весь стиль работы с ними
строится вокруг явной проверки if err != nil. Ты научился создавать
ошибки (errors.New, fmt.Errorf), объявлять сигнальные
ошибки и кастомные типы с методом Error(), а главное — работать с
цепочками: оборачивать через %w, искать причину через
errors.Is, извлекать типизированную ошибку через
errors.As, разворачивать цепочку errors.Unwrap и
собирать несколько ошибок в одну через errors.Join.
panic/recover оставляем для действительно исключительных ситуаций и
границ, где нельзя дать всей программе упасть. Такой подход делает поток ошибок
видимым и предсказуемым — читая код, ты всегда знаешь, что и где может пойти не
так.