1. Wrapping у fmt.Errorf
Коли ви тільки починаєте, здається, що достатньо повернути fmt.Errorf("що-небудь пішло не так") — і на цьому все. Але реальний код швидко стає багатоповерховим: main викликає run, той викликає parse, а той викликає strconv.Atoi, і десь усередині все падає. У підсумку ви бачите помилку, але не розумієте, у якій саме операції це сталося і якою була першопричина.
Уявіть, що помилка — це посилка, яка їде ланцюжком функцій угору. Кожна функція — як сортувальний пункт. Їй хочеться наклеїти свій стікер: «я зараз розбирав parse», «я зараз завантажував конфіг», «я зараз перевіряв введення». Так з’являється контекст. Але якщо клеїти стікери неправильно, ви можете втратити інформацію про те, що лежить усередині коробки.
У Go 1.13 (і, звісно, у Go 1.25) для цього є стандартний механізм: wrapping — обгортання помилки так, щоб вона залишалася доступною «всередині» й могла бути витягнута стандартними засобами. Це робиться через fmt.Errorf і спеціальний плейсхолдер %w.
%v: контекст додали, але причину сховали в тексті
Коли ми пишемо:
return fmt.Errorf("parse age: %v", err)
ми отримуємо гарний рядок, і для людини це вже корисно. Але технічно це нова помилка, яка просто містить текст старої помилки. Якщо потім хтось захоче програмно дістатися до причини, «всередині» вже нічого немає — лише рядок.
Це важливо зрозуміти саме як механіку: форматування через %v не створює розгорнутого ланцюжка причин. До появи %w результат fmt.Errorf із %v не можна було «розгорнути» — errors.Unwrap повертає nil, тобто початкова помилка недоступна як структура.
Подивімося на мікроприклад: ми додамо контекст, а потім спробуємо «зняти один шар».
package main
import (
"errors"
"fmt"
)
func main() {
base := errors.New("bad input")
w := fmt.Errorf("parse: %v", base)
fmt.Println(w) // parse: bad input
fmt.Println(errors.Unwrap(w)) // <nil>
}
Пояснення просте: "parse: bad input" виглядає нормально, але з точки зору програми це одна пласка помилка, без посилання на base. Саме тому %v — це «контекст для людини», але не «контекст зі збереженням причини».
%w: той самий текст, але з’являється внутрішня причина
Тепер найцікавіше: %w зовні поводиться майже так само, як %v (текст буде схожий), але всередині створюється обгортка, яка вміє повернути першопричину через метод Unwrap.
Формально: якщо у fmt.Errorf присутній %w, то помилка, що повертається, отримує Unwrap() error, який повертає початкову помилку-аргумент.
Подивімося той самий приклад, але з %w:
package main
import (
"errors"
"fmt"
)
func main() {
base := errors.New("bad input")
w := fmt.Errorf("parse: %w", base)
fmt.Println(w) // parse: bad input
fmt.Println(errors.Unwrap(w)) // bad input
}
Ключова думка: контекст («parse») додали, а причину («bad input») не втратили. Це і є wrapping.
Важливо сприймати wrapping як створення нової помилки, яка «містить» стару. Це як матрьошка: зверху напис «parse», а всередині — початкова помилка. У матрьошки можна відкрити один шар і побачити, що всередині.
3. Ланцюжок причин і errors.Unwrap
Кілька обгорток поспіль
У реальному застосунку помилка рідко проходить рівно через одну функцію. Зазвичай вона піднімається вгору через кілька рівнів, і кожен рівень додає свій контекст.
Go називає послідовність «помилка → її причина → причина причини → …» ланцюжком причин. Повторний «unwrap» — тобто зняття одного шару — дозволяє крок за кроком пройти вниз цим ланцюжком.
Зберімо ланцюжок із трьох рівнів:
package main
import (
"errors"
"fmt"
)
func main() {
base := errors.New("bad input")
w1 := fmt.Errorf("parse: %w", base)
w2 := fmt.Errorf("run: %w", w1)
fmt.Println(w2) // run: parse: bad input
fmt.Println(errors.Unwrap(w2)) // parse: bad input
}
Зверніть увагу, що errors.Unwrap(w2) знімає лише один шар. Це очікувано й правильно: unwrap — це «відкрий один шар матрьошки», а не «розбери все до ядра».
Щоб було простіше тримати це в голові, можна уявити таку схему:
flowchart TD
A["базова помилка: bad input"] --> B["w1: parse: %w(base)"]
B --> C["w2: run: %w(w1)"]
Як знімати шари вручну
У прикладному коді люди зазвичай не хочуть вручну ходити ланцюжком, але розуміти механіку дуже корисно: це допомагає читати чужі помилки, акуратно логувати їх і не ламати контракт функцій.
У стандартній бібліотеці є функція errors.Unwrap(err), яка повертає результат err.Unwrap(), а якщо метод Unwrap() не підтримується — повертає nil.
Напишемо мініфункцію, яка друкує ланцюжок причин построково. Це корисно як інструмент налагодження (і так, це той самий «printf-debugging», тільки з повагою до структури).
package main
import (
"errors"
"fmt"
)
func printChain(err error) {
for err != nil {
fmt.Println("-", err)
err = errors.Unwrap(err)
}
}
І тепер спробуємо:
package main
import (
"errors"
"fmt"
)
func main() {
base := errors.New("bad input")
err := fmt.Errorf("parse: %w", base)
err = fmt.Errorf("run: %w", err)
printChain(err)
// - run: parse: bad input
// - parse: bad input
// - bad input
}
Цей вивід інколи виглядає так, ніби помилка вирішила прочитати вам увесь родовід до прадіда. Але для розробника це золото: видно, де додавали контекст і що було першопричиною.
4. Практика: CLI і хороші повідомлення про помилки
Каркас застосунку
Тепер продовжимо нашу навчальну лінію: простий консольний застосунок, який приймає команду та аргумент, перевіряє введення і виконує дію. Жодних структур і баз даних — ми поки що в «початковій лізі», але помилки вже будуть цілком дорослими.
Нехай застосунок розуміє команду add <title>. Ввід читаємо як два слова через fmt.Scan (так, заголовок без пробілів — це обмеження, але для сьогоднішньої теми достатньо).
Спочатку зробімо базову «заготовку» з run():
package main
import (
"fmt"
)
func run() error {
var cmd, arg string
fmt.Scan(&cmd, &arg)
fmt.Println("cmd =", cmd, "arg =", arg) // cmd = add arg = test
return nil
}
func main() {
_ = run()
}
Перевірка команди й обгортання причини
Тепер додамо перевірку команди та обробку помилок. Ми хочемо, щоб run() повертав помилку нагору, а main() друкував її як фінальний результат. Це хороший стиль: «всередині» ми повертаємо помилки, а «ззовні» вирішуємо, що показати користувачу.
Зробімо функцію parseCommand:
package main
import (
"errors"
"fmt"
)
var ErrBadInput = errors.New("bad input")
func parseCommand(cmd string) error {
if cmd != "add" {
return fmt.Errorf("unknown command %q: %w", cmd, ErrBadInput)
}
return nil
}
Зверніть увагу: ми повертаємо контекст («unknown command ...») і обгортаємо ErrBadInput через %w. Це означає, що причина "bad input" збережена всередині.
Підключімо це в run():
package main
import "fmt"
func run() error {
var cmd, arg string
fmt.Scan(&cmd, &arg)
if err := parseCommand(cmd); err != nil {
return fmt.Errorf("run: %w", err)
}
_ = arg
return nil
}
І нарешті — main, який друкує помилку:
package main
import "fmt"
func main() {
if err := run(); err != nil {
fmt.Println("ERROR:", err) // ERROR: run: unknown command "list": bad input
}
}
Виглядає просто, але всередині в нас уже ланцюжок: run → parseCommand → ErrBadInput.
%w vs %v: коротке порівняння
Коли ви тільки починаєте, дуже легко переплутати: «обидва ж друкують помилку, навіщо два різні?». Різниця не в красі рядка, а в тому, чи зберігаємо ми причину як структуру.
| Що робимо | Приклад | Що побачить людина | Чи можна зняти причину через errors.Unwrap |
|---|---|---|---|
| Додали текст, але сховали причину в рядок | |
parse: <текст err> | Ні (Unwrap поверне nil) |
| Додали текст і зберегли причину | |
Майже те саме | Так (Unwrap поверне err) |
І тут з’являється важлива інженерна думка: рішення «обгортати чи ні» — це рішення про контракт, а не про логування. Обгортаючи, ви робите внутрішню причину доступною для коду, що викликає; не обгортаючи — приховуєте деталі заради абстракції.
Як писати хороший контекст
Контекст помилки — це не місце для роману на кшталт «Війна і мир: розділ 1, у якому я намагався розпарсити рядок». Хороший контекст — це коротка відповідь на запитання: яку операцію ми виконували, коли все зламалося.
Наприклад, такі контексти зазвичай допомагають:
- читати вхідні дані
- розібрати вік
- перевірити команду
- зберегти задачу
А такі контексти майже марні:
- помилка
- щось пішло не так
- не вдалося
Доопрацюємо наш CLI, додавши перевірку аргументу. Нехай назва задачі не може бути порожньою (для fmt.Scan це майже не трапляється, але корисно як приклад):
package main
import (
"errors"
"fmt"
)
var ErrBadInput = errors.New("bad input")
func parseTitle(arg string) (string, error) {
if arg == "" {
return "", fmt.Errorf("title: %w", ErrBadInput)
}
return arg, nil
}
Тепер у run() додамо контекст кожного кроку:
package main
import "fmt"
func run() error {
var cmd, arg string
fmt.Scan(&cmd, &arg)
if err := parseCommand(cmd); err != nil {
return fmt.Errorf("parse command: %w", err)
}
title, err := parseTitle(arg)
if err != nil {
return fmt.Errorf("parse title: %w", err)
}
fmt.Println("added:", title) // added: test
return nil
}
Виходить багатослівно, зате якщо щось зламається, ви майже завжди зможете зрозуміти: де саме, на якому кроці і що було причиною.
Коли %w використовувати не потрібно
Дуже легко спокуситися правилом: «завжди пишемо %w». Але Go спеціально не робить із цього догму: абсолютні правила на кшталт «завжди %w» хибні, бо іноді ви не хочете розкривати внутрішню причину.
Уявіть, що всередині вашого застосунку є шар, який працює з якоюсь бібліотекою. Якщо ви почнете повертати назовні помилки так, щоб їх можна було unwrap-ити до конкретних типів або значень із бібліотеки, ви, по суті, обіцяєте: «ця бібліотека — частина моєї публічної поведінки». А потім ви захочете замінити бібліотеку — і раптом зламаєте зовнішній код, який спирався на конкретну причину.
Для нашого маленького CLI це можна відчути так. Припустімо, десь усередині в нас з’явилася функція, яка «читає конфіг» (зараз це просто імітація):
package main
import (
"errors"
"fmt"
)
func loadConfig() error {
internal := errors.New("low-level config format error")
return fmt.Errorf("load config: %v", internal) // не розкриваємо внутрішні деталі
}
Тут ми свідомо використовуємо %v: людині ми все пояснили ("load config ..."), а от «причину як структуру» назовні не віддаємо. Таке рішення буває корисним на межах модулів, щоб зовнішній код не починав залежати від ваших внутрішніх деталей.
5. Типові помилки під час wrapping через %w
Помилка №1: використовувати %v і очікувати, що причина збережеться як «структура».
Ззовні рядок виглядає майже так само («parse: bad input»), тому новачок щиро думає, що все зробив правильно. Але %v просто вставляє текст, і errors.Unwrap не зможе дістати початкову помилку. Якщо ви хочете ланцюжок причин — використовуйте %w.
Помилка №2: писати контекст «ні про що».
Фрази на кшталт fmt.Errorf("error: %w", err) додають шум, але не додають сенсу. Контекст має відповідати на запитання «що робили»: parse title, read input, load config. Тоді з одного повідомлення можна зрозуміти, де шукати проблему, не відкриваючи весь проєкт.
Помилка №3: дублювати причину в контексті.
Іноді виходить щось на кшталт fmt.Errorf("bad input: %w", ErrBadInput). Це виглядає як «bad input: bad input» — ніби помилка заїла платівку. Контекст і причина мають доповнювати одне одного: контекст — про операцію, причина — про тип проблеми.
Помилка №4: обгортати nil і дивуватися, чому «помилки немає».
У Go часто пишуть wrapping усередині if err != nil { ... }. Це не ритуал заради краси: якщо бездумно намагатися обгорнути помилку, не перевіряючи, що вона справді є, можна отримати дивні ефекти. Звичка проста: спочатку перевірили err != nil, потім обгорнули.
Помилка №5: перетворювати %w на «завжди й усюди», ламаючи межі абстракцій.
Wrapping робить причину доступною для зовнішнього коду. Іноді це саме те, що потрібно, але інколи ви розкриваєте внутрішні деталі реалізації і випадково перетворюєте їх на частину контракту. Рішення «обгортати чи ні» залежить від того, чи хочете ви, щоб код, який викликає, міг перевірити причину.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ