Функциональное программирование с библиотекой fp-ts

В предыдущей статье рассматривался пример использования библиотеки ramda вместе с React / Redux. Здесь я поделюсь своим опытом в использовании другой замечательной библиотеки fp-ts.

Это мои первые шаги в данном направлении. Буду признателен за любые комментарии и замечания.

Функциональное программирование - программирование с контейнерами

Если ramda - это больше про композицию функций, то fp-ts - это уже про работу с функторам, монадами и т.п. Это довольно абстрактные концепции, которые, для простоты, я определил как просто контейнеры.

Идея заключается в том, что в коде мы работаем не со значениями переменных, как таковыми, но помещаем их в контейнеры, хранящими, как значение переменной, так и набор дополнительной информации о ней (контекст). Такой подход делает код более надежным, изолируя потенциально опасные варианты значений (null или undefined, например).

Рассмотрим базовый контейнер Option, отвечающий за переменную, которая может быть, а может и отсутствовать. Он хранит контекст в поле _tag, принимающем два значения: "None" или "Some". Первое значение _tag принимает, если интересующая нас переменная равна undefined или null. Второе значение, если переменная имеет какое-то иное значение (то есть, существует).

пример с undefined:

import * as Option from 'fp-ts/Option'; const x = undefined // переменная x "отсутствует" const container = Option.of(x) // помещаем ее в контейнер Option // теперь его значение // {_tag: "None"}

пример с существующим значением:

import * as Option from 'fp-ts/Option'; const x = 55 // переменная x имеется const container = Option.of(x) // помещаем ее в контейнер Option // теперь его значение // {_tag: "Some", value: 55}

Сейчас нам не доступно значение переменной x напрямую. Оно спрятано в контейнере Option и, чтобы для него добраться, нужно использовать функцию map. Например, нам нужно получить результат от умножения полученной переменной на 2:

import { pipe } from 'fp-ts/function'; ... const result = pipe ( container, Option.map((y) => y * 2) )

На выходе мы получим не результат умножения, но результат умножения, спрятанный в контейнере Option. Тип переменной result следующий:

const result: Option.Option<number>

Порядок действий у функции map следующий:

  • Принимает функцию foo, которую нужно применить к хранящемуся в контейнере значению
  • Смотрит на значение _tag контейнера
  • Если он имеет значение "None", то просто возвращает это контейнер обратно и ничего не делает.
  • Если он имеет значение "Some", то выполняет функцию foo, передавая туда в качестве аргумента значение из переменной value контейнера.
  • Полученный результат равен null / undefined? Возвращает Option.None, то есть {_tag: "None"}
  • Полученный результат не равен null / undefined? Возвращает его в виде Option.Some, то есть {_tag: "Some", value: foo(x)}.

Функторы и монады

Контейнер, имеющий подобную функцию map, называется функтором.

В некоторых случаях, функция, передаваемая в map, может сама возвращать контейнер, а не полученное значение напрямую:

const result = pipe ( container, Option.map((y) => { return Option.of(y * 5)}) )

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

const result: Option.Option<Option.Option<number>>

Чтобы этого избежать, надо извлечь Option.Optionиз лишнего слоя Option. Функция, которая сначала выполняет переданную функцию, а потом "снимает" с полученного результата "лишний слой" контейнера, называется flatMap или chain (в случае с fp-ts).

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

Что это дает

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

Помещение переменной и всех результатов вычислений в контейнеры позволяет избежать применения в коде постоянных проверок вида

if (x == null) { throw ... }

Мы знаем, что если где-то и возникнет undefined или null, они будут запечатаны в контейнер Option и при применении к ним любой функции они будут просто отсеяны методом map и переданы далее. И так до самого конца кода, где можно будет уже проверить, что лежит в контейнере: некоторый результат (Option.Some) или ошибка (Option.None).

Вероятность того, что где-то в коде мы забудем произвести нужную проверку, при этом исключается. Главное, не извлекать наши значения из контейнера Option. Мы можем писать код так, будто никаких ошибок не происходит. Если же где-то возникнет undefined, то такая переменная будет проигнорирована всеми функциями, передаваемыми в контейнер через map или chain.

Современные линтеры вполне успешно помогают избежать ошибок, связанных с тем, что где-то будет пропущена нужная проверка значения переменной или результатов вычислений. Но сила fp-tsзаключается в том, что его проверки будут работать и в процессе выполнения кода.

Пример с REST API

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

Логика примерно следующая:

  • Проверяем, получен ли itemId
  • Проверяем, существует ли товар с указанным itemId
  • Вызываем метод, проверяющий заголовки запроса и определяющий, авторизован ли пользователь (для простоты он не принимает req, просто выдает какие-то данные о пользователе)
  • Проверяем, существует ли такой пользователь в нашей БД
  • Проверяем, хватает ли денег на счете клиента для покупки товара
  • Производим соответствующие записи в БД

Пример кода:

const handler = async (req: CustomNextApiRequest, res: NextApiResponse<ResponseType>) => { const body = makeSerializable(req.body); const method = makeSerializable(req.method); if (method !== 'POST') { res.status(405).send('Wrong method'); return; } if (!body.itemId) { res.status(400).send('Missed data'); return; } const sessionUserId = await getSessionUserId(); if (!sessionUserId) { res.status(403).send('No signed in user'); return; } const user = await getUser(sessionUserId); if (!user) { res.status(400).send('Cant find user'); return; } const item = await getItem(body.itemId); if (!item) { res.status(400).send('Cant find item'); return; } if (!isBalanceSufficient(item, user)) { res.status(400).send('Balance is not sufficient'); return; } /** some db actions */ res.status(200).send('OK'); };

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

Решение с помощью fp-ts и TaskEither

Для решения этой задачи с помощью fp-ts нам понадобиться модуль TaskEither. Он состоит из двух частей:

Either

Аналогична Option, но в отличие от последней возвращает не Option.None / Option.Some, а значения Either.Left и Either.Right. Some и Right принципиально ничем не отличаются - это просто контейнеры для хранения данных. Вариант с Either.Left, в отличие от None, может хранить еще и строку с текстом ошибки.

Чаще всего значение передается в Either через метод с говорящим именем fromNullable. Он принимает первым аргументом строку с текстом ошибки и вторым аргументом интересующее нас значение, которые может быть нулевым (nullable)

Например

E.fromNullable('No data')(565); // { _tag: 'Right', right: 565 } E.fromNullable('No data')(null) // { _tag: 'Left', left: 'No data' }

Either предпочтительнее Option, так как позволяет нам выводить информацию о природе ошибки.

Task

Task - это обертка для асинхронных задач.

T.of(getItem) // T.Task<(id: number) => Promise<Item | null>>

Предполагается, что Task всегда выполняется успешно. Для того, чтобы обрабатывать "плохие" результаты, используется TaskEither. То есть Task, который может возвращать как положительный результата Either.Right, так и отрицательный - Either.Left.

Шаг 1. Обработка User

Этот кусок кода немного сложнее, чем работа с Item, поэтому сразу разберу его, и тогда код с item станет сразу понятен.

Задача - получить пользователя, завернутого в контейнер Either. То есть, либо пользователь есть (Either.Right), либо пользователя нет (Either.Left с указанием ошибки в формате string).

const user: E.Either<string, User> // нам нужно получить это

Код выглядит следующим образом:

import * as TE from 'fp-ts/TaskEither'; const user = await pipe( getSessionUserId, // () => Promise<number | null> TE.fromTask, // TE.TaskEither<never, number | null> TE.chain( flow( // get number | null TE.fromNullable('Not logged In'), // TE.TaskEither<string, number> TE.chain( flow( // get usereId getUser, // return Promise<User | null> (user) => () => user, TE.fromTask, // TE.TaskEither<never, User | null> TE.chain(TE.fromNullable('Cant find user')), // TE.TaskEither<string, User> )), ), ), // TE.TaskEither<string, User> )();

Для удобства, я добавил в комментариях возвращаемые значения после каждого этапа.

Разберем по шагам, что здесь происходит:

const user = await pipe(...)()

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

Так как на выходе мы должны получить TaskEither, то я сразу вызывал полученный результат (await ()), чтобы получить просто Either.

pipe ( getSessionUserId, TE.fromTask, ...)

Берем функцию getSessionUserId и помещаем его в контейнер (TaskEither) методом TE.fromTask. Теперь можно безопасно с ним работать.

pipe ( getSessionUserId, TE.fromTask, TE.chain ( flow ( ... ) ... ) ...)

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

Второе замечание, здесь мы используем flow вместо pipe. Этот вариант оказывается лаконичнее, когда необходимо передать pipeкак анонимную функцию. Два варианта ниже являются аналогичными.

flow(foo,...) (x) => pipe(x, foo...)

Переменная, передаваемая в flow имеет значение undefined | number, поэтому первым делом оборачиваем ее в контейнер:

flow ( TE.fromNullable('Not logged In') // TE.TaskEither<string, number> ... )

Если значение getSessionUserId было undefined, то следующий ниже код TE.chain(...) пропускается и далее передается лишь сообщение о ошибке ('Not logged In').

В случае, пользователь был авторизован, то мы используем значение его id для дальнейшей работы внутри очередного блока chain -> flow.

flow( getUser, // return Promise<User | null> (user) => () => user, TE.fromTask, // TE.TaskEither<never, User | null> TE.chain(TE.fromNullable('Cant find user')), )),

Здесь мы также вызываем асинхронную функцию getItem, и переводим ее в контейнер TaskEither. Так как для этого нужна сигнатура () => Promise, то появляется строка (user) => () => user.

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

TE.chain(TE.fromNullable('Cant find user'))

Работа с item

Получение данных о запрашиваемом Item очень похоже. Только вместо getSessionUserId мы просто проверяем, передавался ли itemId в запросе.

const item = await pipe( body.itemId, TE.fromNullable('Item ID is missed'), // TE.TaskEither<string, number> TE.chain( // pass itemId as 35 to flow flow( getItem, // Promise<Item | null> (item) => () => item, // () => Promise<Item | null> TE.fromTask, // TE.TaskEither<never, Item | null> TE.chain(TE.fromNullable('Cant find item')), )), )();

Проверка баланса

Остался последний шаг, проверить, хватает ли денег на балансе пользователя для покупки данного предмета. Здесь мы не можем обойтись обычным pipe или flow, так как в проверке участвуют две переменные, помещенные в контейнер Either.

Для таких случаем используется запись Do: мы обозначаем, какие переменные будем использовать и как их назовем, потом вызываем функцию с этими переменными E.chain(...). Выглядит так:

pipe( E.Do, E.bind("_item", () => item), E.bind("_user", () => user), E.chain(({ _user, _item }) => isBalanceSufficient(_item, _user) ? E.left('Balance is not sufficient') : E.right('OK') ), E.fold( (result) => res.status(400).send(result), (result) => res.status(200).send(result) ) );

Работа функции довольно простая: если баланса достаточно, то возвращаем "хорошее" значение E.right. Если не достаточно - то "плохое" E.left с указанием ошибки.

Последняя функция E.fold() принимает контейнер E.Either и проверяет его значение (result). Если значение завернуто в E.left, то выполняется первая функция: ответ со строкой, содержащей описание ошибки, и статусом 400. Если результат положительный E.right, то возвращаем "ОК" со статусом 200.

Код полностью

Полностью функция выглядит так:

const handler = async (req: CustomNextApiRequest, res: NextApiResponse<ResponseType>) => { const body = makeSerializable(req.body); const method = makeSerializable(req.method); if (method !== 'POST') { res.status(405).send('Wrong method'); return; } const item = await pipe( body.itemId, TE.fromNullable('Item ID is missed'), // TE.TaskEither<string, number> TE.chain( // pass itemId as 35 to flow flow( getItem, // Promise<Item | null> (item) => () => item, // () => Promise<Item | null> TE.fromTask, // TE.TaskEither<never, Item | null> TE.chain(TE.fromNullable('Cant find item')), )), )(); const user = await pipe( getSessionUserId, // () => Promise<number | null> TE.fromTask, // TE.TaskEither<never, number | null> TE.chain( flow( // get number | null TE.fromNullable('Not logged In'), // TE.TaskEither<string, number> TE.chain( flow( // get usereId getUser, // return Promise<User | null> (user) => () => user, TE.fromTask, // TE.TaskEither<never, User | null> TE.chain(TE.fromNullable('Cant find user')), // TE.TaskEither<string, User> )), ), ), // TE.TaskEither<string, User> )(); pipe( E.Do, E.bind("_item", () => item), E.bind("_user", () => user), E.chain(({ _user, _item }) => isBalanceSufficient(_item, _user) ? E.left('Balance is not sufficient') : E.right('OK') ), x => x, E.fold( (result) => res.status(400).send(result), (result) => res.status(200).send(result) ) ); return; }; export default handler;

Код можно разбить на отдельные функции. Но для демонстрации мне такой вариант показался нагляднее.

Так как у нас все значение помещены в контейнеры TaskEither или Either, то мы можем не опасаться непредвиденных сценариев.

Например, что произойдет, если прийдет запрос без itemId:

  • На этапе TE.fromNullable('Item ID is missed'), так как значение body.itemId = undefined, мы получим { _tag: 'Left', left: 'Item ID is missed' }
  • Так как значение Left, то выполнение функции, заключенной в TE.chain, пропускается.
  • На этом этапе - E.bind("_item", () => item), - мы передаем имеющееся значение в переменную _item
  • При "распаковке" внутреннего содержания _item в строке E.chain(...) контейнер, опять таки, видит, что это E.left, поэтому пропускает ее дальше.
  • На этапе E.fold(...) из-за того, что значение E.left, выполняется первая функция, передающая содержащееся внутри сообщение об ошибке.

На выходе мы получаем ответ от сервера "Item ID is missed" с кодом 400.

Заключение

Несмотря на то, что библиотека fp-ts является довольно популярной и имеет подробную документацию, разобраться с ней сразу оказалось сложно. Большая благодарность @souperman без советов которого я бы не смог составить и этот пример.

Обязательно попробую максимальное использование библиотеки в следующем проекте, который буду создавать под себя и писать один. Синтаксис и подходы fp-ts слишком специфичны, чтобы можно было безболезненно внедрить ее в те проекты, где работаю в команде и код должен быть максимально понятен для любого участника.

Если найду какие-нибудь интересные паттерны, расскажу о них в новой статье.

11
Начать дискуссию