Skip to content

Functional Programming in TypeScript using the fp-ts library: Pipe and Flow Operator

Functional programming (FP) is an approach to software development that uses pure functions to help developers in trying to create maintainable and easy-to-test software. FP is notable for its ability to efficiently parallelize pure functions. This approach should lead to code that is easier to reason about and test, and can often result in more concise and maintainable code.

What you will find in this post

Reading about “pure functions” or “parallelizing” things could be scary. Everything seems very “mathematical” and “theoretical”, but don’t be afraid: the goal of this post is to try to explain in a really practical way (theory and math will pop out only when really necessary) how to bring these core concepts and approach of FP in your TS project using Giulio Canti’s library fp-ts.

Pure and Impure functions

Pure functions always produce the same output for the same input, regardless of the context in which it is called.

Calling this type of function also has no side effects, meaning it does not modify any external state or interact with the outside world in any way. Here is an example of such pure function:

function doubleNumbers(numbers: number[]): number[] {
	return numbers.map((n) => n * 2);
}

This function takes an array of numbers, doubles each number in the array, and returns a new array with the doubled numbers. Here are some characteristics of this function that make it pure:

  1. No side effects: This function doesn't modify any external state outside of its own scope. It only operates on the input array, and returns a new array with the transformed values.

  2. Deterministic: Given the same input, this function will always return the same output. There are no random or unpredictable behaviors.

  3. No dependencies: This function doesn't rely on any external state or dependencies. It only depends on the input that's passed to it.

Because of these characteristics, this function is considered pure. It's predictable, testable, and doesn't introduce any unexpected behavior.

A side effect occurs in a program when you insert external code into your function. This prevents the function from “purely” performing its task.

An impure function in TypeScript is a function that, when called with the same arguments, may produce different results each time it is called. This is because an impure function can depend on mutable state or external factors, such as the current time, user input, or global variables, which can change between calls.

Here's an example of an impure function in TypeScript:

let counter = 0;

function incrementCounter(value: number): number {
	counter += value;
	return counter;		
}

In this example, the incrementCounter function modifies the global counter variable every time it is called. Since the function's return value depends on the current value of counter, calling the function with the same argument multiple times may produce different results.

For example, if we call the function like this:

incrementCounter(1); // returns 1

incrementCounter(1); // returns 2

incrementCounter(1); // returns 3  

The function's return value changes each time we call it because the value of counter is being modified by each call. This makes incrementCounter an impure function.

Note that impure functions can make it harder to reason about code since their behavior can be unpredictable. In general, it's best to minimize the use of impure functions and rely on pure functions, which have no side effects and always produce the same output for a given input.

Actions, Calculations, and Data - Thinking like a functional programmer

Let’s try to focus one moment on side effects.

The definition says FP avoids side effects, but side effects are the very reason we run our software. The definition seems to imply that we must try to completely avoid them when in reality, we use side effects when we have to. Functional programmers know side effects are necessary yet problematic, so they have a lot of tools for working with them.

A functional programmer first tries to classify the code into 3 general categories:

  • Actions

  • Calculations

  • Data

Actions depends on when they are called or how many times they are called.

Calculations and Data instead doesn’t depend on when or how many times they are called, the difference is only that calculations can be executed, data cannot; you don’t really know what calculation will do until you run it.

Functional programmers prefer data to calculations and calculations to actions and they try to build code by applying and composing as many “calculations” as possible in order to work with "solid" data (but also trying to “control” actions too).

Fp-Ts

There are many famous functional programming languages such as Haskell, Scala, Elm, Erlang, Elixir etc.

Fp-Ts provides developers with popular patterns and reliable abstractions from typed functional languages in TypeScript.

In this post, we will focus on the first two building blocks of fp-ts: Pipe and Flow operators.

Pipe Operator

The pipe operator is a built-in function in fp-ts that allows you to compose functions from left to right. It takes a value as its first argument and any number of functions as subsequent arguments (that accept only one argument of the same type of the returning type of the preceding function). The output of each function becomes the input of the next function in the pipeline, and the final result is returned.

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

const result = pipe(
	1,
	increment,
	double,
	decrement
);

function increment(n: number): number {
	return n + 1;
}  

function double(n: number): number {
	return n * 2;
}

function decrement(n: number): number {
	return n - 1;
}

In this example, we start with the number. Then increment it, double it, and finally decrement it. The pipe operator allows us to chain these functions together in a readable and concise way.

Why use the pipe operator?

The pipe operator is useful for a few reasons:

  1. It makes code more readable. By chaining functions together with the pipe operator, it is easy to see the flow of data through the functions.

  2. It makes code more concise. Without the pipe operator, we would have to nest function calls, which can be hard to read and understand.

  3. It makes code more modular. By breaking down a problem into smaller, composable functions, we can write more reusable code.

Flow Operator

We could say that the pipe operator is our basic brick to build our ecosystem, flow is really similar but it allows you to define a sequence of functions separately and then apply them to a value later.

The flow operator is also a built-in function in fp-ts, it takes any number of functions as arguments and returns a new function that applies each function in order.

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

const addOne = (x: number) => x + 1;

const double = (x: number) => x * 2;

const addOneAndDouble = flow(addOne, double);

console.log(addOneAndDouble(2)); // Output: 6

In the example above, we create two simple functions addOne and double that takes a number and return a number. We then use the flow function from fp-ts to compose the two functions. The flow function takes any number of functions as arguments and returns a new function that applies each function in sequence from left to right.

By using the Flow operator, we don't need to explicitly define the type of the addOne and double functions' parameters. TypeScript can infer their types based on how they are used in the function composition. This makes the code shorter and more readable while still ensuring type safety.

Pros of using the Flow operator in fp-ts:

  1. Type inference: The Flow operator allows TypeScript to infer the types of function parameters, making the code shorter and more readable while still ensuring type safety.

  2. Composability: The Flow function provided by fp-ts makes it easy to compose multiple functions into a single function.

Cons of using the Flow operator in fp-ts:

  1. Learning curve: If you are new to functional programming, the Flow operator can be challenging to understand and use correctly.

  2. Debugging: Since the Flow operator relies on type inference, it can be challenging to debug issues related to type mismatches or incorrect function composition.

Differences between pipe and flow

The key difference between pipe and flow is in how they handle type inference. Pipe is better at inferring types, as it can use the output type of one function as the input type of the next function in the pipeline. Flow, on the other hand, requires explicit type annotations to be added to the composed functions.

When to use Pipe or Flow

Both pipe and flow are useful tools for composing functions and creating pipelines in a functional and declarative way. However, there are some situations where one might be more appropriate than the other.

Pipe is a good choice when you need to perform a linear sequence of transformations on a value in left-to-right order.

Flow is a good choice when you need to create a reusable function that encapsulates a series of transformations.

Conclusion

Fp-ts is a powerful library that provides many tools and abstractions for functional programming in TypeScript. The pipe and flow operators are just two of the many features that it offers. In this post we started to explore the building blocks of this library, learning their powers and their main differences. If you're interested in learning more about functional programming, explore some of these other concepts and check out the fp-ts documentation for more information, or wait for the next blog post here on This Dot Blog!