1. Чому в помилки майже завжди дві аудиторії
Коли ви пишете програму, неминуче настане момент, коли «щось пішло не так». І тут є тонка психологічна пастка: нам хочеться просто вивести print(error) і вважати завдання завершеним. Але реальний світ не такий зручний: користувач не зобов’язаний знати, що таке ParseError.invalidInt("abc"), а розробникові мало прочитати «Щось пішло не так, спробуйте пізніше» без деталей. Тому майже в кожної помилки є дві аудиторії: користувач і розробник.
Уявіть CLI‑застосунок — консольну утиліту. Користувач вводить команду з помилкою. Йому важливо швидко зрозуміти, що саме слід виправити. Розробникові ж потрібно знати, де саме це сталося, які дані надійшли і чому код вирішив, що це помилка. Якщо змішати ці дві задачі в одному рядку виводу, вийде або «страшно й незрозуміло» для користувача, або «занадто красиво й марно» для налагодження.
Користувацька помилка: коротко й так, щоб її можна було виправити
Користувацька помилка — це повідомлення, яке ви показуєте людині, щоб вона могла продовжити роботу. Воно не має нагадувати звіт компілятора або щоденник після трьох годин налагодження. Його мета практична: допомогти користувачеві виправити введення, зрозуміти обмеження, повторити дію, обрати інший сценарій. Стиль тут теж важливий: людяний, спокійний і максимально прикладний.
Корисно тримати в голові просте правило: користувацьке повідомлення про помилку має спиратися на те, що користувач бачить і контролює — наприклад, назву команди, формат аргументу, назву файла, яку він указав, — а не на внутрішні назви ваших enum‑кейсів або функцій. Цю ідею часто формулюють як принцип reportability: помилка має бути зрозумілою та actionable, тобто такою, що дає змогу діяти.
Нижче — контраст на простих прикладах. Таблиця тут не «список заради списку», а швидкий спосіб порівняти два світи.
| Ситуація | Погано (занадто розробницьке) | Добре (користувацьке) |
|---|---|---|
| Користувач увів нечисловий рік | |
|
| Користувач забув аргумент | |
|
| Внутрішня непередбачена ситуація | |
|
Зверніть увагу: «хороші» повідомлення не зобов’язані бути ідеальними. Їм достатньо бути зрозумілими, не агресивними і не розкривати внутрішню будову застосунку. Користувач прийшов розв’язувати задачу, а не читати ваші пригоди у світі типів.
2. Помилка для розробника: повідомлення для діагностики
Розробницька інформація про помилку — це те, що допомагає вам або вашому майбутньому «я через два тижні» зрозуміти, що саме сталося. Вона може бути довгою, технічною, з контекстом і деталями. Їй можна бути негарною. Ба більше, їй навіть корисно бути «неприємно конкретною»: яке значення надійшло, де зламалося, яка гілка switch не спрацювала, що було у вхідному рядку.
Тут важливо не переплутати: розробницька помилка — це не обов’язково «інша помилка» як тип. Частіше це інша форма опису тієї самої ситуації. І ще одна важлива думка: розробницьку діагностику майже завжди пишуть так, ніби ви розмовляєте з колегою-розробником, який знає терміни та якому треба швидко знайти причину.
Якщо користувач побачить такий текст, він або злякається, або почне робити дивні висновки («у мене ErrorProtocol, що робити?»), або надішле вам скриншот о третій годині ночі. А якщо розробник побачить замість деталей «Сталася помилка», він уже о третій годині ночі напише вам… Тому краще заздалегідь розділити канали.
3. Дві поверхні однієї помилки: userMessage і debugMessage
Тепер побудуємо дуже практичну модель: один тип помилки, але дві обчислювані властивості — одна для користувача, інша для діагностики. Це зручно, бо ми не дублюємо сутності, а заздалегідь вирішуємо:
- «як це показати людині»
- «як це показати в лог/налагодження»
І головне — ми перестаємо друкувати помилку «як є» туди, де їй не місце.
Зробімо невеликий верхньорівневий тип помилки для навчального CLI. Нехай це буде міні-застосунок LibraryMini, у який ми додаємо книги (назва + рік). Це не «архітектура на 500 файлів», а один файл, зате з правильною звичкою.
import Foundation
enum AppError: Error {
case invalidCommand(String)
case invalidYear(String)
case missingArguments
case internalProblem(details: String)
/// Повідомлення, яке можна показувати користувачеві.
/// Без внутрішніх деталей і без "сирих" значень, якщо їх не потрібно показувати.
var userMessage: String {
switch self {
case .invalidCommand:
return "Невідома команда. Використовуйте add або list."
case .invalidYear:
return "Рік має бути числом, наприклад, 2015."
case .missingArguments:
return "Бракує аргументів. Приклад: add \"Кобзар\" 1860"
case .internalProblem:
return "Сталася непередбачена помилка. Спробуйте ще раз."
}
}
/// Повідомлення для журналів і налагодження: максимально конкретне й придатне для пошуку.
var debugMessage: String {
switch self {
case .invalidCommand(let cmd):
return "AppError.invalidCommand(cmd: \(cmd))"
case .invalidYear(let raw):
return "AppError.invalidYear(raw: \(raw))"
case .missingArguments:
return "AppError.missingArguments"
case .internalProblem(let details):
return "AppError.internalProblem(details: \(details))"
}
}
}
Зверніть увагу на важливу річ: userMessage навмисно не містить внутрішніх деталей. Навіть для invalidYear ми не показуємо «що саме ви ввели», хоча технічно можемо. Іноді це доречно, але зараз тренуємо дисципліну: користувацьке повідомлення — це допомога, а не протокол подій.
А debugMessage ми не намагаємося зробити «красивим для людини». Він красивий для пошуку та зіставлення. Вам потім буде простіше шукати в журналах invalidYear і бачити, які вхідні значення ламали розбір.
4. Де друкувати повідомлення: всередині логіки чи на межі застосунку
Друкувати користувацьке повідомлення краще в одному місці — на межі застосунку. Для CLI це зазвичай main‑частина, тобто верхньорівневий код. Там само, за потреби, можна друкувати й діагностичні деталі — умовно, у журнал.
Зберемо невеликий сценарій. Важливо: всередині бізнес-логіки ми не друкуємо помилки — ми їх викидаємо.
Зробімо функцію runOnce(), яка читає рядок і виконує команду. Для простоти команда матиме такий вигляд: add "Кобзар" 1860 або list. Лапки ми поки не розбираємо по-справжньому, просто вважаємо, що назва без пробілів (пізніше ви навчитеся робити краще).
import Foundation
func runOnce() throws {
let line = readLine() ?? ""
let parts = line.split(separator: " ")
guard let cmd = parts.first else { throw AppError.missingArguments }
if cmd == "list" {
print("Поки список порожній.")
} else if cmd == "add" {
throw AppError.missingArguments
} else {
throw AppError.invalidCommand(String(cmd))
}
}
Зверніть увагу: всередині runOnce() ми не друкуємо повідомлення про помилку. Ми лише повідомляємо, що сталося, але не вирішуємо, як це показати. Це і є розділення відповідальності без гучних слів.
Тепер зробімо верхньорівневу обробку. Тут ми й розділимо вивід: користувачеві — userMessage, розробникові — debugMessage. Щоб не вводити прапорці та розбір аргументів командного рядка завчасно, заведемо звичайну константу isDebug.
import Foundation
let isDebug = true
do {
try runOnce()
} catch let error as AppError {
print(error.userMessage)
if isDebug { print("DEBUG:", error.debugMessage) }
} catch {
print("Сталася непередбачена помилка.")
if isDebug { print("DEBUG: невідома помилка:", error) }
}
Якщо isDebug = false, користувач побачить лише людяне повідомлення. Якщо isDebug = true, ви побачите й діагностичний «хвіст». У реальних проєктах діагностична інформація частіше потрапляє до файла журналу або в stderr, але зараз важливий сам принцип: не змішувати аудиторії.
5. Чому print(error) майже завжди поганий UX
Дуже хочеться написати catch { print(error) }. Іноді це навіть працює, особливо на навчальних прикладах. Але проблема в тому, що print(error) — це не UX-контракт. Це просто рядкове представлення, яке може бути непередбачуваним, занадто технічним і місцями навіть небезпечним, наприклад якщо в помилці випадково опиняться деталі, які користувачеві бачити не варто.
Порівняйте підхід. Нехай користувач увів add Dune abc. Ми чесно викидаємо invalidYear("abc"). Тепер два варіанти обробки:
// Варіант А: "як вийде"
catch { print(error) }
// Варіант Б: UX-контракт
catch let e as AppError { print(e.userMessage) }
Перший варіант говорить: «ну, як вирішить Swift». Другий варіант говорить: «ось як ми показуємо помилки користувачеві». І другий варіант у реальному житті майже завжди перемагає, оскільки він дає стабільну поведінку, а не випадковість.
6. Як тече помилка: від throw до тексту на екрані
Щоб закріпити це в голові, корисно уявити потік помилки як маршрут: «код» → «помилка як значення» → «рішення, кому і що показати». Тут немає магії, лише дисципліна.
flowchart TD
A[Функція всередині застосунку] -->|throw AppError| B[Межа застосунку: do/catch]
B --> C[Користувацький вивід: userMessage]
B --> D[Діагностика: debugMessage / журнал]
Якщо ви звикнете до цього маршруту зараз, далі, коли зʼявляться файли, мережа й великі сценарії, вам буде набагато простіше не влаштувати «кашу з помилок» у консолі.
7. Очікувана vs непередбачена помилка
Є ще один важливий відтінок, який сильно впливає на тексти. Деякі помилки — очікувані: користувач увів не те, файл не знайдено, аргумент пропущено. Вони є частиною нормального сценарію, і їхній userMessage може бути конкретним та корисним.
А деякі помилки — непередбачені: порушення внутрішнього інваріанта, неочікуваний порожній масив, дивний стан. Для користувача це майже завжди має виглядати нейтрально: «непередбачена помилка».
І тут часто помиляються навпаки: починають показувати користувачеві внутрішню будову, щоб «хоч щось сказати». Але це як повісити на двері кафе табличку: «Ми забули ввімкнути духовку, вибачте, NullPointerException». Користувачеві не легше, а вам соромно.
Тому базовий підхід такий: непередбачена помилка отримує спокійний userMessage, а вся конкретика йде в debug-канал. Саме для цього нам і потрібен internalProblem(details: String).
8. Типові помилки
Помилка № 1: друкувати користувачеві print(error) і вважати це UX.
Такий код здається зручним, бо він «сам усе скаже». Але він не дає вам контролю над формулюваннями та стабільністю повідомлень. Одного дня ви перейменуєте кейс enum — і в користувачів раптово зміняться тексти. Правильніше тримати явний userMessage як контракт.
Помилка № 2: запихати технічні деталі в користувацьке повідомлення.
Формулювання на кшталт ParseError.invalidToken(at: 2) звучать ефектно лише для розробника. Для користувача це шум. Йому потрібна підказка «який формат очікується» і приклад правильного введення. Деталі токенізації та назви кейсів enum мають жити в debug-повідомленнях.
Помилка № 3: “ковтати” помилку, а потім намагатися пояснити її користувачеві без причини.
Якщо ви використовуєте try? там, де вам потрібне нормальне повідомлення («чому не вийшло?»), ви самі стираєте причину і лишаєте собі тільки nil. Потім залишається друкувати щось на кшталт «не вдалося», що погіршує UX. Якщо причина важлива — краще do/catch і нормальна обробка.
Помилка № 4: змішувати user і debug в одному стилі та в одному потоці виводу.
Коли все виводиться однаково, користувач починає читати debug-частину, а розробник втрачає сигнал серед «будь ласка, спробуйте ще раз». Навіть у навчальному проєкті корисно візуально відокремлювати канали: хоча б префіксом DEBUG: і тим, що debug можна вимкнути однією константою.
Помилка № 5: перетворювати кожну помилку на “щось пішло не так”.
Це зворотна крайність. Користувацькі повідомлення мають бути спокійними, але не порожніми. Якщо людина ввела неправильну команду, їй можна і потрібно сказати, які команди існують, або дати приклад. Принцип простий: користувацьке повідомлення має посилатися на те, що користувач реально може виправити й контролювати.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ