Ryan Lee
Ryan's Blog

Ryan's Blog

Practical Guide to Fp-ts P1: Pipe and Flow

Practical Guide to Fp-ts P1: Pipe and Flow

Learn how to use Fp-ts practically. Introduces the pipe and flow operators. No mathematical knowledge required.

Ryan Lee's photo
Ryan Lee

Published on Jun 13, 2020

4 min read

Introduction

This post is an introduction to fp-ts, a functional programming library for Typescript. Why should you be learning fp-ts? The first reason is better type safety. Fp‑ts allows you to make assertions about your data structures without writing user-defined type guards or using the as operator. The second reason is expressiveness and readability. Fp-ts gives you the tools necessary to elegantly model a sequence of operations that can fail. All in all, you should add fp-ts to your repertoire of tools because it will help you write better Typescript programs.

Contrary to popular opinion, you don’t need to understand complex mathematics to learn functional programming. In truth, you just need to get a feel for how each operator works. Once you get a handle on the basic operators, you can go back and review the mathematics. With this in mind, this post and the ones that follow, I will introduce functional programming from a practical perspective, avoiding mathematical jargon unless necessary.

The Pipe Operator

The basic building block of fp-ts is the Pipe operator. Intuitively, you can use the operator to chain a sequence of functions from left-to-right. The type definition of pipe takes an arbitrary number of arguments. The first argument can be any arbitrary value and subsequent arguments must be functions of arity one. The return type of a preceding function in the pipeline must match the input type of the subsequent function.

Let's look at how to pipe simple addition and multiplication functions.

import { pipe } from 'fp-ts/lib/function'

function add1(num: number): number {
  return num + 1
}

function multiply2(num: number): number {
  return num * 2
}

pipe(1, add1, multiply2) // 4

The result of this operation is 4. How did we arrive at this result? Let’s look at the steps.

  1. We start with the value of 1.
  2. 1 is piped into the first argument of add1 and add1 is evaluated to 2 by adding 1.
  3. The return value of add1, 2 is piped into the first argument multiply2 and is evaluated to 4 by multiplying by 2.

Currently our pipeline inputs a number and outputs a new number. Is it possible to transform the input type to another type, like a string? The answer is yes. Let’s add a toString function at the end of the pipeline.

function toString(num: number): string {
  return `${num}`
}

pipe(1, add1, multiply2, toString) // '4'

Now our pipeline evaluates to ’4’. What happens if we were to put toString between add1 and multiply2? We get a compile error because the output type of toString, string does not match the input type of multiply2, number.

pipe(1, add1, toString, multiply2)

Argument of type '(num: number) => string' is not assignable to parameter of type '(b: number) => number'. Type 'string' is not assignable to type 'number'.ts (2345)

In short, you can use the pipe operator to transform any value using a sequence of functions. The flow of control can be modelled as follows. [^1]

A -> (A->B) -> (B->C) -> (C->D)

The Flow Operator

The flow operator is almost analogous to the pipe operator. The difference being the first argument must be a function, rather than any arbitrary value, say a number. The first function is also allowed to have an arity of more than one.

For example, we could wrap our three functions inside of the flow operator.

import { flow } from 'fp-ts/lib/function'

pipe(1, flow(add1, multiply2, toString))
flow(add1, multiply2, toString)(1) // this is equivalent

In comparison, to the pipe operator, this is what the "flow" of control looks like for the flow operator. [^2]

(A->B) -> (B->C) -> (C->D) -> (D->E)

What is a good use case for the flow operator? When should you use it over the pipe operator? A general rule of thumb is when you want to avoid using an anonymous function. In Typescript, a good example of an anonymous function are callbacks.

Lets declare a function, concat with arity of 2. The first argument will be a number. The second argument is a callback that takes a number as its first argument and transforms it into a string. The function returns the first value and the transformed second value as a two dimensional tuple.

function concat(
  a: number,
  transformer: (a: number) => string,
): [number, string] {
  return [a, transformer(a)]
}

Using our previous repertoire of functions, we can compose a callback for this function. Here is an example using the pipe operator.

concat(1, (n) => pipe(n, add1, multiply2, toString)) // [1, '4']

What’s the problem with this? The problem is we have to declare n as part of an anonymous function to use it with the pipe operator. You should avoid this because it puts you at risk of shadowing a variable in the outer scope. It is also more verbose.

The solution is to use the flow operator and remove the anonymous function. This works because the return value of flow is a function itself. The signature of this function is number -> string which is exactly the same as the callback signature.

concat(1, flow(add1, multiply2, toString)) // [1, '4']

Appendix

 
Share this
Proudly part of