In the previous article we looked at an example of using the ramda
library together with React/Redux
. Here, I want to share my experience using another great library, fp-ts.
These are my first steps in this direction, and I would appreciate any comments and feedback.
Functional programming - programming with containers
If ramda
is more about function composition, fp-ts
is already about working with functors, monads, and so on. These are quite abstract concepts that, for simplicity, I defined as just containers.
The idea is that in the code, we work not with the values of variables as such, but rather place them in containers that store both the value of the variable and additional information about it (context). This approach makes the code more reliable by isolating potentially dangerous value options (such as null or undefined).
Let's look at the basic Option
container, which is responsible for a variable that may or may not exist. It stores context in the _tag field, which takes two values: "None" or "Some". The first value is taken by _tag if the variable of interest is undefined or null. The second value is taken if the variable has some other value (i.e., exists).
example with undefined
:
import * as Option from 'fp-ts/Option';
const x = undefined // variable x is "undfined"
const container = Option.of(x) // put it into Option
// now its value is
// {_tag: "None"}
example with existing value:
import * as Option from 'fp-ts/Option';
const x = 55 // variable x exist
const container = Option.of(x) // put it into Option
// now the value is
// {_tag: "Some", value: 55}
Right now, we don't have direct access to the value of the variable x. It is hidden in the Option container, and in order to access it, we need to use the map function. For example, if we want to get the result of multiplying the received variable by 2:
import { pipe } from 'fp-ts/function';
...
const result = pipe (
container,
Option.map((y) => y * 2)
)
On the output, we will not get the result of multiplication directly, but the result of multiplication hidden in the Option container. The type of the variable result will be as follows:
const result: Option.Option<number>
The map function performs the following steps:
- Accepts a function foo that needs to be applied to the value stored in the container.
- Checks the _tag value of the container.
- If it has a value of "None", it simply returns the container as it is and does nothing.
- If it has a value of "Some", it executes the function foo, passing the value from the value variable of the container as an argument.
- Is the resulting value equal to null/undefined? Returns Option.None, which is {_tag: "None"}
- If the resulting value is not equal to null/undefined, it returns it as Option.Some, which is {_tag: "Some", value: foo(x)}.
Functors and Monads
A container that has a map function is called a functor.
In some cases, the function passed to map can itself return a container, instead of the value directly:
const result = pipe (
container,
Option.map((y) => { return Option.of(y * 5)})
)
If the function passed to map returns a container itself instead of a value, the resulting value will be nested inside another container. Consequently, working with such a structure is not possible.
const result: Option.Option<Option.Option<number>>
To avoid this, you need to extract Option.Option<number>
from the extra layer of Option
. The function that first executes the passed function and then "removes" the extra layer of the container from the resulting value is called flatMap
or chain
(in the case of fp-ts
).
Essentially, this is the same function as map
, after which the value is extracted from the outer container. If a container has such a function in its arsenal, it is already called a monad.
Why does this make sense?
Since we do not interact with the variable's value directly, we do not have to worry about a "missing" variable appearing during code execution, leading to unpredictable results. The Option container and its map function will take care of this for us.
Placing the variable and all calculation results into containers allows us to avoid using constant checks in the code such as
if (x == null) {
throw ...
}
We know that if there is undefined or null anywhere, they will be sealed in an Option container and filtered out by the map method when applied to any function. This continues until the end of the code, where we can check what is in the container: a result (Option.Some) or an error (Option.None).
The probability of forgetting to perform the necessary check in the code is eliminated. The key is not to extract our values from the Option container. We can write code as if no errors are happening. If undefined appears somewhere, such a variable will be ignored by all functions passed to the container via map or chain.
Modern linters successfully help to avoid errors related to missing checks of variable values or computation results. But the strength of fp-ts is that its checks will work during the runtime.
Example with REST API
Here is an example of a backend function for a hypothetical store, where a registered user tries to purchase an item by paying for it using funds stored in their balance.
The logic is as follows:
- Check if itemId is received.
- Check if an item with the specified itemId exists.
- Call a method that checks the request headers and determines whether the user is authorized (for simplicity, it does not take req and simply returns some user data).
- Check if such a user exists in our database.
- Check if the client has enough funds in their account to purchase the item.
- Make the appropriate entries in the database.
Here is the sample code:
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');
};
At each stage, a check is made to ensure that the received value is valid. If you skip the error and allow the appearance of undefined or null values in the program, the result can be unpredictable.
Solution using fp-ts and TaskEither.
To solve this problem with fp-ts, we will need the TaskEither module. It consists of two parts:
Either
TaskEither
is similar to Option
, but instead of returning Option.None
/Option.Some
, it returns Either.Left
and Either.Right
values. Some
and Right
are fundamentally the same - they are just containers for storing data. The Either.Left
variant, unlike None
, can also store a string with an error message.
The most common way to pass a value to Either
is through the method with a self-explanatory name fromNullable
. It takes a string with an error message as the first argument and the second argument is the value of interest, which can be null (nullable).
For example:
E.fromNullable('No data')(565); // { _tag: 'Right', right: 565 }
E.fromNullable('No data')(null) // { _tag: 'Left', left: 'No data' }
Either
is preferred over Option
since it allows us to provide information about the nature of the error.
Task
Task
is a wrapper for asynchronous tasks.
T.of(getItem) // T.Task<(id: number) => Promise<Item | null>>
It is assumed that Task
always completes successfully. In order to handle "bad" results, TaskEither
is used. That is, a Task
that can return both a positive result (Either.Right
) and a negative one (Either.Left
).
Шаг 1. Processing User
This piece of code is a bit more complex than working with Item, so I will explain it right away, and then the code with item will become clear.
The task is to get the user wrapped in an Either
container. That is, either the user exists (Either.Right
), or the user does not exist (Either.Left
with an error message in the string format).
const user: E.Either<string, User> // we need to get
the code:
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>
)();
For convenience, I added the return values as comments after each step.
Let's go through what's happening here step by step:
const user = await pipe(...)()
The pipe
function takes a list of functions that are called sequentially. The result of the first function is passed as an argument to the second function. The result of the second function is passed as an argument to the third function, and so on.
Since we need to get TaskEither
as a result, I immediately called the obtained result (await()
) to get just Either
.
pipe (
getSessionUserId,
TE.fromTask,
...)
We take the getSessionUserId
function and wrap it in a container (TaskEither
) using the TE.fromTask
method. Now we can work with it safely.
pipe (
getSessionUserId,
TE.fromTask,
TE.chain (
flow (
...
)
...
)
...)
Since the result of the getSessionUserId
function is inside a container, we need to unpack it in order to work with it. The methods map
and chain
are responsible for this. Later in the code, the result will be wrapped in another TaskEither
, so we use chain
to avoid double nesting.
Another thing to note is that we use flow
instead of pipe
here. This option is more concise when you need to pass pipe
as an anonymous function. The two options below are equivalent.
flow(foo,...)
(x) => pipe(x, foo...)
The variable passed to flow
has the type undefined
| number
, so we first wrap it in a container:
flow (
TE.fromNullable('Not logged In') // TE.TaskEither<string, number>
...
)
In case the value of getSessionUserId
was undefined, the following TE.chain(...)
code is skipped and only the error message ('Not logged In') is returned.
In case the user was logged in, we use their id value for further work inside the next chain
-> flow
block.
flow(
getUser, // return Promise<User | null>
(user) => () => user,
TE.fromTask, // TE.TaskEither<never, User | null>
TE.chain(TE.fromNullable('Cant find user')),
)),
Here we also call the asynchronous function getItem
, and wrap it in the TaskEither
container. Since it requires the signature () => Promise<User | null>
, the line (user) => () => user appears.
Then, again, we use chain and check the received result, indicating an error message in case the user is not found.
TE.chain(TE.fromNullable('Cant find user'))
Processing Item
The process of getting the requested Item
data is very similar. The only difference is that instead of getSessionUserId
, we simply check whether the itemId
was passed in the request.
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')),
)),
)();
Balance checking
The final step is to check if the user has enough balance to purchase the item. Here, we can't simply use pipe
or flow
since the check involves two variables that are wrapped in Either
containers.
For such cases, we use the Do
notation: we declare which variables we're going to use and give them names, then call the function with these variables using E.chain(...)
. It looks like this:
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)
)
);
The function works quite simply: if the balance is enough, then it returns a "good" E.right
value. If it is not enough, then a "bad" E.left
value is returned with an error message.
The last function E.fold()
takes the E.Either<string, string>
container and checks its value (result). If the value is wrapped in E.left
, then the first function is executed: a response with a string containing an error description and a status of 400
. If the result is positive E.right
, then it returns "OK" with a status of 200
.
Full code
The complete function looks like this:
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;
The code can be split into separate functions, but for the sake of demonstration, this version seemed more illustrative.
Since all the values are wrapped in either TaskEither
or Either
containers, we don't have to worry about unforeseen scenarios.
For example, what would happen if a request came without an itemId
:
At the
TE.fromNullable
('Item ID is missed') stage, since the body.itemId value is undefined, we would get{ _tag: 'Left', left: 'Item ID is missed' }
.Since the value is
Left
, the function enclosed inTE.chain
is skipped.At this stage -
E.bind("_item", () => item)
- we pass the existing value to the variable_item
.When we "unpack" the inner contents of _item in the
E.chain(...)
line, the container again sees that it isE.left
, so it skips it further.At the
E.fold(...)
stage, because the value isE.left
, the first function is executed, passing the error message contained within.
As a result, we get a response from the server saying "Item ID is missed" with a status code of 400
.
Conclusion
Although the fp-ts
library is quite popular and has detailed documentation, it was initially difficult to understand. Many thanks to @souperman for his advice, without which I would not have been able to put together this example.
I will definitely try to make the most of the library in my next project, which I will be creating for myself and writing alone. The fp-ts
syntax and approaches are too specific to be seamlessly integrated into projects where I work in a team, and the code should be as understandable as possible for any participant.
If I find any interesting patterns, I will write about them in a new article.
Top comments (1)
Very good explanation for someone who's is new to fp-ts. The use of examples are spot on and makes the understanding easy.