Pull to refresh

Монады на JS/TS в дикой жизни

Level of difficultyMedium
Reading time6 min
Views9.6K

Идея разобраться в теме монад меня привлекала уже очень давно. Сложность описания концепций представляло не только мою личную проблему, но и была потенциальной проблемой для коллег. Ведь хотелось не просто в них разобраться, а работать с ними каждый день. Функциональное программирование неплохо формирует мышление, является очень выразительным и часто лаконичным решением. Ниже идет описание опыта разработки с применением библиотек монад на 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 подхода в рамках привычных инструментов. Ну и если все делать осознанно и аккуратно, получается поддерживаемый и тестируемый код. Ясное дело, что на*****кодить можно любыми способами, в т.ч. и в функциональном стиле. Но как говорится: «Нормально делай, нормально будет».

Tags:
Hubs:
+1
Comments18

Articles

Change theme settings