1. Валідація через throw: базовий патерн guard-style
Валідація — це момент, коли програма перевіряє: дані не просто «є», а відповідають правилам. У новачків тут часто вмикається режим: «якщо щось не так — повернемо nil» або «якщо щось не так — надрукуємо „помилка“ і підемо далі». Проблема в тому, що валідація — це не рідкісна катастрофа, а повсякденна реальність: користувач помилився, рядок порожній, число поза діапазоном.
Коли ви використовуєте throw для валідації, ви говорите дуже чесно: «далі за цим сценарієм іти не можна, бо дані невалідні, і ось точна причина». Це контрольований вихід, а не падіння програми. І він особливо корисний там, де важливо зберегти причину помилки, щоб на вищому рівні ухвалити рішення: що показати користувачу, що записати в лог, а що попросити повторити.
Порівняймо за змістом:
- nil — це «не вдалося», але чому — незрозуміло.
- throw SomeError.reason(...) — це «не вдалося, тому що …», і причина типізована.
І саме це «тому що» — ваш головний союзник, коли проєкт стає трохи більшим, ніж «прочитав число — вивів число».
Guard-style як лінійний ланцюжок правил
Коли перевірок багато, код легко перетворюється на суцільний ліс із if-ів: один if усередині іншого, потім ще два, а потім ви забули зробити return/throw, і програма продовжила працювати в неправильному стані. Виходить щось на кшталт зомбі-даних: ніби рухаються, але вже не живі.
guard зручний тим, що змушує оформлювати перевірку як умову допуску: якщо умова не виконана — ми зобов’язані вийти з поточного шляху. Це і є сутність guard: блок else повинен завершувати поточний шлях (return, throw, break, continue тощо). Саме тому guard — це не «перевернутий if», а окремий інструмент для читабельності.
У валідації це дає дуже зручну структуру: ви пишете кілька guard, і код читається як список вимог. Якщо всі вимоги виконано, ви спокійно рухаєтеся далі, уже з коректними даними.
Мислити можна так:
Вхідні дані
↓
guard правило №1 (інакше throw)
↓
guard правило №2 (інакше throw)
↓
guard правило №3 (інакше throw)
↓
Далі працюємо з валідними значеннями
Мініприклад: «непорожній рядок» як правило
import Foundation
enum ValidationError: Error {
case emptyText
}
func requireNonEmpty(_ text: String) throws -> String {
guard !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
throw ValidationError.emptyText
}
return text
}
Зверніть увагу на важливу деталь: після throw немає сенсу писати код «про всяк випадок». throw завершує поточну функцію негайно.
2. Єдиний формат помилок у LibraryCLI
У невеликих вправах можна дозволити собі «в одному місці print, в іншому return nil, у третьому — просто break». Але щойно ви будуєте CLI-застосунок, а наш навчальний проєкт саме до цього й рухається, хаос у помилках стає реальною проблемою.
Чому CLI особливо чутливий до формату помилок? Тому що в користувача немає кнопок і підсвічування полів. У нього є лише текст. І якщо ваші повідомлення про помилки щоразу різні, користувач починає грати у вгадування: що вводити, куди дивитися, чому сьогодні «Invalid input», а завтра «bad year».
Отже, мета цього розділу — домовитися про дисципліну.
Ми хочемо, щоб усі помилки валідації в нашому LibraryCLI:
- були зосереджені в одному enum (або в парі чітко розділених enum, але сьогодні зробимо один — так простіше втримати в голові);
- мали передбачуване, зрозуміле для людини повідомлення;
- за потреби зберігали контекст — що ввели, який діапазон очікували.
Зробімо це без тем, які ще не проходили: без LocalizedError, без «шарів помилок», без Result. Лише те, що вже вміємо: enum, associated values, switch, обчислювані властивості.
LibraryValidationError: один кейс = одне правило
Поширена помилка новачка — зробити один кейс .failed і спробувати втиснути туди рядок «щось пішло не так». Формально це працює, але практичної користі мало: ви не можете нормально розгалужуватися, не можете нормально тестувати, і з часом повідомлення перетворюються на набір випадкових фраз.
Натомість ми робимо так: одне правило — один кейс.
Таблиця правил (як проєктувати помилки)
| Правило валідації | Що не так | Який кейс помилки |
|---|---|---|
| Команда порожня | немає що парсити | |
| Невідома команда | користувач помилився в слові | |
| Бракує аргументів | формат команди некоректний | |
| Рік — не число | не спрацював |
|
| Рік поза діапазоном | число є, але не підходить | |
| Заголовок порожній | після trim — порожньо | |
Реалізація enum і стандартного тексту повідомлень
import Foundation
enum LibraryValidationError: Error {
case emptyCommand
case unknownCommand(name: String)
case missingArgument(name: String)
case emptyTitle
case yearNotANumber(input: String)
case yearOutOfRange(value: Int, min: Int, max: Int)
var message: String {
switch self {
case .emptyCommand:
return "Порожня команда. Введіть команду, наприклад: add \"Clean Code\" 2008"
case .unknownCommand(let name):
return "Невідома команда: \\(name). Спробуйте: add"
case .missingArgument(let name):
return "Бракує аргументу: \\(name). Приклад: add \"Clean Code\" 2008"
case .emptyTitle:
return "Назва книги не може бути порожньою."
case .yearNotANumber(let input):
return "Рік має бути числом, але отримано: '\\(input)'."
case .yearOutOfRange(let value, let min, let max):
return "Рік \\(value) поза діапазоном \\(min)…\\(max)."
}
}
}
Тут message — це наш «єдиний формат» на рівні змісту: у кожної помилки є стандартний текст. Трохи пізніше ми додамо ще й єдиний формат виводу, наприклад завжди з "Error:".
3. Розбір команди add у guard-style
А тепер дуже життєва ситуація: користувач вводить рядок. Ми хочемо додати книгу. Нам треба витягнути з введення команду, заголовок і рік. І ми не хочемо використовувати !, тому що ! у таких місцях — це «я сподіваюся, що користувач завжди ідеальний». Користувач, звісно, старається… але все ж ні.
Уявімо просту модель введення: користувач вводить рядок такого вигляду:
add CleanCode 2008
Так, у реальності хочеться підтримати лапки, пробіли в назві та інше — але це залишимо на потім, на окрему тему про CLI-розбір. Сьогодні наша мета — валідація через throw, тому тримаємо формат простим і передбачуваним.
Токенізація і базова перевірка
import Foundation
func tokenize(_ line: String) throws -> [String] {
let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { throw LibraryValidationError.emptyCommand }
return trimmed.split(separator: " ").map(String.init)
}
Зверніть увагу: ми не «повертаємо порожній масив токенів і сподіваємося, що далі розберуться». Ми одразу говоримо: порожня команда — це помилка валідації.
Розберімо саму команду add
import Foundation
func parseAddCommand(_ tokens: [String]) throws -> (title: String, year: Int) {
guard tokens.first == "add" else {
throw LibraryValidationError.unknownCommand(name: tokens.first ?? "")
}
guard tokens.count >= 3 else {
throw LibraryValidationError.missingArgument(name: "title/year")
}
let title = tokens[1]
let yearText = tokens[2]
guard let year = Int(yearText) else {
throw LibraryValidationError.yearNotANumber(input: yearText)
}
return (title, year)
}
Так, тут є момент: якщо tokens.first дорівнює nil, ми підставили "". В ідеалі можна окремо кидати .emptyCommand, але наш tokenize уже не пропускає порожнє введення, тож tokens у звичайному сценарії не буває порожнім.
4. Throwing-init як гарантія коректності: Year і Book
Тепер зробімо наступний крок розвитку коду: нехай «рік» перестане бути просто Int, а стане типом Year. Це те, про що ми говорили в темі про value objects: коли є правила, сам тип має зберігати їх поруч із собою.
Важливо: сьогодні нас цікавить не «value objects як філософія», а те, як throw допомагає зробити інваріант незаперечним: якщо Year створено — воно коректне.
Year: guard-style перевірка діапазону
import Foundation
struct Year {
let value: Int
init(_ value: Int) throws {
let min = 1450
let max = 2100
guard (min...max).contains(value) else {
throw LibraryValidationError.yearOutOfRange(value: value, min: min, max: max)
}
self.value = value
}
}
Зверніть увагу: ми свідомо використовуємо той самий LibraryValidationError, а не окремий YearError. Це і є «єдиний формат помилок» у мініатюрі: у проєкті не плодяться десятки enumʼів на кожну дрібницю, поки ви ще на ранньому етапі. Коли проєкт стане більшим, можна буде розділяти помилки за шарами — але це не тема сьогоднішньої лекції.
Book як валідна сутність
Зробімо мінімальний Book без ID і без авторів — не ускладнюймо:
import Foundation
struct Book {
let title: String
let year: Year
init(title: String, year: Year) throws {
let cleanTitle = title.trimmingCharacters(in: .whitespacesAndNewlines)
guard !cleanTitle.isEmpty else {
throw LibraryValidationError.emptyTitle
}
self.title = cleanTitle
self.year = year
}
}
Зверніть увагу на стиль: Book не «сподівається», що title уже перевірили десь зовні. Він сам захищає свою коректність. Це зменшує дублювання перевірок і кількість місць, де можна забути правило.
5. Єдина точка виводу помилок
Дуже хочеться — і це абсолютно людське бажання — робити print("Помилка!") у тому місці, де її виявили. Але це ламає архітектуру навіть маленького застосунку: низькорівневий код починає вирішувати, як спілкуватися з користувачем, хоча в нього немає контексту.
Правильніше так: низький рівень кидає помилку, середній рівень за потреби додає контекст або перетворює помилку, а верхній рівень, умовний main, вирішує, що саме надрукувати. Так у вас з’являється єдиний стиль повідомлень, і ви не отримуєте «зоопарк» формулювань.
Зберімо сценарій runLibraryCLI()
import Foundation
func runLibraryCLI() throws {
let line = readLine() ?? ""
let tokens = try tokenize(line)
let parsed = try parseAddCommand(tokens)
let year = try Year(parsed.year)
let book = try Book(title: parsed.title, year: year)
print("Книгу додано: \\(book.title), \\(book.year.value)") // приклад виводу
}
Тут добре видно ланцюжок перевірок: кожен крок або дає коректне значення, або кидає зрозумілу помилку.
Верхньорівнева обробка: єдиний формат тексту
import Foundation
do {
try runLibraryCLI()
} catch let e as LibraryValidationError {
print("Error: \\(e.message)")
} catch {
print("Error: неочікувана помилка: \\(error)")
}
Ось це — ключовий момент лекції: єдиний формат. Усі наші помилки валідації перетворюються на передбачуваний рядок на кшталт "Error: ...".
І так, ми окремо ловимо LibraryValidationError, а всі інші, яких не очікували, передаємо в загальний catch. Це чесно: «очікувані помилки користувача» і «неочікувані помилки програми» — різні звірі, але сьогодні ми не заглиблюємося в політику помилок, а просто робимо мінімум розумного розділення.
6. Блок-схема потоку помилок у CLI
Щоб усе це не здавалося набором магічних слів throws/try, корисно тримати в голові простий маршрут даних:
flowchart TD
A["readLine()"] --> B[tokenize]
B -->|успішно| C[parseAddCommand]
B -->|виняток| E[catch → повідомлення про помилку]
C -->|успішно| D[Year.init]
C -->|виняток| E
D -->|успішно| F[Book.init]
D -->|виняток| E
F -->|успішно| G[успішний вивід]
F -->|виняток| E
Сенс простий: помилка може виникнути на будь-якому кроці, і це нормально. Головне — щоб вона доїжджала до одного місця, де перетворюється на зрозумілий текст.
7. Типові помилки
Помилка № 1: змішувати валідацію і вивід користувачу в одній функції.
Дуже легко почати друкувати помилки всередині Year.init або parseAddCommand, бо здається, що «а де ж іще». Але так ви втрачаєте контроль над єдиним форматом повідомлень: в одному місці буде print("Некоректний рік"), в іншому print("Помилка введення даних"), у третьому — взагалі нічого. Набагато чистіше кидати помилку вниз по коду й друкувати її в одному верхньому місці.
Помилка № 2: робити один кейс .failed(String) замість набору причин.
Спершу це здається зручним: «ну я ж можу покласти туди будь-який текст». Потім виявляється, що ви не можете нормально розрізняти причини, не можете нормально порівнювати сценарії в тестах, коли вони з’являться, і починаєте парсити власні ж рядки через contains. Краще один кейс — одна причина, а контекст зберігати через associated values.
Помилка № 3: використовувати if замість guard, а потім забувати завершити гілку помилки.
Класика: ви перевірили умову, надрукували «помилка», але код пішов далі, і тепер у вас частково заповнені дані. Саме цим guard-style і зручний: якщо умова не виконана, ви повинні вийти (throw). Це зменшує шанс продовжити виконання в неправильному стані.
Помилка № 4: перетворювати будь-яку невдачу на nil, а потім не розуміти, що сталося.
Optional — чудовий інструмент, але коли вам треба пояснити, чому саме не вдалося — порожньо? не число? поза діапазоном? — nil занадто мовчазний. Валідація часто вимагає причини, і throw якраз дає цю причину в типізованому вигляді.
Помилка № 5: зберігати замало контексту в помилці, а потім друкувати беззмістовне повідомлення.
Якщо ви робите .yearOutOfRange без фактичного значення і без меж, то повідомлення буде в стилі «рік неправильний», і користувачу доведеться вгадувати. Associated values існують саме для того, щоб помилка була інформативною: «отримали 3021, очікували 1450…2100».
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ