← назад к разделу

ОСНОВЫ ЯЗЫКА: GO Ошибки

Обязательно

Ошибки в Go: error как значение

Go

Как явно возвращать и проверять ошибки, сохранять их причину через %w и отличать ожидаемый сбой от panic.

1. Ошибка - второй результат функции

1 / 5

В Go нет обычного потока исключений: функция возвращает результат и значение error. Отсутствие ошибки обозначает nil.

  • Проверяют err сразу, до использования результата.
  • Если локально обработать сбой нельзя, его возвращают вызывающему.
  • Интерфейс error требует только метод Error() string.
ReadConfig(path) (Config, error) err != nil → returnerr == nil → работаем

2. New создаёт ориентир, Errorf добавляет данные

2 / 5

errors.New подходит для фиксированной sentinel-ошибки. fmt.Errorf подставляет контекст вызова в сообщение.

  • Экспортируемые sentinel-ошибки называют с префиксом Err.
  • Текст помогает человеку, а тип или sentinel помогают коду принять решение.
  • Ошибку не сравнивают по совпадению текста.
errors.NewErrNotFound fmt.Errorfзапись id=7 не найдена return Item{}, err Стабильная причина + полезный контекст

3. %w сохраняет цепочку причин

3 / 5

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

  • fmt.Errorf("loadUser: %w", err) сохраняет причину.
  • errors.Is(err, ErrNotFound) заменяет сравнение обёртки через ==.
  • errors.As(err, &target) открывает поля, например ValidationError.Field.
ErrNotFoundпричина repo.find: %wконтекст loadUser: %wконтекст errors.Is → true · errors.As → *ValidationError Без %w цепочка разрывается

4. panic не заменяет обычную ошибку

4 / 5

Неверный ввод, недоступный файл или отсутствующая запись - ожидаемые сбои, поэтому их возвращают через error.

  • panic оставляют для нарушенного внутреннего инварианта.
  • recover работает в отложенной функции и перехватывает panic.
  • Прятать все panic в обычный поток мешает находить дефекты программы.
Сбой Ожидаемыйreturn error Инвариантpanic defer → recover() recover - редкая граница, не привычный catch

5. Несколько ошибок и nil требуют внимания

5 / 5

errors.Join объединяет независимые сбои, сохраняя их для Is и As. Отдельная ловушка - typed nil внутри интерфейса.

  • Если var p *MyError = nil присвоить в error, интерфейс сам уже не nil.
  • Интерфейс равен nil, только когда пусты и тип, и значение.
  • Возвращайте явный nil, если конкретного ошибочного значения нет.
errA errB errors.JoinIs найдёт обе errorтип: *MyErrorзначение: nilerr != nil Интерфейс = динамический тип + динамическое значение

В большинстве языков ошибки — это исключения: что-то ломается, выполнение «выпрыгивает» из функции, и где-то выше его ловит try/catch. В Go всё устроено иначе. Здесь ошибка — это обычное значение, которое функция возвращает наравне с результатом. Разберём, почему так сделано и как с этим жить.

Зачем ошибки как значения

Главная идея Go: ошибка — это часть результата функции, а не отдельный поток управления. Функция, которая может не справиться, возвращает два значения — результат и error:

func ReadConfig(path string) (Config, error) {
    // ...
}

Вызывающий код видит второе значение прямо перед собой как переменную, и компилятор не даст объявить err и забыть про него. Полной страховки всё же нет: вызов без присваивания (os.Remove(path)) или _ вместо err молча проглотят ошибку — такие места ловит линтер errcheck, а в ревью на них смотрят отдельно. Минус — проверок становится много. Плюс — поток выполнения линеен и предсказуем: ты всегда видишь, где именно что-то может пойти не так.

Короткая формула: в Go не «бросают» ошибку, её возвращают и тут же проверяют.

Что такое error

error — это встроенный interface с единственным методом:

type error interface {
    Error() string
}

Любой тип, у которого есть метод Error() string, является ошибкой. Это значит, что ошибка — не магия, а обычное значение какого-то типа. Если ошибки нет, функция возвращает nil на месте error.

value, err := strconv.Atoi("42") // превращает строку в число
if err != nil {
    // сюда не зайдём: "42" корректно
}
// value == 42

Подробнее про интерфейсы — в отдельной статье (ссылка ниже); здесь достаточно знать, что error — это контракт «я умею рассказать о себе строкой».

Идиома if err != nil

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

f, err := os.Open("data.txt")
if err != nil {
    return err // не справились — сообщаем выше
}
// дальше работаем с f, зная, что ошибки нет

Несколько правил, которые делают этот код идиоматичным:

  • Проверяй ошибку сразу после вызова, до использования результата.
  • Если обработать ошибку здесь нельзя — возвращай её выше (return err), пусть разбирается тот, кто знает контекст.
  • При ошибке остальные возвращаемые значения обычно бессмысленны — не используй их.
// типичная цепочка: каждый шаг может сорваться
data, err := os.ReadFile(path)
if err != nil {
    return err
}
cfg, err := parse(data)
if err != nil {
    return err
}
return validate(cfg)

Да, проверок много. Это осознанный выбор языка: явность важнее краткости.

Как создавать ошибки

Два основных способа сделать ошибку.

errors.New — простая ошибка с фиксированным текстом:

import "errors"

var ErrNotFound = errors.New("запись не найдена")

func find(id int) (Item, error) {
    // ...
    return Item{}, ErrNotFound
}

Ошибку часто кладут в переменную с префиксом Err (так называемая sentinel-ошибка) — чтобы выше её можно было сравнить и принять решение.

fmt.Errorf — когда в текст нужно подставить данные:

import "fmt"

func find(id int) (Item, error) {
    // ...
    return Item{}, fmt.Errorf("запись id=%d не найдена", id)
}

fmt.Errorf форматирует строку так же, как fmt.Printf, но возвращает error.

Обёртывание и errors.Is / errors.As

Когда ошибка поднимается через несколько слоёв, голый текст вроде "запись не найдена" не помогает: непонятно, где и при чём это случилось. Решение — обёртывание (wrapping): добавить контекст, но сохранить исходную ошибку внутри.

Для этого в fmt.Errorf используется глагол %w (от wrap):

func loadUser(id int) (User, error) {
    u, err := repo.find(id)
    if err != nil {
        // добавляем контекст, но не теряем исходную ошибку
        return User{}, fmt.Errorf("loadUser id=%d: %w", id, err)
    }
    return u, nil
}

Теперь текст ошибки накапливает «маршрут» (loadUser id=7: запись не найдена), а внутри по-прежнему лежит оригинал. Достать его помогают две функции.

errors.Is — проверить, что где-то в цепочке обёрток есть конкретная sentinel-ошибка:

_, err := loadUser(7)
if errors.Is(err, ErrNotFound) {
    // да, в глубине это именно "не найдено" — отвечаем соответствующе
}

Сравнивать через == здесь нельзя: обёрнутая ошибка — уже другое значение. errors.Is разворачивает слои за тебя.

errors.As — когда нужна не просто проверка, а доступ к полям ошибки определённого типа:

type ValidationError struct {
    Field string
}

func (e *ValidationError) Error() string {
    return "некорректное поле: " + e.Field
}

var vErr *ValidationError
if errors.As(err, &vErr) {
    // нашли ValidationError в цепочке — можно прочитать vErr.Field
    fmt.Println("проблемное поле:", vErr.Field)
}

Короткая формула: оборачивай через %w, проверяй значение через errors.Is, извлекай тип через errors.As.

Всё то же одним запускаемым куском:

живой пример

package main

import (
    "errors"
    "fmt"
)

var ErrNotFound = errors.New("запись не найдена")

func find(id int) error {
    return ErrNotFound
}

func loadUser(id int) error {
    if err := find(id); err != nil {
        return fmt.Errorf("loadUser id=%d: %w", id, err)
    }
    return nil
}

func main() {
    err := loadUser(7)
    fmt.Println(err)                      // loadUser id=7: запись не найдена
    fmt.Println(errors.Is(err, ErrNotFound)) // true: оригинал жив внутри обёртки
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

repo.find возвращает ErrNotFound, loadUser оборачивает её через %w, обёрнутая ошибка уходит в вызывающий код, а тот достаёт исходную через errors.Is

panic и recover — редкий случай

В Go есть и механизм, похожий на исключения: panic разворачивает стек и аварийно завершает программу, а recover позволяет его перехватить. Но это не замена обычным ошибкам.

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

живой пример

package main

import "fmt"

func recovered() {
    defer func() {
        if r := recover(); r != nil {
            // перехватили panic, не дали процессу упасть
            fmt.Println("восстановились после:", r)
        }
    }()
    panic("что-то пошло совсем не так")
}

func main() {
    recovered()
    fmt.Println("программа продолжает работу")
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

recover работает только внутри defer-функции. Типичное применение — на границе, чтобы одна сбойная задача не уронила весь сервис. Правило по умолчанию: ошибки возвращай, panic не используй.

Ошибки на уровне HTTP и backend

Этот раздел — про язык. Как ошибки превращаются в HTTP-ответы (коды статусов, единый формат тела, логирование на границе обработчика) — отдельная тема backend-уровня. Об этом — в статье Ошибки и HTTP в Go из раздела про серверную разработку.

Дополнительно: при первом чтении можно пропустить

Глубже: несколько ошибок сразу, стиль обёртки и nil, который не nilрасширенное

Валидация формы находит не одну ошибку, а три, и терять две из них обидно. С Go 1.20 несколько ошибок собирают в одну через errors.Join, и errors.Is/errors.As умеют заглядывать в каждую:

живой пример

package main

import (
    "errors"
    "fmt"
)

var ErrEmptyName = errors.New("пустое имя")
var ErrBadEmail = errors.New("некорректная почта")

func validate(name, email string) error {
    var errs []error
    if name == "" {
        errs = append(errs, ErrEmptyName)
    }
    if email == "" {
        errs = append(errs, ErrBadEmail)
    }
    return errors.Join(errs...) // nil, если срез пуст
}

func main() {
    err := validate("", "")
    fmt.Println(err)                       // обе ошибки, каждая с новой строки
    fmt.Println(errors.Is(err, ErrBadEmail)) // true
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

Тем же способом fmt.Errorf принимает несколько %w в одной строке. Обратную операцию — снять один слой обёртки — делает errors.Unwrap, но в прикладном коде она почти не нужна: Is и As ходят по цепочке сами.

Про стиль. Текст ошибки пишут со строчной буквы и без точки в конце, потому что он станет серединой чужой фразы: loadUser id=7: запись не найдена. Контекст добавляют коротким «что делали», а не пересказом. И глагол %v вместо %w — не безобидная опечатка: текст сохранится, а цепочка оборвётся, и errors.Is выше перестанет находить исходную ошибку.

Последняя ловушка не про пакет errors, а про интерфейсы: функция, которая объявила переменную типа *MyError и вернула её пустой, отдаст не nil, а error с типом внутри — и if err != nil сработает. Разбор с примером — в интерфейсах, раздел про nil внутри интерфейса.

Коротко

  • Ошибка в Go — это значение типа error (интерфейс с методом Error() string), а не исключение.
  • Функция возвращает error рядом с результатом; вызывающий проверяет его идиомой if err != nil.
  • Создавай ошибки через errors.New (фиксированный текст) или fmt.Errorf (с данными).
  • Оборачивай ошибку через %w, чтобы добавить контекст и сохранить оригинал внутри.
  • errors.Is ищет в цепочке конкретную sentinel-ошибку, errors.As — извлекает ошибку нужного типа.
  • panic/recover — только для неустранимых сбоев; для ожидаемых ошибок всегда возвращай error.
  • errors.Join и несколько %w собирают ошибки в одну; текст ошибки — со строчной буквы и без точки, %v вместо %w рвёт цепочку.

Что пощупать

В практикуме remodov/marketplace-system-go ошибки двух видов: значение ErrInvalid, которое узнают через errors.Is, и тип *OutOfStockError с числами внутри для errors.As; обработчик переводит их в 400 и 409.

Код: errors.go, handler.go.

Сделаем сами

Ветка step-03-commands-and-errors.

Что почитать дальше