В предыдущей статье рассматривался пример использования библиотеки 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<number>
из лишнего слоя 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 | null>
, то появляется строка (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<string, string>
и проверяет его значение (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
слишком специфичны, чтобы можно было безболезненно внедрить ее в те проекты, где работаю в команде и код должен быть максимально понятен для любого участника.
Если найду какие-нибудь интересные паттерны, расскажу о них в новой статье.
Top comments (0)