Коротко: в Go ошибка — обычное значение, а не исключение: функция возвращает error, и вы обрабатываете его сразу; никаких try/catch.

Главное, что нужно понять про ошибки в Go: ошибка — это обычное значение, а не исключение. Функция возвращает error вторым результатом, и вы обрабатываете его сразу. Никаких try/catch в обычном потоке. Ниже — как создавать ошибки, оборачивать их с сохранением причины, разбирать через errors.Is/errors.As и когда уместны panic/recover.

Ошибки — это значения

Базовый паттерн Go: функция возвращает результат и error. Если ошибка не nil — что-то пошло не так:

data, err := os.ReadFile("config.json")
if err != nil {
    return fmt.Errorf("чтение конфига: %w", err)
}
// здесь data валидна

error — это просто интерфейс с одним методом Error() string. Любой тип, реализующий его, является ошибкой.

Создание ошибок

Два базовых способа — errors.New для простого текста и fmt.Errorf для форматирования:

import "errors"

err1 := errors.New("пользователь не найден")
err2 := fmt.Errorf("не удалось обработать заказ %d", orderID)

Обёртывание ошибок (%w)

Чтобы не терять исходную причину при проброс ошибки вверх, её оборачивают глаголом %w. Так формируется цепочка ошибок с контекстом на каждом уровне:

func loadUser(id int) (*User, error) {
    row, err := db.Query(id)
    if err != nil {
        return nil, fmt.Errorf("loadUser(%d): %w", id, err)
    }
    // ...
}

В отличие от %v (просто текст), %w сохраняет саму ошибку внутри — её потом можно достать.

Разбор ошибок: errors.Is и errors.As

errors.Is проверяет, есть ли в цепочке конкретная ошибка (sentinel), а errors.As — достаёт ошибку нужного типа:

// sentinel-ошибка пакета
var ErrNotFound = errors.New("не найдено")

func get(id int) error {
    return fmt.Errorf("get %d: %w", id, ErrNotFound)
}

func main() {
    err := get(42)

    if errors.Is(err, ErrNotFound) {
        fmt.Println("это ошибка 'не найдено'")
    }

    var pathErr *os.PathError
    if errors.As(err, &pathErr) {
        fmt.Println("проблема с путём:", pathErr.Path)
    }
}

Это правильная замена сравнению строк ошибок — оно ломается при любом изменении текста.

Кастомные типы ошибок

Когда нужно передать вместе с ошибкой данные (код, поле, причину), создают свой тип:

type ValidationError struct {
    Field string
    Msg   string
}

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

// использование
return &ValidationError{Field: "email", Msg: "некорректный формат"}

Такой тип потом удобно ловить через errors.As.

panic и recover — для исключительных ситуаций

panic разворачивает стек и аварийно завершает программу. Это не механизм бизнес-логики — его применяют для действительно непредвиденного (нарушение инварианта, баг). Перехватить панику можно через recover внутри defer:

func safeRun() (err error) {
    defer func() {
        if r := recover(); r != nil {
            err = fmt.Errorf("паника перехвачена: %v", r)
        }
    }()

    doRiskyThing() // если внутри panic — поймаем
    return nil
}

Типичный уместный случай — recover на верхнем уровне HTTP-обработчика, чтобы одна паника не уронила весь сервер.

defer для освобождения ресурсов

defer откладывает вызов до выхода из функции — идеально для закрытия ресурсов, чтобы не забыть про них при раннем return по ошибке:

f, err := os.Open("data.txt")
if err != nil {
    return err
}
defer f.Close() // выполнится при любом выходе из функции

Хорошие практики

  • Обрабатывайте ошибку сразу после вызова, не копите.
  • Добавляйте контекст через %w на каждом значимом уровне, но без дублирования.
  • Сравнивайте ошибки через errors.Is/errors.As, а не по тексту.
  • Не используйте panic для ожидаемых ошибок (пустой ввод, не найдено и т.п.).
  • Не игнорируйте err через _, если не уверены на 100%, что он не важен.

Явная обработка ошибок поначалу кажется многословной, но именно она делает Go-код предсказуемым и устойчивым в проде. Если переносите на Go сервис с большим количеством внешних интеграций и думаете об архитектуре обработки сбоев — обсудим на консультации.