Невозможные состояния — непредставимы
Девиз доменного моделирования в F#: спроектируй типы так, чтобы неправильное состояние нельзя было выразить.
«Сделать невозможные состояния непредставимыми» — подход, при котором система типов исключает создание некорректных значений ещё на этапе компиляции.
Проблема: слабая модель
Рассмотрим контакт, у которого должен быть email или телефон (или оба), но не «ничего». В наивной модели на полях это легко нарушить.
// слабо: можно создать контакт без связи вообще
type Contact = {
Email: string option
Phone: string option
}
// допустимо { Email = None; Phone = None } — а это бессмыслицаТип разрешает «пустой» контакт, и каждый, кто его использует, обязан помнить про эту проверку в рантайме. Забыл — баг.
Решение: DU, отражающий правила
Перепишем модель так, чтобы «контакт без связи» нельзя было даже создать.
type ContactInfo =
| EmailOnly of string
| PhoneOnly of string
| Both of email: string * phone: string
let describe c =
match c with
| EmailOnly e -> sprintf "email: %s" e
| PhoneOnly p -> sprintf "тел: %s" p
| Both (e, p) -> sprintf "email: %s, тел: %s" e p
printfn "%s" (describe (EmailOnly "[email protected]"))
printfn "%s" (describe (Both ("[email protected]", "+7...")))Вывод:
email: [email protected] email: [email protected], тел: +7...
Теперь невозможное состояние «нет ни email, ни телефона» просто не существует в типе. Проверять в рантайме нечего.
Замена флагов на состояния
Частая ошибка — описывать состояние булевыми флагами, которые могут противоречить друг другу. Заменим их на DU.
// было: isLoading, isError, data — могут противоречить
// стало: ровно одно состояние
type Loading<'T> =
| NotStarted
| InProgress
| Loaded of 'T
| Failed of string
let render state =
match state with
| NotStarted -> "ещё не начато"
| InProgress -> "загрузка..."
| Loaded data -> sprintf "готово: %A" data
| Failed msg -> sprintf "ошибка: %s" msg
printfn "%s" (render (Loaded 42))Вывод:
готово: 42
Невозможно одновременно быть «загружено и ошибка» — состояние всегда ровно одно.
Как работает под капотом
Сила приёма — в том, что компилятор не даст сконструировать недопустимое значение: его попросту нет среди вариантов типа. Это сдвигает проверки из рантайма в компиляцию. Плюс исчерпывающий match заставляет обработать каждое реальное состояние. В сумме целый класс багов («а что если оба null?») исчезает по построению.
Частые ошибки
- Оставлять «технически возможные, но бессмысленные» комбинации полей вместо точного DU.
- Дублировать состояние булевыми флагами — лучше один DU-тип состояния.
- Перемоделировать: не каждый случай требует DU, для простых данных хватит записи.
Итоги
- Проектируйте типы так, чтобы недопустимое значение нельзя было создать.
- DU выражает взаимоисключающие варианты лучше, чем набор nullable-полей.
- Замена булевых флагов на DU-состояние убирает противоречивые комбинации.
- Проверки переезжают из рантайма в компиляцию — багов меньше по построению.