JavaRush /Курси /Swift SELF /Result.map / flatMap / mapError

Result.map / flatMap / mapError

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

1. Навіщо потрібні map / flatMap / mapError

Коли ви щойно знайомитеся з Result, перша реакція зазвичай така: «Гаразд, зроблю switch — і все». І це нормально. Але щойно у вас зʼявляється задача з трьох–чотирьох кроків (розпарсити введення → провалідовувати → перетворити → застосувати до стану застосунку), switch починає розмножуватися, як кролі в debug-логах.

Тоді й виникає ідея «конвеєра»: якщо крок успішний — рухаємося далі; якщо сталася помилка — вона автоматично передається далі, а ви обробляєте її в одному місці. Саме для цього в Result є map, flatMap і mapError.

Щоб було простіше орієнтуватися, запамʼятайте одну думку:
map змінює успіх, mapError змінює помилку, flatMap зʼєднує кроки, які самі повертають Result.

Карта методів: що змінюємо і що залишається недоторканим

Коли ви бачите три схожі назви, мозок новачка робить вигляд, що зайнятий, і йде у відпустку. Тому почнімо з дуже приземленої таблиці: що на вході та що на виході — без філософії.

Метод Якщо був .success Якщо була .failure Що змінюється за типом
map
перетворює значення помилка лишається без змін змінюється Success, Failure залишається тим самим
mapError
успіх іде як був перетворює помилку змінюється Failure, Success залишається тим самим
flatMap
викликає крок, який повертає Result помилка лишається без змін змінюється Success, Failure залишається тим самим

А тепер «людський переклад»: map — «перероби результат, якщо все добре», mapError — «перероби помилку, якщо все погано», flatMap — «зроби наступний крок, який теж може впасти, але без матрьошки з Result<Result<...>>».

2. map: змінюємо Success і не чіпаємо помилку

map — найдружніший із трійки. Він схожий на Array.map і Optional.map: якщо всередині є корисне значення, ми його перетворюємо. Якщо замість нього — помилка, ми нічого не змінюємо: вона їде далі як є. Це ідеальний інструмент, коли крок не може сам породити нову помилку.

З погляду типів: було Result<Success, Failure>, стало Result<NewSuccess, Failure>.

Приклад: IntString через map

import Foundation

enum ParseError: Error {
    case notANumber
}

let r: Result<Int, ParseError> = .success(42)

let text: Result<String, ParseError> = r.map { value in
    "Число: \(value)"
}

print(text) // .success("Число: 42")

Тут map спрацював тільки тому, що був .success. Якби був .failure(.notANumber), то text теж став би .failure(.notANumber) — і замикання взагалі не виконалося б.

Приклад: «маленький конвеєр» успіху

import Foundation

enum MathError: Error { case negative }

let r: Result<Int, MathError> = .success(10)

let squared = r
    .map { $0 + 1 }
    .map { $0 * $0 }

print(squared) // .success(121)

Зверніть увагу: ми двічі змінюємо успішне значення, а помилка, якби вона була, спокійно проїхала б повз усі map.

3. mapError: змінюємо Failure і не чіпаємо успіх

Іноді успішне значення вже ідеальне, а от помилку потрібно подати в іншому форматі. Наприклад, низькорівнева функція повертає ParseError, а ваш верхній рівень застосунку хоче мати єдиний тип помилок: AppError. Тоді ви не хочете переписувати всю успішну логіку — ви хочете акуратно «перекласти» помилку на нову мову.

З погляду типів: було Result<Success, Failure>, стало Result<Success, NewFailure>.

Приклад: «обгортаємо» помилку

import Foundation

enum ParseError: Error { case notANumber }
enum AppError: Error { case parse(ParseError) }

let r: Result<Int, ParseError> = .failure(.notANumber)

let appResult: Result<Int, AppError> = r.mapError { err in
    .parse(err)
}

print(appResult) // .failure(.parse(.notANumber))

Тут успіх не змінювався взагалі. А помилка стала «помилкою рівня застосунку», що корисно для однакової обробки.

Приклад: «переїзд між шарами»

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

4. flatMap: склеюємо кроки ResultResult без матрьошки

Найчастіше новачки плутаються саме тут: «Чому не можна використати map, якщо я хочу викликати наступну функцію?». Можна — але лише якщо наступна функція повертає звичайне значення. Якщо ж вона повертає Result, то map створить «матрьошку»:

Result<Result<NewSuccess, Failure>, Failure>

Тому flatMap робить рівно одне: викликає функцію Success -> Result<NewSuccess, Failure> і «розплющує» результат до нормального Result<NewSuccess, Failure>.

Приклад: парсинг і перевірка діапазону

import Foundation

enum NumberError: Error { case notANumber, outOfRange }

func parseInt(_ text: String) -> Result<Int, NumberError> {
    guard let n = Int(text) else { return .failure(.notANumber) }
    return .success(n)
}

func require0to100(_ n: Int) -> Result<Int, NumberError> {
    guard (0...100).contains(n) else { return .failure(.outOfRange) }
    return .success(n)
}

let r = parseInt("150").flatMap(require0to100)
print(r) // .failure(.outOfRange)

require0to100 повертає Result, тому тут потрібен саме flatMap. Якби він повертав просто Bool або Int, тоді був би map.

5. Практика: міні-CLI і конвеєр операцій

Тепер звʼяжімо все в одну історію: міні-застосунок командного рядка, який зберігає бібліотеку книжок у памʼяті. Жодних файлів, репозиторіїв і мереж — це буде пізніше. Наразі ми тренуємо навичку будувати зрозумілий конвеєр із кроків, де кожен крок може дати помилку.

Уявімо, що в нас є команда:

add "<title>" <year>

І ми хочемо пройти такий шлях:

flowchart TD
    A["Рядок уведення"] --> B["Розбір аргументів"]
    B --> C["Розбір року в Int"]
    C --> D["Перевірка року"]
    D --> E["Створення Book"]
    E --> F["Додавання до масиву"]

Ключовий момент: кроки B–D можуть упасти з різних причин. Ми хочемо не «вкладені switch-розгалуження», а рівний лінійний ланцюжок.

Моделі для застосунку

Перед тим як писати map/flatMap/mapError, нам потрібен мінімальний набір типів. Це буде «скелет застосунку», на який ми спиратимемося.

Модель книжки

import Foundation

struct Book {
    let title: String
    let year: Int
}

Помилки шарів

import Foundation

enum CommandError: Error {
    case unknownCommand
    case missingArgs
    case invalidYear
}

enum DomainError: Error {
    case emptyTitle
    case yearOutOfRange
}

enum AppError: Error {
    case command(CommandError)
    case domain(DomainError)
}

Ми спеціально розділили помилки: командні й доменні. Це стане в пригоді для mapError, щоб «підняти» помилку до спільного формату AppError.

Кроки-процедури: кожну операцію робимо окремою функцією

Одна з найкращих звичок під час роботи з map/flatMap/mapError — тримати кроки короткими й названими. Якщо запхати все в один довгий ланцюжок із замиканнями, це буде «функціонально», але читати його зможуть тільки люди, які медитують на Swift Evolution перед сном.

Крок 1: проста токенізація

import Foundation

func tokenize(_ line: String) -> [String] {
    line.split(separator: " ").map(String.init)
}

Так, без лапок і складних правил — ми розвʼязуємо вправу на Result, а не пишемо bash-скрипт.

Крок 2: парсинг року як Result

import Foundation

func parseYear(_ text: String) -> Result<Int, CommandError> {
    guard let year = Int(text) else { return .failure(.invalidYear) }
    return .success(year)
}

Крок 3: доменна перевірка року

import Foundation

func validateYear(_ year: Int) -> Result<Int, DomainError> {
    guard (1450...2100).contains(year) else { return .failure(.yearOutOfRange) }
    return .success(year)
}

Збираємо конвеєр: flatMap + map + mapError

Тепер найцікавіше: ми хочемо з тексту року отримати валідний рік, а потім створити книжку.

Тут важливий момент: у нас різні типи помилок. parseYear повертає CommandError, а validateYearDomainError. Якщо спробувати зробити прямий flatMap, типи не зійдуться.

Тому ми або завчасно зводимо все до одного типу помилок, або робимо «переклад» через mapError на межі. Це дуже життєва ситуація.

Конвеєр року: parseYearvalidateYear

import Foundation

func parseAndValidateYear(_ text: String) -> Result<Int, AppError> {
    parseYear(text)
        .mapError { .command($0) }
        .flatMap { year in
            validateYear(year).mapError { .domain($0) }
        }
}

Тут одразу видно ролі методів: mapError піднімає помилки до спільного формату.
А flatMap зʼєднує кроки, кожен із яких повертає Result.

Створення книжки: map як фінальне перетворення успіху

import Foundation

func makeBook(title: String, yearText: String) -> Result<Book, AppError> {
    parseAndValidateYear(yearText)
        .map { year in
            Book(title: title, year: year)
        }
}

Book(...) не може «впасти» (у цій версії), тому це чистий map: змінюємо Success з Int на Book, а помилка залишається AppError.

Порівняння: «вкладені перевірки» vs «конвеєр»

Іноді студенти бачать flatMap і думають: «То я тепер зобовʼязаний писати як чарівник із функціонального лісу?». Ні. Це просто інструмент, який інколи робить код простішим.

До: типова вкладеність

import Foundation

func makeBookNested(title: String, yearText: String) -> Result<Book, AppError> {
    switch parseYear(yearText) {
    case .failure(let e):
        return .failure(.command(e))
    case .success(let year):
        switch validateYear(year) {
        case .failure(let e):
            return .failure(.domain(e))
        case .success(let okYear):
            return .success(Book(title: title, year: okYear))
        }
    }
}

Код робочий, але він уже починає розростатися вшир, і на третьому кроці ви відчуєте, як switch тихо захоплює ваш проєкт.

Після: той самий зміст, але ланцюжком

import Foundation

func makeBookPipeline(title: String, yearText: String) -> Result<Book, AppError> {
    parseYear(yearText)
        .mapError { .command($0) }
        .flatMap { validateYear($0).mapError { .domain($0) } }
        .map { Book(title: title, year: $0) }
}

Ця версія зазвичай читається швидше згори донизу: спершу парсимо, потім перевіряємо, потім будуємо книжку. Помилка автоматично минає інші кроки.

Вбудовуємо в маленький цикл CLI

Зробімо міні-обробник рядка. Ми не пишемо повноцінний парсер команд — просто покажемо, як Result допомагає тримати обробку чистою: або додали книжку, або отримали помилку, яку можна показати користувачу.

Обробка команди add

import Foundation

func handleAdd(tokens: [String], library: inout [Book]) -> Result<Void, AppError> {
    guard tokens.count >= 3 else { return .failure(.command(.missingArgs)) }

    let title = tokens[1]
    let yearText = tokens[2]

    return makeBookPipeline(title: title, yearText: yearText)
        .map { book in library.append(book) }
}

Тут map використано трохи хитро: ми перетворюємо Book на Void, тому що нам важливий побічний ефект append. Так, побічні ефекти в map — не ідеальна філософія, але в маленькому CLI це допустимо, якщо ви усвідомлюєте, що робите.

Чому flatMap так називається і чому збиває з пантелику

Якщо ви гуглили flatMap, то, напевно, бачили, що він трапляється в Array, Optional, Sequence, а ще в Result. Іноді він робить різні речі, хоча «ідея» схожа: перетвори й розплющ.

У Swift історично було кілька форм flatMap, і навколо цього справді виникала плутанина. Тому навіть зʼявлялися зміни в API та обговорення, як зменшити зловживання й неоднозначність.

Але в контексті Result нам важливо лише одне: Result.flatMap зʼєднує кроки ResultResult, щоб не було вкладеності.

Якщо вам від цього спокійніше: плутатися в flatMap — це не ознака того, що ви «не програміст». Це ознака того, що ви людина, а не компілятор.

6. Типові помилки

Помилка №1: використовувати map там, де потрібен flatMap, і отримувати «матрьошку типів».
Це трапляється, коли ви робите result.map { step($0) }, а step повертає Result. У підсумку тип стає схожим на Result<Result<...>, ...>, і далі ви не розумієте, чому не можна просто зробити ще один map. У таких місцях правило просте: якщо функція повертає Result, майже завжди потрібен flatMap, бо він спеціально створений, щоб розплющувати вкладеність.

Помилка №2: забувати, що mapError змінює лише помилку, але не «лікує» її.
mapError не перетворює падіння на успіх — він лише перепаковує тип помилки. Часто новачки очікують, що «зараз зроблю mapError — і все стане нормально». Ні: результат залишиться .failure, просто з іншою помилкою. Це корисно для однаковості, але не для магічного зцілення логіки.

Помилка №3: робити гігантський ланцюжок із важкою логікою всередині замикань.
Технічно можна написати 15 рядків flatMap { ... } і всередині кожного блока ще по 20 рядків. Компілятор це переживе. Ваше майбутнє «я» — уже не факт. Дуже допомагає стиль «маленькі іменовані кроки»: parseYear, validateYear, makeBook — і ланцюжок стає читабельним, як рецепт.

Помилка №4: змішувати в одному ланцюжку різні «словники помилок» без mapError.
Щойно у вас різні типи помилок (наприклад, CommandError і DomainError), ланцюжок перестає збиратися, і новачок починає «лікувати» це через fatalError, try! або перетворення всього на String. Набагато здоровіше зробити один спільний тип (AppError) і піднімати помилки на межах через mapError, зберігаючи причини та можливість нормальної обробки.

Помилка №5: перетворювати map на місце для хаотичних побічних ефектів.
Іноді хочеться в map і друкувати, і змінювати масив, і рахувати статистику, і ще відправити голуба з листом. Памʼятайте, що ідея ланцюжка — у ясності. Якщо побічний ефект справді потрібен (наприклад, append до бібліотеки), робіть його коротко й передбачувано, а все складне краще оформіть окремою функцією, щоб ланцюжок залишався конвеєром, а не атракціоном.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ