В этом посте мы познакомимся с ключевым преимуществом F#, который использует систему типов, чтобы «сделать недопустимые состояния непредставимыми» (фраза позаимствована у Ярона Мински).

В прошлом посте мы упростили тип Contact благодаря рефакторингу. Теперь он выглядит так:

type Contact =
    {
    Name: Name;
    EmailContactInfo: EmailContactInfo;
    PostalContactInfo: PostalContactInfo;
    }

Теперь представим, что у нас есть простое бизнес-правило: «Контакт должен иметь электронный адрес или почтовый адрес». Соответствует ли наш тип этому правилу?

Ответ — нет. Бизнес-правило подразумевает, что у контакта может быть электронный адрес и не быть почтового, или наоборот. Но в текущем виде наш тип требует, чтобы у контакта всегда были оба адреса.

Решение кажется очевидным — сделать адреса опциональными:

type Contact =
    {
    Name: PersonalName;
    EmailContactInfo: EmailContactInfo option;
    PostalContactInfo: PostalContactInfo option;
    }

Но и это решение неправильное. Теперь контакт может вообще не иметь никаких адресов. Однако, бизнес-правило утверждает, что в контакте должен быть по крайней мере один адрес.

Так какое решение правильное?

Делаем недопустимые состояния непредставимыми

Если мы тщательно обдумаем бизнес-правило, то выясним, что существуют всего три варианта:

  • У контакта есть только электронный адрес

  • У контакта есть только почтовый адрес

  • У контакта есть и электронный, и почтовый адреса

При такой формулировке правила, решение очевидно — используем тип-объединение с тремя вариантами.

type ContactInfo =
    | EmailOnly of EmailContactInfo
    | PostOnly of PostalContactInfo
    | EmailAndPost of EmailContactInfo * PostalContactInfo

type Contact =
    {
    Name: Name;
    ContactInfo: ContactInfo;
    }

Этот дизайн идеально соответствует требованиям. Все три варианта представлены в явном виде, а четвёртый возможный вариант (когда вообще нет ни электронного, ни почтового адреса) недоступен.

Для варианта «электронный и почтовый адреса» я пока использую обычный тип-кортеж. Его вполне хватает.

Конструируем ContactInfo

Теперь разберёмся, как использовать наш тип на практике. Начнём с создания контакта:

let contactFromEmail name emailStr =
    let emailOpt = EmailAddress.create emailStr
    // обрабатываем варианты правильного и неправильного электронного адреса
    match emailOpt with
    | Some email ->
        let emailContactInfo =
            {EmailAddress=email; IsEmailVerified=false}
        let contactInfo = EmailOnly emailContactInfo
        Some {Name=name; ContactInfo=contactInfo}
    | None -> None

let name = {FirstName = "A"; MiddleInitial=None; LastName="Smith"}
let contactOpt = contactFromEmail name "abc@example.com"

Здесь мы написали простую вспомогательную функцию conctactFromEmail, чтобы создать новый контакт из имени и электронного адреса. Однако, электронный адрес может быть неправильным, так функция умеет обрабатывать ошибку. Поэтому возвращаемый тип — это Contact option, а не просто Contact.

Обновляем ContactInfo

Если мы хотим добавить почтовый адрес к существующему ContactInfo, у нас нет иного выбора, кроме как учесть все возможные варианты:

  • Если контакт содержал только электронный адрес, добавляем почтовый адрес. Возвращаем вариант EmailAndPost.

  • Если контакт содержал только почтовый адрес, заменяем этот адрес на новый. Возвращаем вариант PostOnly.

  • Если контакт содержал и электронный, и почтовый адреса, заменяем почтовый адрес на новый. Возвращаем вариант EmailAndPost.

Вот вспомогательный метод, который обновляет почтовый адрес. Он явным образом обрабатывает каждый вариант.

let updatePostalAddress contact newPostalAddress =
    let {Name=name; ContactInfo=contactInfo} = contact
    let newContactInfo =
        match contactInfo with
        | EmailOnly email ->
            EmailAndPost (email,newPostalAddress)
        | PostOnly _ -> // игнорируем текущий адрес
            PostOnly newPostalAddress
        | EmailAndPost (email,_) -> // игнорируем текущий адрес
            EmailAndPost (email,newPostalAddress)
    // создаём новый контакт
    {Name=name; ContactInfo=newContactInfo}

А вот пример использования:

let contact = contactOpt.Value   // см. предупреждение об option.Value ниже
let newPostalAddress =
    let state = StateCode.create "CA"
    let zip = ZipCode.create "97210"
    {
        Address =
            {
            Address1= "123 Main";
            Address2="";
            City="Beverly Hills";
            State=state.Value; // см. предупреждение об option.Value ниже
            Zip=zip.Value;     // см. предупреждение об option.Value ниже
            };
        IsAddressValid=false
    }
let newContact = updatePostalAddress contact newPostalAddress

ПРЕДУПРЕЖДЕНИЕ: я использую option.Value, чтобы извлечь содержимое опционального типа. Так можно делать в учебном коде, но категорически нельзя в продуктовом! Работая с опциональным типом всегда применяйте сопоставление с образцом.

Зачем вообще создавать такие сложные типы?

Возможно, сейчас вы думаете, что я всё неоправданно усложнил. Отвечу вам так:

Во-первых: бизнес-логика сложна сама по себе. Её нельзя упростить. Если ваш код проще логики, значит, в нём чего-то не хватает.

Во-вторых, логика, представленная в типах, сама себя документирует. Взглянув на варианты объединения, вы сразу понимаете, в чём заключается бизнес-правило. Вам не надо читать какой-то другой код.

type ContactInfo =
    | EmailOnly of EmailContactInfo
    | PostOnly of PostalContactInfo
    | EmailAndPost of EmailContactInfo * PostalContactInfo

Наконец, если логика представлена типом, любые изменения в бизнес-правиле немедленно сломают код, что, как это ни странно, хорошо.

Последний пункт мы обсудим в следующем посте. Возможно, что, пытаясь представить бизнес-логику с помощью типов, вы внезапно узнаете что-то совершенно новое о предметной области.

Комментарии (4)


  1. Filipp42
    23.04.2026 08:26

    Эх... С монадами код был бы краше...


    1. kleidemos
      23.04.2026 08:26

      По моему, в этой статье примеры ещё не позволяют себя улучшить посредством монад.


    1. markshevchenko Автор
      23.04.2026 08:26

      Про монады в F# у Влащина есть другой цикл. Который уже переведён на русский: https://habr.com/ru/articles/840106/


  1. amcured
    23.04.2026 08:26

    А что помешало выбрать язык с типизацией здорового человека (F*, например) и реализовать refinement не удалением гланд через ухо, а …кхм… ну прям изкоробочным рефайнментом?