1. Навіщо типізувати помилки
Коли застосунок зростає, ви майже неминуче потрапляєте в ситуацію: «не вдалося» — це надто мало інформації. Одна й та сама операція може не спрацювати з різних причин, і від причини залежить, що робити далі: попросити повторити введення, підказати формат, зупинити сценарій або вибрати запасний шлях. Тому нам потрібен інструмент, який дає змогу розрізняти причини відмови так само строго, як ми розрізняємо типи Int і String.
Уявіть, що ви пишете мінізастосунок MiniLibrary — умовну бібліотеку в памʼяті: користувач вводить назву книги та рік. Помилок може бути багато: порожня назва, рік не число, рік поза діапазоном, рядок містить зайві символи. Якщо все звести до false або до рядка "error", далі код перетворюється на ворожіння на кавовій гущі.
У Swift є кілька способів виразити ситуацію, коли щось може не спрацювати. Optional — один із них, і він чудово підходить, коли причина неважлива. Але коли причина важлива, а для нормального UX це зазвичай саме так, ми переходимо до помилок — до значень, які описують, що саме пішло не так.
Error — це протокол: що це означає простими словами
Слово «протокол» ви вже могли зустрічати як «контракт»: набір вимог, яким має відповідати тип. Error — це протокол, який каже компілятору: «значення цього типу можна використовувати як помилки». Тобто їх можна кидати (throw) і перехоплювати (catch) — але механіку кидання й перехоплення ми повноцінно розберемо в наступних лекціях. Тут наша мета — навчитися проєктувати помилки як моделі даних.
Важливо: Error — не клас і не магія, а радше маркер: тип «підходить як помилка». Тому часто достатньо просто написати : Error, і все.
import Foundation
enum InputError: Error {
case empty
case tooShort
}
Виглядає майже занадто просто — і саме в цьому його сила: Swift не змушує вас успадковуватися від «ThrowableExceptionOfDoom». Вам потрібно лише описати набір причин, з яких ви готові коректно відмовити.
Чому enum майже ідеальний для помилок
Помилки зручно моделювати через enum з однієї простої причини: у помилки зазвичай є скінченний набір причин. Порожнє введення, неправильний формат, значення поза діапазоном — це чітко окреслені випадки. А enum якраз і означає: «можливі такі варіанти, і лише такі».
Друга причина — switch. Ви вже бачили, як switch змушує вас бути чесними: або перелічіть усі варіанти, або додайте default. Для помилок це особливо цінно, тому що обробка помилок — це місце, де «забули один випадок» перетворюється на баг рівня «користувач не розуміє, що відбувається».
Третя причина — читабельність. Порівняйте:
// Погано: рядок не задає контракт
let errorMessage = "invalid_year"
і
// Добре: типізована причина
let error: BookInputError = .invalidYearText(input: "19O2")
Друга версія не лише красивіша. Вона дає вам структуру: у коді видно, що саме сталося, і компілятор допомагає це опрацювати.
До речі, у реальному Swift-коді та в великих системах, зокрема в інфраструктурних компонентах, помилки часто містять контекст прямо в кейсі, наприклад «невідомий одержувач із таким-то ID». Це нормальна й дуже корисна практика.
2. Дизайн помилок: назви типів і кейсів
Помилки — це частина API вашого коду, навіть якщо поки що цей API потрібен лише вам. Тому імена мають значення. Гарна новина: правила доволі прості й схожі на правила іменування звичайних enum.
Зазвичай тип помилки називають із суфіксом Error: InputError, ParseError, BookError, LibraryError. Кейси — це конкретні причини: .empty, .invalidFormat, .outOfRange. Чим конкретніша причина, тим простіше потім писати обробку.
Ось невелика таблиця «як це зазвичай виглядає»:
| Контекст | Імʼя типу | Приклади випадків |
|---|---|---|
| Введення користувача | |
|
| Парсинг | |
|
| Доменні правила | |
|
Зверніть увагу: ми не робимо один універсальний AppError.failed. Це як зробити одну універсальну скриньку «різне» і потім намагатися знайти там паспорт, батарейки та сенс життя.
import Foundation
enum BookInputError: Error {
case emptyTitle
case invalidYearText(input: String)
}
Тут уже видно: помилка «про книги», а не «про все на світі», і в неї дві зрозумілі причини.
3. Associated values: додаємо контекст без «мішка даних»
Сама причина помилки — це вже непогано. Але іноді вам потрібно ще й пояснити «чому» більш предметно: яке значення прийшло, які межі очікувалися, що саме було не так у рядку. Для цього в enum є associated values — ви вже бачили їх у прикладах зі станами.
Ключовий принцип простий: контекст додають не «про всяк випадок», а тоді, коли він потрібен для обробки, для нормального повідомлення користувачу або для журналювання.
Приклад: «рік поза діапазоном» без контексту — це малоінформативно. «Рік 3021 поза діапазоном 1450...2026» — це вже корисно.
import Foundation
enum RangeError: Error {
case outOfRange(value: Int, min: Int, max: Int)
}
А тепер трохи ближче до нашого мінізастосунку:
import Foundation
enum BookInputError: Error {
case emptyTitle
case invalidYearText(input: String)
case yearOutOfRange(value: Int, min: Int, max: Int)
}
Зверніть увагу на важливу деталь: ми не додаємо туди все підряд (наприклад, «поточний користувач», «час», «назва ОС»). Це буде просто шум. Помилка має залишатися компактною моделлю причини.
4. Помилка — це звичайне значення
Одна з найприємніших рис Swift: помилка — це не обов’язково «щось, що ламає застосунок». На рівні моделі даних помилка — це значення, з яким можна працювати так само, як із будь-яким enum.
Це корисно навіть до вивчення throws/try: ви можете будувати функції у стилі «повернути або помилку, або nil», щоб потренуватися розрізняти причини й писати нормальні повідомлення.
Зробімо маленьку утиліту, яка перетворює помилку на зрозумілий людині текст. Ми поки не використовуємо жодних складніших механік, просто працюємо з enum.
import Foundation
enum BookInputError: Error {
case emptyTitle
case invalidYearText(input: String)
case yearOutOfRange(value: Int, min: Int, max: Int)
}
func message(for error: BookInputError) -> String {
switch error {
case .emptyTitle:
return "Назва книги не може бути порожньою."
case .invalidYearText(let input):
return "Рік '\(input)' не виглядає як число."
case .yearOutOfRange(let value, let min, let max):
return "Рік \(value) має бути в діапазоні \(min)…\(max)."
}
}
А тепер — «перевірка вручну», щоб побачити, що це справді просто значення:
import Foundation
let e: BookInputError = .yearOutOfRange(value: 3021, min: 1450, max: 2026)
print(message(for: e)) // Рік 3021 має бути в діапазоні 1450…2026.
Це виглядає як дрібниця, але на практиці це основа нормальної архітектури: помилки стають типізованими даними, а не шумом у консолі.
5. Приклад: MiniLibrary і помилки введення без throws
Зараз зберемо невеликий фрагмент застосунку — мінібібліотеку, куди користувач додає одну книгу. Ми не будемо ускладнювати зберігання, мережу, файли та інше: сьогодні важливі саме модель помилок і вміння додавати контекст. Тому зробимо просту структуру BookDraft і функції валідації, які повертають BookInputError?.
Чому BookInputError?, а не відразу throws? Тому що сьогодні ми зосереджуємося на дизайні типів помилок. Механіка throws/try/do-catch — це окрема тема, і краще не змішувати все в одну кашу, навіть якщо каша дуже смачна.
import Foundation
struct BookDraft {
let title: String
let year: Int
}
Тепер функція для перевірки заголовка. Зверніть увагу: вона повертає або nil (усе добре), або конкретну помилку.
import Foundation
func validateTitle(_ title: String) -> BookInputError? {
if title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
return .emptyTitle
}
return nil
}
Функція для перевірки року. Тут дві різні причини: «не число» і «поза діапазоном».
import Foundation
func validateYearText(_ text: String) -> BookInputError? {
guard let value = Int(text) else {
return .invalidYearText(input: text)
}
let min = 1450
let max = 2026
guard (min...max).contains(value) else {
return .yearOutOfRange(value: value, min: min, max: max)
}
return nil
}
Тепер зберімо «спробу створити книгу» — але поки без складної архітектури. Ми просто перевіримо обидва поля й або повернемо BookDraft, або повернемо помилку. Щоб не вводити нові теми, використаємо кортеж ((BookDraft?, BookInputError?)) — так, це не ідеально, але зараз нам важливіше побачити, як помилки живуть у коді.
import Foundation
func makeBookDraft(title: String, yearText: String) -> (BookDraft?, BookInputError?) {
if let error = validateTitle(title) {
return (nil, error)
}
if let error = validateYearText(yearText) {
return (nil, error)
}
let year = Int(yearText) ?? 0
return (BookDraft(title: title, year: year), nil)
}
І нарешті — мінісценарій «як це виглядає» без інтерактиву, щоб код був коротким і зрозумілим:
import Foundation
let (book, error) = makeBookDraft(title: " ", yearText: "2020")
if let error {
print(message(for: error)) // Назва книги не може бути порожньою.
} else if let book {
print("Додамо книгу: \(book.title), \(book.year)") // не виконається
}
Сенс цього прикладу не в тому, що ми «вигадали ідеальний API», а в тому, що ви побачили: помилки справді проєктуються як моделі даних, і associated values дають нам контекст без рядкового хаосу.
6. Скільки контексту додавати до помилки
На перших порах хочеться або зовсім не додавати контекст («і так зрозуміло»), або, навпаки, додавати його занадто багато («раптом знадобиться»). Обидва підходи швидко роблять код незручним. Хороша рамка така: контекст у помилці має відповідати щонайменше на одне з двох питань.
Перше питання — «як обробити?» Наприклад, якщо у вас є .outOfRange(value:min:max), ви можете залежно від value запропонувати користувачу найближче допустиме значення або підказати правильний формат.
Друге питання — «як пояснити?» Наприклад, .invalidYearText(input:) дає змогу показати користувачу саме те, що він увів, а не абстрактне «неправильне введення».
Якщо контекст не допомагає ні обробці, ні поясненню, він майже завжди перетворюється на шум. І це небезпечно: помилки починають розростатися, їх складніше порівнювати візуально, складніше охоплювати випадки і складніше підтримувати.
До речі, у реальному коді associated values у помилок — це не рідкість, а норма. Наприклад, «невідомий одержувач із таким-то ідентифікатором» — класичний випадок, який показує, що контекст часто є частиною причини, а не «зовнішнім логом».
7. Типові помилки під час проєктування enum Error
Помилка №1: один кейс .failed на всі випадки.
Спочатку це здається зручним: менше писати. Але потім ви раптом розумієте, що ваш switch не може розрізнити причини, а отже ви не можете дати нормальну реакцію. У підсумку все перетворюється на рядкові повідомлення й гру в здогадки, тобто ви втрачаєте сенс типізованої помилки.
Помилка №2: «причини» ховаються в рядку, а не в кейсах.
Іноді пишуть щось на кшталт .invalid(message: String) і кладуть туди "emptyTitle" або "yearOutOfRange". Це майже завжди крок назад: ви позбавляєте компілятор можливості допомагати вам, а галуження знову перетворюється на порівняння рядків, де описка — це новий вид прихованого боса у вашій грі.
Помилка №3: контекст додано «про всяк випадок» і роздуває тип.
Якщо ви додаєте в помилку все підряд, вона стає важкою для читання та супроводу. Ба більше, ви починаєте тягнути деталі з різних рівнів застосунку туди, де їм не місце. Контекст має бути мінімальним і корисним: вхідний рядок, фактичне значення, очікувані межі.
Помилка №4: помилки стають надто загальними і перестають бути «про свою предметну область».
AppError на весь застосунок виглядає спокусливо, але майже завжди зарано. Набагато простіше жити, коли помилки згруповані за змістом: помилки введення окремо, помилки доменних правил окремо. Тоді й обробка читається як нормальний текст: «якщо помилка введення — підкажи формат; якщо помилка правил — поясни обмеження».
Помилка №5: змішування «помилки для користувача» і «помилки для розробника» прямо в моделі.
Якщо ви намагаєтеся прямо в enum зберігати готові великі тексти для інтерфейсу («покажи користувачу ось це речення»), ви надто рано прив’язуєте доменну логіку до подання. На перших кроках допустимо повертати прості повідомлення через switch, як ми робили вище, але тримайте в голові ідею: помилка — це причина, а красивий текст — це вже подання причини.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ