Невозможные состояния — непредставимы

Девиз доменного моделирования в 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-состояние убирает противоречивые комбинации.
  • Проверки переезжают из рантайма в компиляцию — багов меньше по построению.
Проверьте себя
1. Что значит «сделать невозможные состояния непредставимыми»?
AПрятать ошибки
BПроектировать типы так, чтобы некорректное значение нельзя было создать
CОтключить проверку типов
DИспользовать только числа
2. Чем плоха модель Contact с двумя option-полями Email и Phone?
AСлишком быстрая
BРазрешает бессмысленное состояние None/None
CНе компилируется
DНе поддерживает email
3. Чем заменить набор противоречивых булевых флагов состояния?
AБольшим if
BОдним DU, где состояние всегда ровно одно
CГлобальной переменной
DКортежем из флагов