Function composition is just a fancy term describing the act of passing the return value of one function as an argument to another. We say that functions compose well if the result of one function can be directly passed as an argument to another function.
// Good example
const toNumber = (a: string): number => Number(a);
const double = (a: number): number => a * 2;
const toArray = <A>(a: A): A[] => [a];
export const result = toArray(double(toNumber("21")));
// Bad example
export const badMultiply = (input: number, by: number): number =>
input * by;
export const result2 = toArray(badMultiply(toNumber("21"), 2));
In the first example, the functions compose well because the result of each function matches the expected input of the next function. In the second example, it is no longer enough to just pass down the result of toNumber
because badMultiply
expects two arguments.
In functional programming, we strive to write functions that compose well. In TypeScript, it is not possible to return multiple values. Hence, the rule of thumb is to write functions of arity 1, i.e. functions that take a single argument. However, it is possible to "rewrite" functions so they do compose well using a technique called currying.
Currying is a transformation of functions that translates a function from callable as
f(a, b, c)
into callable asf(a)(b)(c)
export const multiply = (by: number) => (input: number) =>
badMultiply(input, by);
The above function takes a single argument and returns another function that also takes a single argument. For the sake of simplicity, going forward we refer to this as a function with two arguments. The order of arguments matters. The section called pipe provides more details on that.
Another technique worth mentioning is partial application. If we have a function of arity n
and partially apply the first m
arguments, the result is a function of arity n - m
. This is extremely helpful for creating specialized functions that hold a certain state. For example:
// http.ts
type Method = "GET" | "POST" | "PUT" | "DELETE";
type Headers = Record<string, string>;
type Body =
| Blob
| Buffer
| URLSearchParams
| FormData
| string
| null
| undefined;
type Request = {
method: Method;
url: URL;
headers: Headers;
body: Body;
};
// a function of arity 2
export const request =
(method: Method) =>
(url: URL): Request => ({
method,
url: url,
headers: {},
body: undefined,
});
// functions of arity 1
export const get = request("GET");
export const post = request("POST");
export const put = request("PUT");
export const del = request("DELETE");
In the above example, request
is a generic request building function of arity 2. The two arguments it takes are method
and url
. We can partially apply every distinct value of method
to the request
function resulting in four specialized request functions, each of arity 1. Now, whenever we want to build a get
request we have a shortcut to do so:
import { get, request } from "./http.js";
// instead of
export const myGetRequest = request("GET")(new URL("http://google.com"));
// we can just do
export const myGetRequest2 = get(new URL("http://google.com"));
pipe
Code such as toArray(double(toNumber("21")))
reads opposite to the order of execution but can still be considered as fairly readable. Now, imagine our program consisted of 10 or more functions. Code written in such style could fairly quickly become unreadable. fp-ts
provides some tools to make function composition more readable. The example could be re-written using pipe
the following way:
import { pipe } from "fp-ts/lib/function.js";
const toNumber = (a: string): number => Number(a);
const double = (a: number): number => a * 2;
const toArray = <A>(a: A): A[] => [a];
export const result = pipe(
"21",
toNumber,
double,
toArray
);
The above code reads in the same order it is executed. It demonstrates pipe
with functions that all expect a single argument. What about functions that take more arguments?
import { pipe } from "fp-ts/lib/function.js";
const toNumber = (a: string): number => Number(a);
const multiplyBy = (by: number) => (input: number) => input * by;
const toArray = <A>(a: A): A[] => [a];
export const result = pipe(
"21",
toNumber,
multiplyBy(2),
toArray
);
The previous section mentions that the order of arguments in function composition matters. We can see why in the above example. If input
was the first argument, the above code would have to be written as follows:
import { pipe } from "fp-ts/lib/function.js";
const toNumber = (a: string): number => Number(a);
const multiplyBy = (input: number) => (by: number) => input * by;
const toArray = <A>(a: A): A[] => [a];
export const result = pipe(
"21",
toNumber,
multiplyBy,
(by) => by(2),
toArray
);
The above code is not only longer but it also reads bad. This is how much impact the order of arguments can have on writing functional code.
apply
If the order of arguments prevents the function being simply piped into another and we don't want to wrap it, we could use apply
. The previous code example could be rewritten as:
import { apply, pipe } from "fp-ts/lib/function.js";
const toNumber = (a: string): number => Number(a);
const multiplyBy = (input: number) => (by: number) => input * by;
const toArray = <A>(a: A): A[] => [a];
export const result = pipe(
"21",
toNumber,
multiplyBy,
apply(2),
toArray
);
flow
What if we wanted to reuse the code in pipe
and call the function calculate
?
import { pipe } from "fp-ts/lib/function.js";
const toNumber = (a: string): number => Number(a);
const multiplyBy = (by: number) => (input: number) => input * by;
const toArray = <A>(a: A): A[] => [a];
const calculate = (a: string) => pipe(
a,
toNumber,
multiplyBy(2),
toArray
);
export const result = calculate("21");
The code does not read terribly bad but fp-ts
provides another tool to solve the problem of code-reuse more elegantly. We can think of flow
as a reusable pipe
:
import { flow } from "fp-ts/lib/function.js";
const toNumber = (a: string): number => Number(a);
const double = (a: number): number => a * 2;
const toArray = <A>(a: A): A[] => [a];
const calculate = flow(toNumber, double, toArray);
export const result = calculate("21");
pipe
's first argument is always a value and is followed by functions and returns a value. flow
expects only function arguments and returns a function. The type annotation of calculate
from the above example could be written as:
export type Calculate = (_: string) => number[];
Wrap-up
- Function composition is all about passing the result of one function to another as its input.
- In TypeScript, two functions compose well when they accept a single argument and their respective "input" and "output" types match.
- Functions that don't compose well can be transformed into functions that do by currying.
- Partial application allows us to create functions that store state and thus become specialized versions of a generic function that we partially applied the arguments on.
-
pipe
allows us to read composed functions in the order of execution. -
apply
allows us to partially apply an argument to a function or work around suboptimal ordering of function arguments. -
flow
allows us to re-use composed functions more easily.
Next up, we look into the commonly misunderstood concepts of functor and monad.
Top comments (0)