Идея разобраться в теме монад меня привлекала уже очень давно. Сложность описания концепций представляло не только мою личную проблему, но и была потенциальной проблемой для коллег. Ведь хотелось не просто в них разобраться, а работать с ними каждый день. Функциональное программирование неплохо формирует мышление, является очень выразительным и часто лаконичным решением. Ниже идет описание опыта разработки с применением библиотек монад на JS / TS.

Первый опыт

Первый заход на монады мне удалось совершить на небольшом локальном для компании проекте, который не требовал ежедневной поддержки и командного взаимодействия. В каком‑то смысле это был небольшой pet‑проект компании имени одного меня.

Идея состояла в том, чтобы максимально просто организовать ввод только цифр от 1 до 99.

Помимо посторонней рутины в обработчике ввода находится, собственно сам «трасформер‑валидатор» введенного значения:

const numericValue = Maybe.of(event.target.value)
  .filter(isString)
  .map(leaveOnlyDigits)
  .map(parseToDecimals)
  .filter(notNaN)
  .map(invertIfNegative)
  .map(limitTo99)
  .map(makeOneIfZero)
  .getOrElse(1);

В данном примере была использована монада Maybe из библиотеки folktale. Есть все основания полагать, что монады из других библиотек по сути будут работать также. Сама же folktale не развивается уже много лет.

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

В итоге, мы получаем читаемую и понятную цепочку взаимодействий со значением поля ввода. Профит!

Для тех, кто не знаком с этой концепцией, хотел бы сделать небольшое теоретическое отступление и расставить несколько акцентов, которые мне кажется уместны в данном контексте. Безо всяких сомнений, более полную информацию можно найти в документации к библиотекам, а теоретическими выкладками на эту тему богат даже рунет.

Итак, монада Maybe может быть двух подтипов Just или Nothing.

Метод создания монады Maybe.of возвращает Maybe.Just, в которой содержится значение, переданное в метод. Оно может быть любое, в т.ч. undefined или null. При создании этой монады нам еще не важно, каким будет это значение.

Метод map также возвращает Maybe.Just, но со значением уже измененным той функцией, которую мы в этот метод, при условии, что наша монада имеет подтип Maybe.Just. Если вызвать метод map у монады подтипа Maybe.Nothing, никаких действий не будет выполнено и вернется монада типа Maybe.Nothing.

Метод filter возвращает Maybe.Just, если функция переданная в этот метод при взаимодействии со значением внутри монады вернет true. В обратном случае, метод вернет монаду Maybe.Nothing.

Метод getOrElse возвращает значение монады подтипа Maybe.Just, либо возвращает значение, переданное в этот метод.

Надо сказать, что конкретно эта цепочка преобразований и проверок может быть представлена в более привычном для javascript‑разработчиков виде:

const numericValue = [event.target.value]
  .filter(isString)
  .map(leaveOnlyDigits)
  .map(parseToDecimals)
  .filter(notNaN)
  .map(invertIfNegative)
  .map(limitTo99)
  .map(makeOneIfZero)
  .find(it => it) || 1;

Такой финт возможен, поскольку массивы в JavaScript — это полноценные функторы. То есть, у них есть метод в данном случае map, который принимает на вход некую функцию преобразования и возвращает на выходе объект того же типа, то есть такой же массив. Но внутри него уже содержится измененное значение.

Так что, подобные, простенькие псевдомонадные штуки вполне реально писать на обычных массивах.

Опыт в полностью практической области

Затем была попытка затащить все это дело на бэк. Но с библиотекой folktale это оказалось невозможным из‑за отсутствия реализации работы асинхронными методами и на тот момент было принято решение ограничиться псевдомонадным решением. Но уже к тому моменту стало понятно, на сколько удобно было бы работать с подобным подходом.

В итоге, когда представилась возможность начать проект на TypeScript, удалось найти библиотеку с монадами, которая умела в асинхронность: purify‑ts. Необходимость появления монад в проекте была обусловлена удобством обработки ошибок. Для этого была использована монада Either и ее асинхронный аналог EitherAsync. Помимо этих монад в библиотеке представлено множество других, в т.ч. и Maybe. Они прекрасно между собой сочетаются и позволяют писать в функциональном стиле. Кстати сказать, если вдруг у разработчика, который будет работать с таким подходом вдруг возникнут сложности, надо понимать, что монаду всегда можно «развернуть» и дальше продолжить работать с кодом как обычно. Конечно, вся прелесть функционального подхода и его выразительность и лаконичность потеряются.

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

return EitherAsync.liftEither(this.stateManager.checkUserExpiration())
  .bimap(
    // если дата истекла
    () => EitherAsync.liftEither(this.stateManager.getUser())
      .map((user) => user.id)
      .chain((userId) => this.userService.getUser(userId ?? ''))
      .map((user) => this.stateManager.setUser(user)
        .chain(() => this.stateManager.setUserExpiration())
        .reduce((acc) => acc, user))
      .chain((user) => EitherAsync.liftEither(this.stateManager.checkUserStatus())
        .map(() => user)
        .chainLeft(async (statusError) => (await this.authService.removeAllUserSessions(user.id))
          .chain(() => this.stateManager.logout())
          .map(() => statusError),
        ),
      )
      .then((either) => either.extract()),

    // если дата НЕ истекла
    () => this.stateManager.getUser().extract(),
  )
  .run()
  .then((either) => either.extract());

Здесь чуть другой нейминг методов, но суть совершенно та же самая.

Стоит обратить внимание на метод chain, который принимает на вход метод, возвращающий монаду со значением другого типа (назовем его Monad<B>). Он ее «переваривает» и возвращает монаду с уже новым типом. Работу этого метода можно сравнивать с методом flatMap массивов. Если бы мы вместо chain воспользовались методом map, то вернули бы исходную монаду, в которой было бы значение с типом Monad<B>, а так у нас сразу будет Monad<B>.

Еще раз другими словами:

Monad<A>.map(() => Monad<B>): => Monad<Monad<B>>
Monad<A>.chain(() => Monad<B>): => Monad<B>

Array.map(() => Array) => Array[Array]
Array.flatMap(() => Array) => Array

Монада Either, как и Maybe может быть двух типов, но в ее контексте они называются Left (для неверных значений) и Right (для верных). Это делает ее применение в обработке ошибок очень полезной. Она немного похожа на Maybe за тем исключением, что у Maybe.Nothing не может быть значения, а в Either.Left может.

Метод bimap — удобный метод одновременной работы с монадами типа Either. Это одновременный маппер для Either.Left и Either.Right монад. Если в результате проверки

EitherAsync.liftEither(this.stateManager.checkUserExpiration())

мы получим монаду Either.Right, мы перейдем к мапперу для Either.Right:

() => this.stateManager.getUser().extract()

Иначе нам предстоит пройти длинную цепочку проверок, запросов и преобразований из маппера для Either.Left.

Результат работ этого метода (тот, что большой, приведенный выше) будет уже не монада, а объект конкретного типа: NetworkError (это то, что лежало у нас в монаде Either.Left), или User (это то, что лежало у нас в монаде Either.Right). Этот объект дальше уходит в обработчик запроса. В нашем случае я работал с Express:

sendResult(res: Response, result: any) {
  if (result instanceof NetworkError) {
    return res.status(result.status).send(result.error).end();
  }

  res.json(result || 'OK');
}

NB: В проекте использовался tsoa для генерации swagger‑спецификации, поэтому пришлось здесь разворачивать монаду. Иначе, ее можно было бы прокинуть влоть до самого обработчика sendResult и отправить ответ также в функциональном стиле. Было бы элегантнее.

NB: Работа с данной библиотекой чем‑то напоминает синтаксис работы с джавовским Reactor‑ом. В общем, если маленько перестроить мозги и абстрагироваться, получается довольно выразительный код.


В заключение хотел бы сказать, что этот опыт был очень захватывающим! Обдумывать, осознавать и понимать детали и различные теоретические и практические аспекты монад и функционального программирования довольно непросто, но это здорово тренирует мозги, расширяя понимание программирования в целом, дает практику совершенно иного для js подхода в рамках привычных инструментов. Ну и если все делать осознанно и аккуратно, получается поддерживаемый и тестируемый код. Ясное дело, что на*****кодить можно любыми способами, в т.ч. и в функциональном стиле. Но как говорится: «Нормально делай, нормально будет».

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


  1. nin-jin
    00.00.0000 00:00
    +15

    Функциональное программирование неплохо формирует мышление, является очень выразительным и часто лаконичным решением.

    return EitherAsync.liftEither(this.stateManager.checkUserExpiration())
      .bimap(
        // если дата истекла
        () => EitherAsync.liftEither(this.stateManager.getUser())
          .map((user) => user.id)
          .chain((userId) => this.userService.getUser(userId ?? ''))
          .map((user) => this.stateManager.setUser(user)
            .chain(() => this.stateManager.setUserExpiration())
            .reduce((acc) => acc, user))
          .chain((user) => EitherAsync.liftEither(this.stateManager.checkUserStatus())
            .map(() => user)
            .chainLeft(async (statusError) => (await this.authService.removeAllUserSessions(user.id))
              .chain(() => this.stateManager.logout())
              .map(() => statusError),
            ),
          )
          .then((either) => either.extract()),
    
        // если дата НЕ истекла
        () => this.stateManager.getUser().extract(),
      )
      .run()
      .then((either) => either.extract());
    
    if( !this.user ) return null
    if( !this.userIsExpired ) return this.user
    
    this.user = this.userService.get( user.id ?? '' )
    this.userSetExpiration()
    
    if( this.userHaveRightStatus() ) return this.user
    
    this.authService.userSessionsClear( user.id )
    this.logout()
    
    return null

    Занавес.


    1. HAGer2000 Автор
      00.00.0000 00:00

      Нарушена бизнес логика процесса, пропущена пара важных вызовов, не обработаны ошибки, в конце концов даже возвращается не то, что нужно. А в остальном всё нормально! =)


      1. nin-jin
        00.00.0000 00:00
        +8

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


        1. HAGer2000 Автор
          00.00.0000 00:00
          -5

          Я ж так и написал:

          Функциональное программирование неплохо формирует мышление

          )))

          Статья для тех, кто интересуется этим вопросом. Вас никто не заставляет их использовать


    1. funca
      00.00.0000 00:00
      +3

      if( !this.user ) return null

      В теории там что-то типа if( !(isPromise(this.user) ? await this.user : this.user) ) return null , и так для каждой строки. Но в практическом смысле таких неопределенностей лучше конечно избегать, если это возможно.


      1. nin-jin
        00.00.0000 00:00
        +1

        Да нет, именно так как я написал, без монады Promise.


        1. funca
          00.00.0000 00:00
          +1

          Ну вот видите, в вашем примере тоже с пол пинка не разобраться. Какие тогда претензии к коду автора?)


    1. 0xd34df00d
      00.00.0000 00:00
      +4

      Функциональным программированием лучше заниматься в функциональных языках, только и всего.


  1. muturgan
    00.00.0000 00:00
    +1

    Кажется вам должна понравиться rxjs :)


    1. HAGer2000 Автор
      00.00.0000 00:00

      так и есть =) но руки до нее пока не дошли


      1. funca
        00.00.0000 00:00
        +1

        Rxjs не монадический, хотя выглядит похоже. Был проект, который добавляет некоторые алгебраические типы поверх https://gcanti.github.io/fp-ts-rxjs/. Монадический аналог для стримов это https://github.com/cujojs/most, откуда rxjs@5 как мне кажется утянули идею с операторами. Но весь хайп уже давно в прошлом.


        1. HAGer2000 Автор
          00.00.0000 00:00
          +1

          нет ли на примете полноценного проекта, написанного на rxjs?


          1. funca
            00.00.0000 00:00
            +1

            Вот пример для души https://blog.thoughtram.io/rxjs/2017/08/24/taming-snakes-with-reactive-streams.html. А так смотрите любое на Angular :)


    1. kemsky
      00.00.0000 00:00
      +1

      С rxjs ровно те же проблемы, если чуть больше логики попытаетесь на нем делать, императивный код радикально проще.


      1. muturgan
        00.00.0000 00:00
        +1

        Дичайше плюсую вашему комментарию.

        Я всего лишь хотел подметить, что автору статьи он должен понравиться :)


  1. funca
    00.00.0000 00:00
    +3

    метод chain, который принимает на вход метод, возвращающий монаду другого типа (назовем его Monad2).

    Не-а. Метод chain (aka flatMap или bind) должен возвращать ту же самую монаду: m a -> (a -> m b) -> m b -- согласно определению монады. В общем случае разные монады между собой не композятся. Собственно в этом главная их проблема.

    Условно, если вы положили письмо в конверт, а конверт - в почтовый ящик, то уже не сможете достать письмо обратно из ящика без конверта. Ну или вывернуть наизнанку - чтобы письмо оказалось в ящике, а ящик в конверте.

    Для каких-то частных случаев можно придумать такой комбинатор монад, но это если вам повезет. Либо руками создать химеры как EitherAsync.

    https://github.com/fantasyland/fantasy-land#fantasy-landchain-method


    1. HAGer2000 Автор
      00.00.0000 00:00
      +1

      Да, сильно некорректно написал:

      chain<L2, R2>(f: (value: R) => Either<L2, R2>): Either<L | L2, R2>;

      Тип монады тот же самый, но тип значения новый

      Спасибо за замечание!


  1. PYXRU
    00.00.0000 00:00
    +4

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

    Я лично еще не встретил не одного явного плюса в использовании всего этого, кроме пожалуй выучить еще один подход, но для написания чистый функций не обязательно использовать монады(в вашем случае хватит static методов на классах(потому что без typescript это без ошибок не написать) или просто передавать аргументы в функции все зависит от проекта