1. Перевіряємо помилки за змістом, а не за err.Error()
Коли ви пишете перші програми, дуже хочеться зробити так: якщо текст помилки містить слово "invalid", то це «помилка введення», а якщо ні — то «внутрішня помилка». І це працює… рівно до першого рефакторингу, зміни формулювання, локалізації, заміни бібліотеки або просто до того дня, коли хтось поставить крапку в кінці повідомлення.
У Go рядок err.Error() — це передусім повідомлення для людини, а не контракт для програми. Якщо ви починаєте ухвалювати рішення через strings.Contains(err.Error(), "..."), ви прирікаєте себе на вічні страждання: «Чому на одному комп’ютері все працює, а на іншому помилка інша?».
Іноді рядок усе ж друкують — у лог, користувачеві або в CLI, — але розгалуження логіки краще будувати не на рядку, а на перевірюваних сутностях. Для цього в Go і існують errors.Is та errors.As.
2. errors.Is: перевірка помилки на конкретну причину
errors.Is(err, target) відповідає на запитання: «Чи є err або будь-яка з причин у ланцюжку саме помилкою target?». Це важливо, бо після wrapping порівняння err == target майже завжди перестає працювати: назовні повертається нова помилка-обгортка, а не початкова.
Ключовий сенс у тому, що errors.Is сам проходить по ланцюжку причин, ніби ви вручну викликали errors.Unwrap у циклі, але без зайвої рутини та помилок.
Міні-демо: err == ErrX ламається після wrapping
Уявімо маркерну (sentinel) помилку — заздалегідь створене значення:
package main
import (
"errors"
"fmt"
)
var ErrBadInput = errors.New("неправильне введення")
func main() {
err := fmt.Errorf("розбір: %w", ErrBadInput)
fmt.Println(err == ErrBadInput) // false
fmt.Println(errors.Is(err, ErrBadInput)) // true
}
err == ErrBadInput — false, тому що err тепер є «обгорткою». А от errors.Is(err, ErrBadInput) — true, бо errors.Is уміє зазирнути всередину.
Важливий наслідок: errors.Is працює тільки якщо причина справді «всередині»
Якщо ви додали помилку як текст через "%v", ланцюжка причин не буде, і errors.Is там нічого не знайде.
Ось чому "%w" — не «краса», а цілком практичний інструмент: він робить причину придатною для програмної перевірки.
3. errors.As: витягуємо помилку потрібного типу
Якщо errors.Is відповідає «так/ні: це та причина?», то errors.As відповідає на інше запитання: «Чи є всередині цієї помилки або в ланцюжку її причин помилка конкретного типу, і чи можна дістати її як значення, щоб подивитися деталі?».
Про errors.As можна думати як про «розумний пошук по ланцюжку причин»: вона сама проходить по wrapping і намагається знайти помилку потрібного типу. Якщо знаходить — записує її у вашу змінну, і далі ви працюєте з нею як зі звичайним об’єктом: читаєте поля, ухвалюєте рішення, формуєте зрозуміле повідомлення користувачеві. Нам не потрібно аналізувати err.Error() і вгадувати зміст за текстом — ми дістаємо типізовану помилку напряму.
Навіщо це потрібно на практиці
Типізована помилка особливо корисна, коли вам потрібні деталі, а не просто клас.
Наприклад, strconv.Atoi повертає помилку типу *strconv.NumError. У неї є поля, і інколи нам важливо зрозуміти, що саме не розпарсилося або яка операція провалилася.
Зробімо приклад: парсимо число, обгортаємо помилку контекстом через "%w", а потім витягуємо типізовану помилку:
package main
import (
"errors"
"fmt"
"strconv"
)
func main() {
_, err := strconv.Atoi("12x")
err = fmt.Errorf("розбір числа: %w", err)
var numErr *strconv.NumError
if errors.As(err, &numErr) {
fmt.Println("неправильне число:", numErr.Num) // неправильне число: 12x
}
}
Ми не аналізуємо рядок помилки, не шукаємо «invalid syntax» і не ворожимо за фазами Місяця. Ми перевіряємо тип причини й дістаємо його як нормальний Go-об’єкт.
4. Чому errors.As приймає &target
Зазвичай у новачків виникає цілком чесне запитання: «Чому не можна просто errors.As(err, target)? Навіщо цей амперсанд, і чому інколи виходить „вказівник на вказівник“?».
errors.As має записати знайдену помилку у вашу змінну target. Щоб функція могла змінити змінну, їй потрібно дати адресу цієї змінної. Це та сама логіка, що й у fmt.Scan(&x): ви передаєте «куди покласти результат».
Коротка аналогія зі Scan
Із Scan ви вже стикалися:
package main
import "fmt"
func main() {
var x int
fmt.Scan(&x) // &x — куди записати прочитане число
}
errors.As працює за тим самим принципом, тільки «читає» вона не зі stdin, а з ланцюжка помилок.
Що означає *T у цільовому типі
У прикладах часто пишуть так:
var numErr *strconv.NumError
errors.As(err, &numErr)
Важливо прочитати це спокійно:
- numErr — змінна, яка може зберігати значення типу «вказівник на strconv.NumError».
- &numErr — адреса змінної numErr, щоб errors.As могла покласти туди знайдену помилку.
- Так, візуально це виглядає як «вказівник на вказівник», і це нормально: просто помилка такого типу часто сама є вказівником.
Якщо тримати в голові фразу «&target — це „куди записати відповідь“», усе стає помітно простіше.
5. errors.Is vs errors.As: коли що обирати
З errors.As легко переборщити: «О, можна дістати тип — значить, буду діставати завжди». Але в більшості випадків вам не потрібні деталі конкретного типу помилки. Вам потрібно зрозуміти зміст ситуації: «неправильне введення», «не знайдено», «немає прав», «проблема введення/виведення». Для таких розвилок краще підходить errors.Is: він читається простіше і менше прив’язує ваш код до внутрішньої будови конкретної помилки.
errors.As варто брати тоді, коли без «внутрішностей» справді не обійтися: вам потрібно витягти поля типізованої помилки й на їхній основі сформувати поведінку або повідомлення. Наприклад, зрозуміти, яке саме значення не розпарсилося, який параметр був неправильним, який тип очікувався.
Нижче таблиця для швидкої навігації:
| Запитання | Інструмент | Що ви отримуєте |
|---|---|---|
| «Це помилка саме цієї конкретної причини?» | |
|
| «Усередині є помилка саме цього типу, і чи можна її дістати?» | |
|
Якщо зовсім коротко: errors.Is відповідає «це та причина (маркер) десь усередині?», а errors.As — «чи є помилка потрібного типу усередині, щоб дістати її деталі?».
6. Приклад: парсимо id і розгалужуємо обробку
Зробімо невеликий приклад. Нехай у нас є навчальний консольний застосунок (умовно назвемо його tasker), який приймає команду й аргументи. Ми поки що не будуємо повноцінний CLI на flag (це інша тема), але вже хочемо:
- розпарсити id із рядка,
- повернути помилку з контекстом,
- у main зрозуміти, що це за помилка: «користувач увів не число» чи «щось інше».
Функція parseID: повертаємо контекст, але не втрачаємо причину
package main
import (
"fmt"
"strconv"
)
func parseID(s string) (int, error) {
id, err := strconv.Atoi(s)
if err != nil {
return 0, fmt.Errorf("розбір id %q: %w", s, err)
}
return id, nil
}
Тут важливий стиль: контекст короткий і по суті. Ми не пишемо «ERROR!!!! parse id failed!!!», а описуємо операцію: "розбір id \"...\"". Для людини це вже корисно, а для коду причина err лишилася доступною через ланцюжок.
У main: витягуємо *strconv.NumError через errors.As
package main
import (
"errors"
"fmt"
"strconv"
)
func main() {
_, err := parseID("12x")
if err != nil {
var numErr *strconv.NumError
if errors.As(err, &numErr) {
fmt.Println("id має бути цілим числом") // id має бути цілим числом
return
}
fmt.Println("неочікувана помилка:", err)
}
}
Повідомлення користувачеві просте («id має бути цілим числом»), а «страшна» помилка з деталями лишається в err — ви можете її залогувати, вивести в налагодженні або показати в докладному режимі.
Альтернатива: errors.Is для «класу» помилки через sentinel
Іноді ми не хочемо залежати від конкретного типу помилки зі стандартної бібліотеки. Нам потрібне своє поняття: «неправильне введення». Тоді зручнішою стає зв’язка sentinel + errors.Is: errors.Is перевіряє, чи є в ланцюжку ось такий маркер.
Як читати чужий код: ручний Unwrap vs errors.Is/errors.As
Коли ви читатимете реальний Go-код (і особливо стандартну бібліотеку), ви зустрінете обидва підходи. Іноді люди вручну йдуть по ланцюжку через errors.Unwrap, але частіше — використовують errors.Is та errors.As, бо це коротше й менше схильне до помилок.
Щоб зрозуміти механіку, корисно уявити ланцюжок як «матрьошку»:
flowchart TD
E0["err: «команда: розбір id 12x: ...»"] --> E1["обгортка → *strconv.NumError"]
errors.Is/errors.As роблять приблизно те саме, тільки без того, щоб ви писали цикл самі.
7. Типові помилки під час роботи з errors.Is і errors.As
Помилка №1: перевіряти помилки через err.Error() або strings.Contains.
Такий код спочатку здається простим, але він ламається від будь-якої зміни тексту, мови або версії залежності. Якщо вам потрібна перевірюваність — використовуйте errors.Is (для конкретної маркерної помилки) або errors.As (для конкретного типу помилки).
Помилка №2: після wrapping порівнювати err == target і дивуватися, що «не збіглося».
Після fmt.Errorf("ctx: %w", ErrX) назовні повертається вже не ErrX, а обгортка. Тому порівняння == перестає бути робочим інструментом. Правильна думка тут така: «Якщо є wrapping — перевірку причини робимо через errors.Is».
Помилка №3: забути, що errors.As приймає адресу змінної (&target).
Якщо передати не &target, а просто target, errors.As не зможе записати знайдену помилку, і код або не скомпілюється, або працюватиме неправильно за змістом. Тримайте в голові аналогію: це як Scan(&x) — «куди покласти результат».
Помилка №4: очікувати, що errors.Is/errors.As запрацюють, якщо ви «обгорнули» помилку через "%v".
fmt.Errorf("ctx: %v", err) додає текст, але не створює ланцюжка причин. Для errors.Is/errors.As це виглядає як звичайна нова помилка без першопричини всередині. Якщо причина має бути перевірюваною — обгортайте через "%w".
Помилка №5: застосовувати errors.As там, де достатньо errors.Is.
errors.As потрібен, коли вам справді важливі деталі конкретного типу. Якщо ви просто розгалужуєте поведінку («не знайдено» / «немає прав» / «неправильне введення»), типізована помилка часто буде зайвою прив’язкою до внутрішньої будови. У таких випадках простіше й стабільніше проєктувати розпізнавані причини та перевіряти їх через errors.Is.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ