DEV Community

Attila Večerek
Attila Večerek

Posted on • Updated on

Function composition

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));
Enter fullscreen mode Exit fullscreen mode

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 as f(a)(b)(c)

export const multiply = (by: number) => (input: number) =>
  badMultiply(input, by);
Enter fullscreen mode Exit fullscreen mode

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");
Enter fullscreen mode Exit fullscreen mode

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"));
Enter fullscreen mode Exit fullscreen mode

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
);
Enter fullscreen mode Exit fullscreen mode

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
);
Enter fullscreen mode Exit fullscreen mode

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
);
Enter fullscreen mode Exit fullscreen mode

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
);
Enter fullscreen mode Exit fullscreen mode

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");
Enter fullscreen mode Exit fullscreen mode

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");
Enter fullscreen mode Exit fullscreen mode

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[];
Enter fullscreen mode Exit fullscreen mode

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.

Extra resources

Latest comments (0)