fp-ts for the impatient
Introduction
This post is an express tour for impatients who want to use fp-ts. In this brief introduction, we don’t go through the what is functional programming as well as its advantages/disadvantages.
You don’t really need to understand all mathematics concepts in order to learn functional programming. IMO, you only need to to know how each operator works. Once you get to know each basic operators in fp-ts, you can go back and review the mathematic theory.
Practical guide to fp-ts
Pipe, flow
Pipe
In fp-ts pipe is a function, but in pure functional programming language (like Haskell) it’s an operator. Javascript also has a proposal for it (see Pipe Operator (|>) for JavaScript
)
It’s the basic building block of fp-ts, you can use pipe() to chain the sequence of functions from left to right
Let’s look at this simple example:
import { pipe } from "fp-ts/lib/function";
const add =
(first: number) =>
(second: number): number => {
return first + second;
};
const add1 = add(1);
const add3 = add(3);
pipe(1, add1, add3); // 5
// This is equivalent to this
add3(add1(1));
The result of this operation is 5
. It’s self-explanatory but we can look at these steps:
- We start with the value of
1
. 1
is piped into the first argument ofadd1
andadd1
is evaluated to2
by adding1
.- The return value of
add1
,2
is piped into the first argumentadd3
and is evaluated to5
by adding3
.
At this point, the pipe receives a number as input and output a new number, but we can also do something else like input a string from a number also.
const meowify = (num: number): string => {
return "meow ".repeat(num).trim();
};
pipe(1, add1, add3, meowify); // 'meow meow meow meow meow'
Notes that we cannot put the meowify function in between add1 and add3 function like this
❌ pipe(1, add1, meowify, add3)
Flow
The flow operator is very similar to pipe operator, the difference is the first argument of flow must be a function. For example, we can use the three functions above to form a flow like this:
import { flow, pipe } from "fp-ts/lib/function";
flow(add1, add3, meowify)(1); // this is equivalent to pipe(1, add1, add3, meowify)
// Or we can use it like this
pipe(1, flow(add1, add3, meowify)); // 'meow meow meow meow meow'
In the example with pipe
what if we don’t want to feed 1
as the input to the pipe? We probably have to do this:
const meowify1 = (n: number) => pipe(n, flow(add1, add3, meowify)
// but with flow you don't need to
const meowify2 = flow(add1, add3, meowify)
Tip: If you have a long curried functions, you can use ap
from Identity monad to apply all arguments
import { ap } from "fp-ts/lib/Identity";
const makeUrl = (protocol: string) => (domain: string) => (port: number) => {
return `${protocol}://${domain}:${port}`;
};
// ✅ right
pipe(makeUrl, ap("https"), ap("swappie.com"), ap(80)); // https://swappie.com:80
// Equivalent to
makeUrl("https")("swappie.com")(80);
// ❌ this doesn't work
pipe("https://", "swappie.com", 80, makeUrl);
Option, Either
Option
Options are containers, or specifically an Option is a monad (it’s analogous to Maybe monad in Haskell), that wrap values that could be truthy
or falsy
. If the values are truthy
, we say the Option
is of Some
type, and if the values are falsy
(undefined | null
) we say it has the None
type.
type Option<A> = None | Some<A>;
“Why should we use Option types in the first place?” You might ask. We already know that Typescript already has good ways to deal with undefined
or null
values. For example, we can use optional chaining or nullish coalescing.
The anwser is: mostly you won’t need to use Option, optional chaining can do it as well for you. However the Option type is more than just checking for null. Options can be used to represent failing operations, and most importantly you can chain them or in other words you can compose functions that return Option
into a more complex one.
In most cases you probably don’t need Option, but let see these example to see some benefits of Option monad
const findUrl = (array: string[]): string | undefined =>
array.find((item) => item.startsWith("http"));
const makeA = (url: string | undefined): string =>
url ? `<a href=${url}>${url}</a>` : "no link";
const parseLink = (array: string[]): string => makeA(findUrl(array));
// execute
const input = ["[", "google", "]", "(", "http://www.google.com", ")"];
console.log(parseLink(input)); // <a href=http://www.google.com>http://www.google.com</a>
console.log(parseLink([])); // no link
The code above can be converted to FP style
import * as O from "fp-ts/lib/Option";
// O.fromNullable convert a non-nullable value to Some(value) and nullable
// to None
const findUrl = (array: string[]): O.Option<string> =>
O.fromNullable(array.find((item) => item.startsWith("http")));
const makeA = (url: string): string => `<a href=${url}>${url}</a>`;
const parseLink = flow(
findUrl,
O.fold(() => "no link", makeA)
);
parseLink(input); // <a href=http://www.google.com>http://www.google.com</a>
parseLink([]); // no link
💡 Notes that you can lift a nullable
value to an Option
using O.fromNullable
Either
An Either is a type that represents a synchronous operation that can succeed or fail. Much like Option, where it is Some
or None
, the Either type is either Right
or Left
. Right
represents success and Left
represents failure. It is analogous to the Result type in Rust.
This is one practical example of using fp-ts, specially Either
for validating a password strength. For each individual functions below, I think they pretty self-explanatory excepts the last one validatePassword
import * as E from "fp-ts/Either";
import * as F from "fp-ts/function";
const minxLength = (s: string): E.Either<Error, string> => {
return s.length < 8 ? E.left(new Error("Password is too short")) : E.right(s);
};
const oneCapital = (s: string): E.Either<Error, string> =>
/[A-Z]/g.test(s)
? E.right(s)
: E.left(new Error("at least one capital letter"));
const oneNumber = (s: string): E.Either<Error, string> =>
/[0-9]/g.test(s) ? E.right(s) : E.left(new Error("at least one number"));
// This also works
// F.pipe(minLength(s), E.chain(oneCapital), E.chain(oneNumber));
const validatePassword = (s: string): E.Either<Error, string> =>
F.pipe(s, minLength, E.chain(oneCapital), E.chain(oneNumber));
// validatePassword('123456'); // Error: at least one capital letter
// validatePassword('salaSANA123'); // salaSANA123
We can looks at the break-down steps of validatePassword
as follow:
Let’s take this happy path example validatePassword('salaSANA123')
- We will start with input
salaSANA123
is passed tominLength
1.1 It will be evaluated to aEither
value that contains a right valuesalaSANA123
-
The return value of
minLength('salaSANA123')
value will be piped toE.chain(oneCapital)
2.1
E.chain
will unwrap theE.right('salaSANA123')
to'salaSANA123'
value and passes it tooneCapital
.2.2
oneCapital('salaSANA123')
will evaluate the string and returnsE.right('salaSANA123')
. -
Again, the return value of
oneCapital('salaSANA123')
will be piped toE.chain(oneNumber)
3.1E.chain
will unwrap theE.right('salaSANA123')
to'salaSANA123'
value and passes it tooneNumber
3.2
oneNumber('salaSANA123')
will evaluate the string and returnsE.right('salaSANA123')
In any situation that one of the three function returns E.left(new Error('...'))
the left
value is returned immediately
💡 And just like how you can lift nullable
into an Option, you can also lift an Option
into another fp-ts container, like Either
.
const minLength = (s: string): O.Option<string> =>
s.length >= 6 ? O.some(s) : O.none;
...
const validatePassword = (s: string): Either<Error, string> =>
pipe(
minLength(s),
E.fromOption(() => new Error("at least 6 characters")), //
chain(oneCapital),
chain(oneNumber)
);
Task, TaskEither
Task
In fp-ts
, a Task
is basically a js Promise
, this is the definition of Task
.
interface Task<A> {
(): Promise<A>;
}
From the docs
Task<A>
represents an asynchronous computation that yields a value of typeA
and never fails. If you want to represent an asynchronous computation that may fail, please seeTaskEither
.
TaskEither
Basically TaskEither
= Task
+ Either
, so with TaskEither
you can have a Task
that may fail.
import axios, { AxiosResponse } from "axios";
import * as F from "fp-ts/function";
import * as E from "fp-ts/Either";
import * as T from "fp-ts/Task";
import * as TE from "fp-ts/TaskEither";
type ToDo = {
userId: number;
id: number;
title: string;
completed: boolean;
};
const safeGet = (url: string): TE.TaskEither<Error, AxiosResponse> =>
TE.tryCatch(() => axios.get(url), E.toError);
const fetchTodo = (id: number): TE.TaskEither<string, ToDo> =>
F.pipe(
safeGet(`https://jsonplaceholder.typicode.com/todos/${id}`),
TE.fold(
(e: Error) => T.of(e.message),
(a: AxiosResponse) => T.of(a.data)
)
);
const main = async () => {
const resp = await fetchTodo(1)();
// { userId: 1, id: 1, title: 'delectus aut autem', completed: false }
console.log(resp);
const resp1 = await fetchTodo(3)();
// { userId: 1, id: 3, title: 'fugiat veniam minus', completed: false }
console.log(resp1);
const resp2 = await fetchTodo(0)();
// Request failed with status code 404
console.log(resp2);
};
main();
The TE.fold
function is actually very simple, it accepts two functions (onLeft
, onRight
) and it will call onLeft
on left
value and onRight
on right
value, depends on the previous value from the pipe.
Do Notation
From the docs
Both Haskell and PureScript languages provide syntactic sugar for working with monads in the form of do notation.
fp-ts
provides it’s own implementation of do notation which can help to simplify effectful code.
You can read about the “official” explanation and example from fp-ts (see this) about do notation in fp-ts
Generally speaking, “do notation” allows you bind previous returned values from other functions in the pipe to a context object. Without do notation it’s very hard to maintain the variable scope, since you either to pass it along as intermediate result or go deep into nested pipe.
import * as TE from "fp-ts/TaskEither";
import { pipe } from "fp-ts/function";
const createUser = (username: string): TE.TaskEither<Error, string> => {
return TE.right(`UserId-${username}`);
};
const createOrder = (userId: string): TE.TaskEither<Error, string> => {
return TE.right(`Order-${userId}`);
};
const createOrderRow = (
orderId: string,
userId: string
): TE.TaskEither<Error, string> => {
return TE.right(`OrderRowFor-${orderId}-${userId}`);
};
// This will return something like
// {
// _tag: 'Right',
// right: {
// userId: 'UserIdRick',
// orderId: 'Order123456-UserIdRick',
// orderRowId: 'OrderRowFor-UserIdRick-Order123456-UserIdRick'
// }
// }
const main = pipe(
TE.Do,
TE.bind("userId", () => createUser("Rick")),
TE.bind("orderId", ({ userId }) => createOrder(userId)),
TE.bind("orderRowId", ({ userId, orderId }) =>
createOrderRow(userId, orderId)
),
TE.map(({ userId, orderId, orderRowId }) => ({
userId,
orderId,
orderRowId,
}))
);