1. Чому «показувати користувачу err.Error()» — погана звичка
Коли ви пишете CLI‑застосунок, дуже легко почати жити за принципом: якщо впало — друкуємо err і все. У маленьких навчальних програмах це навіть виглядає чесно: «Ну от помилка — чого ще треба?». Але в реальному світі у помилки є щонайменше три аудиторії, і в кожної — свої очікування.
Користувач хоче коротке й зрозуміле пояснення: що саме він зробив не так або чому програма не змогла виконати дію. Скрипти й автоматизація очікують стабільної поведінки: зазвичай це exit code і передбачуваний формат помилок. А розробник хоче подробиць: який файл, яка операція, яка першопричина, і бажано так, щоб це було видно в логах.
Погляньмо на цю картину у вигляді схеми:
flowchart TD
U[Користувач] -->|читає| MSG[Коротке повідомлення в stderr]
S[Скрипт/CI] -->|читає| CODE[Exit code]
D[Розробник] -->|читає| LOG[Логи/діагностика]
ERR[Error у Go] --> MSG
ERR --> CODE
ERR --> LOG
Головна ідея: одна й та сама помилка має «годувати» одразу три канали, але не одним і тим самим текстом. Якщо ви віддасте користувачу все, що знаєте, це може виглядати як дамп внутрішньої структури, а інколи ще й як витік інформації: шляхи у файловій системі, внутрішні формати, назви таблиць тощо. А якщо ви, навпаки, «сплющите» помилку в рядок занадто рано, то самі себе позбавите можливості потім зрозуміти, що саме сталося.
2. %w і %v: зовні схоже, але контракт різний
На рівні синтаксису різниця між %w і %v у fmt.Errorf виглядає майже смішно: одна літера, а драматургії — як у серіалі на вісім сезонів. Але з погляду архітектури це справді важливо: %w робить першопричину доступною для програмного аналізу через errors.Is/errors.As, а %v перетворює її на текст і «відрізає шлях назад».
Коли ви обгортаєте помилку з %w, ви ніби кажете коду, який вас викликає: «Я дозволяю тобі зазирнути всередину й ухвалити рішення на основі причини». Це зручно, але небезпечно: якщо всередині у вас sql.ErrNoRows, *os.PathError або щось зі сторонньої бібліотеки, то зовнішній код може почати на це спиратися. І далі змінити бібліотеку буде майже так само «приємно», як переїхати на іншу планету без коробок. У документації Go та статтях про найкращі практики цю думку формулюють прямо: wrapping робить конкретну помилку частиною API, і якщо ви не готові підтримувати цю поведінку, краще не обгортати.
Міні‑приклад на пальцях:
package main
import (
"errors"
"fmt"
)
var ErrNotFound = errors.New("not found")
func loadTask(id int) error {
// %w: першопричину збережено
return fmt.Errorf("load task %d: %w", id, ErrNotFound)
}
А ось «сплющений» варіант:
package main
import (
"errors"
"fmt"
)
var ErrNotFound = errors.New("not found")
func loadTaskHidden(id int) error {
// %v: першопричина стала просто текстом, Is/As її не побачать
return fmt.Errorf("load task %d: %v", id, ErrNotFound)
}
Обидва варіанти друкуються для людини майже однаково. Різниця проявиться тоді, коли ви захочете зробити errors.Is(err, ErrNotFound) або витягнути типізовану помилку через errors.As. І ось тут ми підходимо до стратегічного питання: кому й навіщо ми хочемо дозволити зазирнути всередину помилки?
3. Де додавати контекст: шари застосунку та поле Op
Коли ви читаєте чужі логи, швидко розумієте просту річ: «помилка читання» без контексту — це не помилка, а загадка. У хорошій системі помилка майже завжди несе контекст операції, щоб було видно, на якому кроці все зламалося: «load tasks», «parse args», «write output», «open data file».
Кумедно, але новачки зазвичай роблять навпаки: або не додають контекст узагалі, або додають його так старанно, що виходить «read: read: read: read: permission denied». Тому корисно мислити не рядком, а структурою: у нас є шари (команда → логіка застосунку → сховище → ОС), і кожен шар може додати невеликий смисловий ярлик, але не має перетворювати помилку на літературний твір.
Схема шарів нашого навчального CLI‑застосунку, умовно tasker:
flowchart LR
CMD[cmd/tasker: команда] --> APP[app: usecase]
APP --> ST[storage: читання/запис]
ST --> OS[os/fs/json: системні причини]
Нехай у нас уже є AppError (з минулої лекції) з полями Kind, Op, Err. Тоді базова ідея така: Op описує, що саме ми робили на цьому рівні, а Err зберігає першопричину (можливо, вже з додатковим wrapping).
Ось короткий, практичний приклад конструктора, щоб не писати одне й те саме вручну:
package apperr
type Kind int
const (
KindInternal Kind = iota
KindValidation
KindNotFound
KindIO
)
type AppError struct {
Kind Kind
Op string
Err error
}
func (e *AppError) Error() string {
if e == nil {
return "<nil>"
}
if e.Err == nil {
return e.Op
}
if e.Op == "" {
return e.Err.Error()
}
return e.Op + ": " + e.Err.Error()
}
func (e *AppError) Unwrap() error { return e.Err }
Тут важливо, що Unwrap() повертає Err, а отже errors.Is/errors.As зможуть ходити ланцюжком причин.
4. Коли зберігати першопричину, а коли ховати деталі
У wrapping‑стратегії є простий критерій: якщо код, що викликає, може й має ухвалити рішення на основі причини — зберігаємо причину як причину (wrap через %w або Unwrap). Якщо ж причина — внутрішня деталь реалізації, яка не має «протікати» назовні, її краще перетворити на текст або замінити на більш стабільний доменний зміст.
Корисно тримати в голові таку маленьку таблицю: це не «істина в останній інстанції», але як старт — дуже непогано.
| Ситуація | Що сталося | Wrap першопричину? | Чому |
|---|---|---|---|
| Користувач передав шлях до файла, і ми не змогли його відкрити | permission denied, file not found | зазвичай так | причина стосується зовнішнього світу користувача |
| Ми використовуємо внутрішню БД або формат, і це деталь реалізації | sql.ErrNoRows, json.SyntaxError з внутрішнього файла | зазвичай ні (або лише для логів) | інакше зовнішній код почне залежати від наших внутрішніх деталей |
| Помилка валідації аргументів | «limit має бути > 0» | зазвичай не обов’язково | рішення ухвалюються за KindValidation, а причина другорядна |
| Неочікувана помилка | «щось зламалося» | так, але користувачу її показують обережно | нам потрібне розслідування, але користувачу не потрібен дамп |
Зверніть увагу: «wrap чи не wrap» — це не про те, чи хочу я бачити причину в тексті. Текст і так можна зібрати. Це про те, чи робимо ми першопричину частиною програмного контракту. Якщо ви повертаєте помилку, що обгортає, наприклад, sql.ErrNoRows, зовнішній код може почати писати errors.Is(err, sql.ErrNoRows), і тоді ви вже зобов’язані підтримувати цю поведінку, інакше «зламаєте клієнтів».
За тією ж логікою: якщо os.Open повернув *os.PathError, а цей тип — деталь реалізації вашої функції, то краще не віддавати його назовні як unwrap‑ланцюжок, а «перепакувати» без %w.
5. Повідомлення для користувача і діагностика — різні рішення
Зараз буде важлива вправа на мислення: уявіть, що помилка — це валіза. Усередині валізи лежить купа речей: причини, контекст, вкладені помилки. Користувач — це людина на стійці реєстрації, яка питає: «Що у вас сталося?». Розробник — це співробітник служби безпеки, якому потрібно відкрити валізу й зрозуміти, що всередині.
Якщо ви відповідаєте користувачу «ось уся валіза, тримайте», він отримає купу незрозумілих деталей. Якщо ви відповідаєте «нічого не знаю» — ви позбавляєте себе розслідування. Тому нормальний патерн для CLI: повідомлення для користувача будується за змістом (Kind, інколи Op), а першопричина лишається всередині помилки для логів.
Міні‑ескіз функції, яка формує «людське» повідомлення (ми не робимо тут ідеальний UX‑словник — нам важливий принцип):
package apperr
import (
"errors"
)
func UserMessage(err error) string {
if err == nil {
return ""
}
var ae *AppError
if errors.As(err, &ae) {
switch ae.Kind {
case KindValidation:
return "invalid input"
case KindNotFound:
return "not found"
case KindIO:
return "i/o error"
default:
return "internal error"
}
}
return "internal error"
}
Зауважте: ми не друкуємо ae.Err.Error() користувачу. Це не тому, що ми «жадібні», а тому, що це нестабільно й часто занадто технічно. Зате в логах ми можемо спокійно зберегти ae.Err і навіть увесь ланцюжок помилок, щоб розробник міг зрозуміти, що сталося.
І тут wrapping‑стратегія починає працювати: ми можемо зберігати першопричину через %w, не показуючи її користувачу напряму. Тобто «wrap для діагностики» і «show for UX» — це різні рішення, і їх корисно ухвалювати окремо.
6. Практика на CLI tasker: різні політики wrapping
Уявімо, що наш застосунок зберігає задачі у файлі (це типова й зрозуміла модель для навчального CLI). Нехай шлях до файла ми отримуємо з конфігурації або прапорця, але саме існування файла — уже питання I/O, а отже ідеальний приклад для wrapping‑стратегії.
Почнемо з функції завантаження задач. Спростимо: читаємо файл цілком і парсимо JSON (ми тут не обговорюємо streaming‑JSON і складні формати — нам важливі помилки).
package storage
import (
"encoding/json"
"os"
)
type Task struct {
ID int `json:"id"`
Title string `json:"title"`
Done bool `json:"done"`
}
func LoadTasks(path string) ([]Task, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var tasks []Task
if err := json.Unmarshal(data, &tasks); err != nil {
return nil, err
}
return tasks, nil
}
Цей код «технічно працює», але він нічого не каже про зміст. Далі в нас два варіанти політики wrapping.
Варіант A: шлях — частина контракту, отже причини ОС можна зберігати
Якщо шлях до файла задає користувач, то помилки на кшталт permission denied або no such file належать до його світу. У такому разі можна зберегти першопричину, щоб верхній шар міг, наприклад, відрізнити «файла не існує» і «немає прав». Тоді storage‑шар може обгортати з %w, додаючи зрозумілий Op.
package storage
import (
"fmt"
"os"
)
func LoadTasks(path string) ([]Task, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read tasks file %s: %w", path, err)
}
// ...
return nil, nil
}
Тепер errors.Is з os.ErrNotExist або os.ErrPermission (через відповідні помилки fs) теоретично можуть спрацювати на верхньому рівні. Це нормально лише якщо ви готові вважати це частиною поведінки вашої програми.
Варіант B: внутрішній файл — деталь реалізації, отже не «протікаємо» помилками ОС
Якщо файл суворо внутрішній (наприклад, завжди ~/.tasker/tasks.json), і ви не хочете, щоб хтось зовні ухвалював рішення на основі *os.PathError, то можна «сплющити» системну помилку в текст для зовнішнього шару, але зберегти її для логів усередині вашого AppError.
package storage
import (
"encoding/json"
"fmt"
"os"
"example/tasker/apperr"
)
func LoadTasks(path string) ([]Task, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, &apperr.AppError{
Kind: apperr.KindIO,
Op: "load tasks",
// Першопричина є, але рішення про її «публічність» — ваше.
Err: fmt.Errorf("read file: %v", err), // %v: не даємо unwrap назовні
}
}
var tasks []Task
if err := json.Unmarshal(data, &tasks); err != nil {
return nil, &apperr.AppError{
Kind: apperr.KindInternal,
Op: "decode tasks",
Err: err, // можна залишити як є або теж контролювати
}
}
return tasks, nil
}
Зверніть увагу на тонкість: тут ми «сплющили» системну помилку (%v), щоб зовнішній код не робив errors.Is за типами на кшталт *os.PathError і не залежав від деталей. Ця логіка збігається з рекомендацією не обгортати внутрішні помилки, якщо ви не хочете фіксувати їх як частину API.
А водночас у нас усе ще є структурний контекст KindIO і Op, і верхній шар зможе ухвалити рішення рівня «це I/O» без танців навколо тексту помилки.
Як уникнути «read: read: read»: по одному унікальному уточненню для кожного шару
Коли починаєш акуратно wrap’ити помилки, майже неминуче настає момент, коли лог виглядає так, ніби програма застрягла у слові read і не може з нього вибратися. Це нормальна хвороба росту: ви чесно додаєте контекст на кожному кроці, але контекст повторюється.
Тут допомагає дисципліна: кожен шар додає лише той контекст, який є унікальним для нього. У storage‑шарі Op буде «load tasks». У app‑шарі — «add task» або «list tasks». У cmd‑шарі — «parse args» або «run command». І якщо контекст не додає нової інформації, краще не додавати його взагалі.
Міні‑приклад в app‑шарі, де ми не змінюємо зміст помилки, а лише додаємо, на якому рівні це сталося:
package app
import (
"fmt"
"example/tasker/apperr"
"example/tasker/storage"
)
type Service struct {
Path string
}
func (s Service) List() ([]storage.Task, error) {
tasks, err := storage.LoadTasks(s.Path)
if err != nil {
// Додаємо контекст рівня usecase, зберігаючи причину:
return nil, &apperr.AppError{
Kind: apperr.KindOf(err), // припустімо, KindOf уже є
Op: "list tasks",
Err: fmt.Errorf("%w", err), // зберігаємо ланцюжок причин
}
}
return tasks, nil
}
Зверніть увагу: якщо storage.LoadTasks уже повернув AppError{Kind: KindIO, Op: "load tasks"}, то app‑шар додасть Op: "list tasks". У підсумку для логів вийде хороший ланцюжок «list tasks: load tasks: read file: permission denied». Для користувача ж ви зможете показати щось на кшталт «i/o error» або більш дружнє формулювання — але це вже окреме рішення, яке не зобов’язане дорівнювати err.Error().
7. Типові помилки
Помилка №1: використовувати %w «скрізь за звичкою», не розуміючи, що це публічний контракт.
Найнебезпечніша частина %w у тому, що він здається «просто правильнішим за %v». Але %w — це не про красу тексту, а про те, що ви дозволяєте зовнішньому коду аналізувати першопричину через errors.Is/errors.As. У результаті ваш внутрішній вибір бібліотеки або формату стає зобов’язанням. Це як пообіцяти «ніколи не міняти двері», бо в когось підійшов ключ.
Помилка №2: «сплющити» помилку занадто рано й втратити діагностику.
Іноді розробник робить fmt.Errorf("something failed: %v", err) десь глибоко внизу, а потім угорі мріє відрізнити not found від permission denied. Мрія не здійсниться, бо ви самі відрізали можливість errors.Is/errors.As. Якщо ви хочете і контроль, і акуратний UX — зберігайте першопричину, але окремо вирішуйте, що друкувати користувачу.
Помилка №3: змішувати Op і повідомлення для користувача в один рядок.
Op добрий, коли він короткий і технічний: «load tasks», «parse args», «write output». Якщо туди запхати «Будь ласка, вкажіть коректний шлях до файла, інакше…», це перетворюється на непередбачувану мішанину для логів і ламає ідею стабільного UX. Користувацький текст має будуватися окремою функцією, а Op має допомагати розробнику.
Помилка №4: дублювати однаковий контекст на кожному рівні й отримувати «read: read: read».
Коли контекст повторюється, він перестає бути контекстом і стає шумом. Якщо storage‑шар уже сказав «load tasks», то app‑шару немає сенсу писати «load tasks» ще раз. Додавайте лише той шматок змісту, який є унікальним для рівня, інакше логи будуть довгими, а інформації в них не побільшає.
Помилка №5: показувати користувачу «сирий» err.Error() з нижніх шарів.
Нижні шари часто містять системні деталі: шляхи, внутрішні формати, дивні формулювання стандартної бібліотеки. Для діагностики це корисно, але для користувача — майже завжди дратівливо й інколи небезпечно. Набагато надійніше друкувати коротке повідомлення за Kind, а деталі залишати в логах (із Op і першопричиною всередині помилки).
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ