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. 1is piped into the first argument ofadd1andadd1is evaluated to2by adding1.- The return value of
add1,2is piped into the first argumentadd3and is evaluated to5by 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
salaSANA123is passed tominLength1.1 It will be evaluated to aEithervalue that contains a right valuesalaSANA123 -
The return value of
minLength('salaSANA123')value will be piped toE.chain(oneCapital)2.1
E.chainwill 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.chainwill unwrap theE.right('salaSANA123')to'salaSANA123'value and passes it tooneNumber3.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 typeAand 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-tsprovides 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,
}))
);