Dataslope logoDataslope

Function Composition

Plumbing small pure functions into pipelines — the operational heart of declarative code, plus currying and partial application.

If pure functions are the atoms of FP, composition is the chemistry: the operation that takes two (or many) functions and glues them into one. Once your functions are pure, you can build entire programs by composing them — no shared state, no intermediate variables, no plumbing.

This chapter covers function composition, the closely related techniques of currying and partial application, and the two helper functions (compose and pipe) you'll use everywhere.

What composition means, mathematically

In math, given f: A → B and g: B → C, the composition g ∘ f is the function A → C defined by (g ∘ f)(x) = g(f(x)). You apply f first, then feed the result into g.

The two diagrams above describe the same function. Composition is the operation that lets us treat "do f then g" as a single named thing.

Composition in TypeScript

We have to write it ourselves. Here's the simplest possible binary composer:

Code Block
TypeScript 5.7

Two things to notice:

  1. The argument order in compose2(g, f) reads "right to left": g(f(x)). This is the mathematical convention.
  2. Composition is not commutative: compose2(g, f) is different from compose2(f, g). Order matters.

pipe: the left-to-right cousin

In practice, most TypeScript code reads better when the data flows forward: input on the left, transformations on the right. That's what pipe does:

Code Block
TypeScript 5.7

pipe(value, f, g, h) evaluates as h(g(f(value))). It is exactly compose reversed. We will use pipe throughout the course because it reads top-to-bottom, like the data flow it represents.

Variadic compose

A compose that takes any number of functions is the symmetric counterpart:

Code Block
TypeScript 5.7

For most working code, pipe is more readable; compose shows up when you want to name a composed function for reuse:

Code Block
TypeScript 5.7

normalize is a value. You can pass it to Array#map, store it, re-export it, test it. It is just itself — three small functions glued together.

Pipelines vs nested calls: the readability test

Before pipelines:

Code Block
TypeScript 5.7

With named composed steps:

Code Block
TypeScript 5.7

Both are correct. The second one is legible: each step has a name, and the pipeline reads like a recipe.

Currying: one argument at a time

Currying is the transformation that turns a function of multiple arguments into a chain of one-argument functions.

Code Block
TypeScript 5.7

A curried function of N arguments has type A1 => A2 => ... => AN => R. Each application "consumes" one argument and returns a smaller function.

Currying is useful primarily because it makes partial application trivial: you supply some arguments now and the rest later.

Partial application

You can partially apply any function — curried or not — by binding some arguments and leaving others as parameters.

Code Block
TypeScript 5.7

A partially applied function is just an ordinary function whose arguments have been supplied incrementally. It has the same type, same behavior, and same testability as any other function.

Why argument order matters

When you curry a function for use in pipelines, the convention is "data last": the value you're transforming should be the last argument, so partial application gives you a "ready-to-pipe" function.

Code Block
TypeScript 5.7

Compare with "data-first" (map(xs, f)), which doesn't compose into a pipeline without an extra wrapper. The whole point of currying in FP is to make pipe(value, step, step, step) work without ceremony.

A real refactor: from imperative to composed pipeline

Same problem we used in Declarative Thinking: "From orders, keep paid > 50, distinct customers, sorted alphabetically."

Code Block
TypeScript 5.7

Each step has a name and a single job. You can read the pipeline as prose. You can lift any step into a separate file. You can test any step in isolation. And the order of the steps is just the order of arguments to pipe — no reshuffling of loops.

A multi-file challenge

Challenge
TypeScript 5.7
Compose a slugger

In text.ts, implement five tiny pure helpers and a pipe of them. Each helper does exactly one thing.

  • trim(s) — strip surrounding whitespace
  • lower(s) — lowercase
  • stripPunct(s) — remove non-alphanumeric, non-space characters
  • collapseSpaces(s) — collapse runs of whitespace to one
  • hyphenate(s) — replace spaces with hyphens
  • slug(s) — pipeline of the above, in this order

Use pipe (already in the file). Do not write s.trim().toLowerCase()… — compose the named helpers explicitly with pipe.

Expected output

hello-world
functional-programming
nice-to-meet-you

QuestionSelect one

What is the difference between compose(f, g) and pipe(x, g, f)?

compose is for synchronous functions and pipe is for asynchronous functions

compose curries its arguments; pipe does not

They express the same idea in different argument orders; compose(f, g)(x) = f(g(x)), while pipe(x, g, f) applies g first then f

compose can only combine two functions; pipe works for any number

On this page