JavaRush /Курсы /Go SELF /Sentinel errors vs typed errors: как выбрать

Sentinel errors vs typed errors: как выбрать

Go SELF
17 уровень , 2 лекция
Открыта

1. Зачем вообще выбирать: «маркер» или «тип»?

Ошибки в Go — это не только «сообщение, чтобы вывести пользователю». Это ещё и договор между функциями: что именно вызывающий код вправе распознавать и как ему разрешено ветвить поведение. Если этот договор не продумать, вы быстро скатитесь в strings.Contains(err.Error(), "..."), а это почти как чинить космический корабль изолентой: иногда держится, но стыдно и страшно.

Представьте, что вы пишете маленький CLI для задач (наше учебное приложение), и вам нужно различать ситуации: «пользователь ввёл ерунду» и «задачи с таким id нет». В одном случае вы хотите подсказать правильный ввод, в другом — сказать «не найдено». И вот тут возникает развилка: как именно дать вызывающему коду возможность это понять?

В Go есть два самых популярных «распознаваемых» подхода:

  • Sentinel error: заранее объявленная «маркерная» ошибка-значение (var ErrNotFound = errors.New("not found")), которую потом ищут через errors.Is.
  • Typed error: ошибка распознаётся по конкретному типу, который можно извлечь через errors.As (например, *strconv.NumError из strconv.Atoi).

Дальше будем учиться выбирать между ними осознанно.

2. Sentinel errors: распознаём ситуацию, а не детали

Sentinel error — это когда вы создаёте одно значение ошибки и используете его как «ярлык». Это похоже на штамп "NOT FOUND" в паспорте ошибки: вызывающему коду не важно, какая там история и кто виноват — важно, что это именно тот класс ситуации, по которому нужно выбрать понятное поведение. В Go это исторически очень распространённый и простой способ сделать ошибку проверяемой.

Как объявлять sentinel и почему нельзя errors.New внутри функции

Sentinel работает только если это одно и то же значение, а не «каждый раз новая ошибка с тем же текстом». В Go две ошибки с одинаковым текстом — это не «одинаковая ошибка», а два разных значения, которые не равны друг другу. Поэтому маркерные ошибки объявляют на уровне пакета, один раз, и потом переиспользуют.

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

package main

import (
	"errors"
	"fmt"
)

var ErrNotFound = errors.New("not found")

func good() error {
	return ErrNotFound
}

func main() {
	err := good()
	fmt.Println(err == ErrNotFound) // true
}

А теперь «похоже, но нет»:

package main

import (
	"errors"
	"fmt"
)

var ErrNotFound = errors.New("not found")

func bad() error {
	return errors.New("not found") // новая ошибка каждый раз
}

func main() {
	err := bad()
	fmt.Println(err == ErrNotFound) // false
}

Вот почему sentinel — это именно значение (один объект), а не просто текст.

Sentinel + wrapping: почему лучше "ctx: %w", чем вернуть ErrX напрямую

Если вы будете возвращать sentinel напрямую, у людей появится соблазн писать err == ErrNotFound. А как только вы захотите добавить контекст через wrapping, такие сравнения начнут ломаться, и вы получите «дрейф API»: кто-то уже завязался на ==, кто-то — на errors.Is, и начинается весёлый зоопарк.

Поэтому лучше возвращать ошибку, оборачивающую sentinel, чтобы внешние пользователи сразу привыкали к errors.Is.

package main

import (
	"errors"
	"fmt"
)

var ErrPermission = errors.New("permission denied")

func checkPermission(ok bool) error {
	if !ok {
		return fmt.Errorf("check permission: %w", ErrPermission)
	}
	return nil
}

func main() {
	fmt.Println(checkPermission(false)) // check permission: permission denied
}

Здесь строка стала «человеческой», но причина остаётся проверяемой.

Когда sentinel начинает мешать

Sentinel — отличный инструмент, но у него есть цена: он хорошо отвечает на вопрос «какой класс ситуации?», но плохо — на вопрос «какие детали?». Если вам нужно различать десятки разновидностей «не найдено», вы либо начнёте плодить ErrNotFoundUser, ErrNotFoundTask, ErrNotFoundProject, либо скатитесь в передачу деталей через текст (и опять здравствуй strings.Contains).

Ещё одна тонкая проблема: если вы оборачиваете и отдаёте наружу чужие ошибки (из другой библиотеки), вы фактически обещаете: «эта конкретная низкоуровневая ошибка будет частью моего контракта навсегда». Wrapping делает причину доступной для программной проверки, а значит — частью API. Если это внутренняя деталь реализации, её лучше не раскрывать.

3. Typed errors: нужны детали, а не просто ярлык

Typed error — это подход «я узнаю ошибку по её типу». В обычной речи это похоже на: «это не просто “ошибка парсинга”, это конкретно *strconv.NumError — значит, парсили число и получили нецифровой ввод». Такой подход особенно уместен, когда ошибка несёт полезные технические подробности, и вы хотите их достать без анализа строки.

Важно: здесь мы не проектируем свои собственные сложные типы ошибок как полноценную модель (это отдельная тема). Но мы обязаны уметь читать и использовать typed errors стандартной библиотеки — они уже вокруг нас.

Как распознают typed error: errors.As(err, &target)

errors.As — это «дай мне ошибку такого-то типа, если она есть в цепочке». Она проходит по error chain и пытается найти значение нужного типа. Если нашла — записывает его в переменную-цель (поэтому передаём &target).

Мини-демо (мы специально добавим wrapping, чтобы увидеть, что As всё равно достаёт причину):

package main

import (
	"errors"
	"fmt"
	"strconv"
)

func main() {
	_, err := strconv.Atoi("12x")
	err = fmt.Errorf("parse id: %w", err)

	var numErr *strconv.NumError
	if errors.As(err, &numErr) {
		fmt.Println("typed cause:", numErr.Error()) // typed cause: strconv.Atoi: parsing "12x": invalid syntax
	}
}

Заметьте: мы не сравнивали строки, не искали подстроки. Мы сказали: «если внутри есть *strconv.NumError — достань».

Пример из приложения: парсим id задачи и хотим «умный» вывод

Представим, что наш CLI принимает команду вида "done 10" (пометить задачу выполненной). Мы парсим "10" через strconv.Atoi. Если пользователь введёт "done ten", то Atoi вернёт typed error *strconv.NumError.

Пишем функцию parseTaskID, которая добавляет контекст, и при этом сохраняет typed-причину:

package main

import (
	"fmt"
	"strconv"
)

func parseTaskID(s string) (int, error) {
	id, err := strconv.Atoi(s)
	if err != nil {
		return 0, fmt.Errorf("parse task id %q: %w", s, err)
	}
	return id, nil
}

На этом уровне мы пока не решаем, что показывать пользователю. Мы просто честно сохраняем причину.

Теперь на верхнем уровне можно сделать более полезную диагностику (для себя, для логов, или просто для отладки на этапе обучения): если причина — именно *strconv.NumError, значит пользователь ввёл не число.

Когда typed error превращается в утечку деталей реализации

Typed errors — мощная штука, но они часто «привязывают» внешний код к конкретной библиотеке. Сегодня у вас strconv.Atoi, завтра вы решили парсить по-другому — и внезапно внешний код уже не может распознать ошибку тем же способом.

С wrapping есть ещё более острый угол: если вы оборачиваете ошибку чужого пакета через "%w", вы позволяете внешнему коду писать errors.Is(err, sql.ErrNoRows) (или errors.As к конкретным типам), и тем самым фиксируете чужой тип/значение как часть своего контракта. Если вы не готовы поддерживать это как часть API, лучше не делать такую причину «проверяемой» на внешнем уровне.

4. Как выбрать: sentinel или typed

Если вы сейчас думаете «а можно всегда typed, потому что это круче?», то вы — не первый. Так думают многие, пока не попробуют жить с этим год-два, меняя реализации. А если думаете «давайте всегда sentinel, потому что проще», вы тоже не первый — и тоже упрётесь, когда вам понадобятся детали.

Чтобы не гадать, удобно держать в голове простую табличку критериев:

Вопрос Если ответ “да” Что обычно выбрать
Внешнему коду достаточно знать класс ситуации (не найдено, валидация, нет прав) без деталей? Да Sentinel error + errors.Is
Внешнему коду нужны детали причины (например, что именно не распарсилось, какой формат ожидался)? Да Typed error + errors.As
Ошибка пришла из библиотеки, которая является деталью реализации, и вы не хотите обещать её наружу? Да Не wrap’ать чужую ошибку как часть контракта; чаще «перепаковать» в свой формат (или хотя бы не делать "%w")
Ошибка — часть вашего публичного договора и должна распознаваться стабильно, даже если реализация поменяется? Да Sentinel на вашем уровне (или ваш typed error, но его проектируют осторожно)
Вы хотите, чтобы вызывающий код мог писать простую проверку без «внутренностей»? Да Sentinel (как минимальный контракт)

И есть очень рабочее «правило трёх вопросов», которое можно проговаривать почти вслух (да, программисты разговаривают с кодом — это нормально, пока код не отвечает):

Первый вопрос: «Нужно ли вызывающему коду ветвить поведение по этой ошибке?» Если нет — достаточно просто текста. Если да — идём дальше.

Второй вопрос: «Достаточно ли ветвления по классу (валидация/не найдено)?» Если да — sentinel. Если нет — вероятно нужен typed.

Третий вопрос: «Я готов обещать этот тип/значение наружу как часть API?» Если нет — не делайте эту ошибку распознаваемой на этом уровне (или сделайте распознаваемой через свой sentinel‑класс, а детали оставьте внутри).

5. Смешанный подход: sentinel-класс и typed-причина

Жизнь редко заставляет выбирать строго одно. Часто лучший дизайн выглядит так: «наружу даём класс через sentinel, а внутри сохраняем техническую причину как typed error». Это позволяет верхнему уровню сказать пользователю «неверный ввод», а разработчику (или логам) — увидеть, что конкретно сломалось, не теряя цепочку причин. Такой подход хорошо сочетается с идеей wrapping и проверок Is/As.

Добавим в приложение два «класса» ошибок

Для нашего CLI-задачника заведём два маркера: ErrValidation и ErrNotFound. Мы не делаем их супер-детальными: это именно классы поведения.

package main

import "errors"

var (
	ErrValidation = errors.New("validation error")
	ErrNotFound   = errors.New("not found")
)

Эти ошибки удобны тем, что UI-слой (пока это просто main) сможет решить: печатать подсказку по вводу или сообщать «не найдено».

parseTaskID: объединяем класс и typed-причину

Теперь делаем parseTaskID, которая при ошибке парсинга вернёт и класс, и причину. Самый простой способ — errors.Join, но даже без него можно обернуть класс поверх причины. Мы воспользуемся errors.Join, потому что это аккуратно хранит несколько причин в одном error и остаётся проверяемым через Is/As.

package main

import (
	"errors"
	"fmt"
	"strconv"
)

func parseTaskID(s string) (int, error) {
	id, err := strconv.Atoi(s)
	if err != nil {
		// Класс + конкретная причина (typed) сохраняются вместе
		return 0, fmt.Errorf("parse task id: %w", errors.Join(ErrValidation, err))
	}
	return id, nil
}

Важный эффект: errors.Is(err, ErrValidation) будет true, и одновременно errors.As(...) тоже сможет сработать, потому что *strconv.NumError внутри сохранён.

Верхний уровень: поведение по sentinel и диагностика по typed

Пишем маленький демонстрационный main, который имитирует команду "done <id>" и показывает два уровня обработки.

package main

import (
	"errors"
	"fmt"
	"strconv"
)

func main() {
	// Представим, что пользователь ввёл "done ten"
	rawID := "ten"

	_, err := parseTaskID(rawID)
	if err == nil {
		fmt.Println("ok")
		return
	}

	// Поведение для пользователя — по КЛАССУ (sentinel)
	if errors.Is(err, ErrValidation) {
		fmt.Println("ошибка ввода: id должен быть целым числом") // ошибка ввода: id должен быть целым числом
	}

	// Диагностика для разработчика — по ТИПУ (typed)
	var numErr *strconv.NumError
	if errors.As(err, &numErr) {
		fmt.Println("debug:", numErr.Error()) // debug: strconv.Atoi: parsing "ten": invalid syntax
	}
}

Обратите внимание на «разделение ролей». Sentinel здесь отвечает на вопрос: «что сказать пользователю?», а typed error — на вопрос: «что конкретно сломалось внутри?». Это очень «по-гошному»: человек получает понятное сообщение, а код не теряет структуру причины.

6. Типичные ошибки при выборе sentinel vs typed

Ошибка №1: «Я различаю ошибки по строке, потому что так быстрее».
Сначала кажется, что strings.Contains(err.Error(), "not found") — это быстрый хак. Потом вы меняете текст ошибки (или библиотека меняет), и внезапно бизнес-логика начинает вести себя иначе. В Go принято различать ошибки через errors.Is (для маркеров) и errors.As (для типов), потому что это опирается на структуру, а не на текст.

Ошибка №2: создавать sentinel внутри функции.
Это классическая ловушка: «я же возвращаю errors.New("not found"), почему errors.Is не работает?». Потому что это новый объект каждый раз. Sentinel должен быть одним значением, объявленным один раз на уровне пакета, иначе узнаваемость исчезает.

Ошибка №3: возвращать sentinel напрямую и поощрять err == ErrX.
Сегодня вы возвращаете ErrNotFound, завтра захотите добавить контекст — и все сравнения == сломаются. Гораздо стабильнее сразу возвращать fmt.Errorf("ctx: %w", ErrNotFound), чтобы потребители привыкали к errors.Is.

Ошибка №4: делать typed errors частью публичного контракта случайно.
Вы обернули ошибку чужого пакета через "%w", и внезапно внешний код начал проверять её тип или sentinel чужой библиотеки. Теперь вы фактически обязаны сохранять эту библиотеку (или хотя бы этот тип ошибки), иначе сломаете пользователей. Wrapping в таком виде превращается в «обещание API», и к нему нужно относиться осторожно.

Ошибка №5: использовать errors.As там, где достаточно errors.Is.
errors.As — инструмент «достать детали», а не «проверить класс». Если вам нужно просто понять «валидация это или нет» — используйте sentinel и errors.Is. Иначе код быстро становится перегруженным деталями, которые не влияют на поведение, и чтение превращается в археологию.

1
Задача
Go SELF, 17 уровень, 2 лекция
Недоступна
Пропуск на сервер
Пропуск на сервер
1
Задача
Go SELF, 17 уровень, 2 лекция
Недоступна
Строгий парсер
Строгий парсер
1
Задача
Go SELF, 17 уровень, 2 лекция
Недоступна
Поиск товара
Поиск товара
1
Задача
Go SELF, 17 уровень, 2 лекция
Недоступна
Возраст посетителя
Возраст посетителя
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ