JavaRush /Курси /Swift SELF /throw як частина валідації

throw як частина валідації

Swift SELF
Рівень 29 , Лекція 4
Відкрита

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:

  1. були зосереджені в одному enum (або в парі чітко розділених enum, але сьогодні зробимо один — так простіше втримати в голові);
  2. мали передбачуване, зрозуміле для людини повідомлення;
  3. за потреби зберігали контекст — що ввели, який діапазон очікували.

Зробімо це без тем, які ще не проходили: без LocalizedError, без «шарів помилок», без Result. Лише те, що вже вміємо: enum, associated values, switch, обчислювані властивості.

LibraryValidationError: один кейс = одне правило

Поширена помилка новачка — зробити один кейс .failed і спробувати втиснути туди рядок «щось пішло не так». Формально це працює, але практичної користі мало: ви не можете нормально розгалужуватися, не можете нормально тестувати, і з часом повідомлення перетворюються на набір випадкових фраз.

Натомість ми робимо так: одне правило — один кейс.

Таблиця правил (як проєктувати помилки)

Правило валідації Що не так Який кейс помилки
Команда порожня немає що парсити
.emptyCommand
Невідома команда користувач помилився в слові
.unknownCommand(name: ...)
Бракує аргументів формат команди некоректний
.missingArgument(name: ...)
Рік — не число
Int(...)
не спрацював
.yearNotANumber(input: ...)
Рік поза діапазоном число є, але не підходить
.yearOutOfRange(value:min:max)
Заголовок порожній після trim — порожньо
.emptyTitle

Реалізація 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».

1
Опитування
Помилки Swift, рівень 29, лекція 4
Недоступний
Помилки Swift
Типізація та обробка помилок
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ