Skip to content

Kata: pipe, compose and pipeAsync

Run the list in order, give the first function every argument, and decide what an empty list returns before you write a loop.

Predict first

Before you read: predict

ExamHall scores free-text answers after a normaliser such as pipe(trim, collapseSpaces, normaliseDigits), built for each question type from its list of steps. The team's kata answer and the scorer factory:

An essay question has no normalising steps, so the app runs const essay = scorerFor([]), then essay(' 42 ').

What happens?

Code for this question
export const pipe = (...fns) => fns.reduce((f, g) => (...args) => g(f(...args))); export function scorerFor(steps) {  const normalise = pipe(...steps);  return (answer) => normalise(answer);}
Pick one

The 30-second version

pipe(f, g, h) returns a new function that runs f, then g, then h, each on the result before it; compose(f, g, h) runs the same list from the right. The first function to run takes every argument of the call, and a list with no functions returns its first argument. pipeAsync waits for each step's result before it starts the next.

Run the list in order, give the first function every argument, and decide what an empty list returns before you write a loop.

How it works

Step 1 of 10

Ask, then say the contract. pipe(f, g, h)(x) is h(g(f(x))); compose(f, g, h)(x) is f(g(h(x))). Ask about no functions and async steps.
In the kitchen
  • the curing card lists brine, rinse, dry, smoke and slice in the order the fish goes through them → pipe (step 1)
  • the shop label says "peppered smoked mackerel", the last treatment first → compose (steps 1, 6)

Analogy: A fish smokery's curing card (food production)

A smokery cures each side of fish by a card that lists its stations.

At the smokery In the kata
the curing card lists brine, rinse, dry, smoke and slice in the order the fish goes through them pipe (step 1)
the shop label says "peppered smoked mackerel", the last treatment first compose (steps 1, 6)
the first station takes the fish and the customer's note with it; each later station takes what the one before hands over the first function gets every argument (step 3)
a card with no stations: the fish goes back as it came no functions is the identity (step 4)
writing a card lights no kiln building runs nothing (step 2)
the slicer starts on a side once it is out of the kiln pipeAsync awaits each step (step 7)

In real terms: pipe and compose return a new function that runs a list of functions in a row, each on the result of the one before.

Breaks down when: a side can be sent back to an earlier station; a pipe only ever moves forward.

Contrasting case: a fish counter that takes several orders at once and serves whichever is ready first is not a curing card. Steps that do not need each other's results belong in Promise.all, not in pipeAsync.

Examples, run for real

4.1 ExamHall EXH-4620: an essay type with no steps

ExamHall's scoring module built one normaliser per question type when it loaded, with the opening question's reduce draft (EXH-4620). The essay type lists no steps, so the module threw while loading, and no free-text answer was scored that morning. MR !4920 replaced the draft with the reference and added the test list to the repository. The list also runs on the draft:

4.1 reference.mjs                           10 of 10 pass (8 core, 2 ladder)
4.1 the reduce draft (no initial value)      8 of 10 pass · red: T3 "with no functions, pipe()(5) and compose()(5) return 5 and pipeAsync()(5) resolves to 5", L1 "every step sees the caller's this"
4.1 EXH-4620 the reduce draft loads 6 question types: throws a TypeError at load · 0 of 6 scorers built
4.1 MR !4920 the reference loads the same 6 types: 6 scorers built · the essay scorer hands '  42 ' back unchanged · the numeric one turns ' ٤٢ ' into '42'

The draft's reduce runs inside pipe(...), when the scorers are built, so the empty list throws before any answer arrives (steps 2 and 4). The reference returns the first argument when the list is empty.

4.2 Payments: one posting chain, awaited step by step

A ledger posts each payment with pipeAsync(authorise, capture, record) from the reference. authorise and capture are async and log when they start and when they finish; record is a plain function.

4.2 post({ id: 'p-7', cents: 1250 }) resolves to 'p-7 recorded'
4.2 log: authorise-start, authorise-end, capture-start, capture-end, record
4.2 a second post({ id: 'p-8', cents: 990 }) resolves to 'p-8 recorded' · log grows by 5 entries

capture starts only after authorise has finished, and record starts after capture (step 7). The chain is built once and runs again on every call (step 2).

Try it

Answer first, then check. Nothing is saved.

Question 1 of 8

A ledger builds its posting function from the kata's reference pipe and compose; each step adds its name to log:

The ledger calls post(1250) once. Put the four names in the order they reach log, first first.

Order these chips: record fee parse convert

Code for this question
import { pipe, compose } from './pipe.mjs'; export const log = [];const step = (name) => (cents) => {  log.push(name);  return cents;}; export const post = pipe(step('parse'), compose(step('fee'), step('convert')), step('record'));

Your order

  1. 1empty
  2. 2empty
  3. 3empty
  4. 4empty

Tap the lines in the order you expect.

Question 2 of 8

A chat client formats replies with a teammate's compose:

The client calls format('hi'), then format('ok').

What do the two calls return?

Code for this question
export const compose = (...fns) => (text) => fns.reverse().reduce((acc, f) => f(acc), text); const reply = (text) => `re: ${text}`;const shout = (text) => text.toUpperCase();export const format = compose(reply, shout);
Pick one

Question 3 of 8

A ledger copied the pipe from MDN's reduce page and builds its posting line with it:

The ledger calls post(12, 'EUR').

What does it return?

Code for this question
export const pipe = (...fns) => (x) => fns.reduce((acc, f) => f(acc), x); export const post = pipe((amount, currency) => `${amount} ${currency}`, (line) => `ledger: ${line}`);
Pick one

Question 4 of 8

A video app loads a show's title through this pipeAsync draft:

The page runs await loadTitle('s1').

What happens?

Code for this question
export const pipeAsync = (...fns) => (x) => fns.reduce((acc, f) => f(acc), x); export const loadTitle = pipeAsync(async (id) => ({ id, title: 'Pilot' }), (show) => `${show.id}: ${show.title}`);
Pick one

Question 5 of 8

the interviewer says: "Implement pipe and compose. What does your pipe() return with no functions? Then make an async version that awaits each step." You have 15 minutes and a plain editor, and ExamHall's answer normaliser is the use case.

Before you type: say your test list out loud.

Name at least six tests, each with its calls and what it expects.

Question 6 of 8

the interviewer says: "Implement pipe and compose. What does your pipe() return with no functions? Then make an async version that awaits each step." You have 15 minutes and a plain editor, and ExamHall's answer normaliser is the use case.

Now write it, in 15 minutes, from a blank file that exports pipe, compose and pipeAsync.

Question 7 of 8

A box office builds seat codes per zone with this kata answer:

A new zone has no format yet. The page runs const box = seatCode('box'), then box(' a12 ').

What happens?

Code for this question
export const pipe = (...fns) => (...args) => fns.reduce((acc, f, i) => (i === 0 ? f(...acc) : f(acc)), args); const formats = { floor: (code) => `FL-${code}`, balcony: (code) => `BA-${code}` };const tidy = (code) => code.trim();const upper = (code) => code.toUpperCase();export const seatCode = (zone) => pipe(tidy, formats[zone], upper);
Pick one

Question 8 of 8

A payments service copied the async pipe from MDN's reduce page:

The service runs await price(100).

What does it give?

Code for this question
export const pipeAsync = (...fns) => (x) => fns.reduce(async (acc, f) => f(await acc), x); export const price = pipeAsync(  async (cents) => cents + 30,  (cents) => cents * 2,  async (cents) => `${cents} c`,);
Pick one

Go deeper

Pro tips
  1. Say what pipe() returns before you write a loop, and write that test first. When: as soon as the interviewer says "implement pipe"; a reduce over the functions with no initial value passes every other core test (step 4). Verify (RAN, tips.mjs): the list run on the opening question's reduce draft.
    tip 1 the reduce draft (no initial value): 7 of 8 pass · red: T3
  2. Run your own answer against the list before you call it done: KATA_IMPL=./my-pipe.mjs node --test kata-core.test.mjs. When: practising the kata at home, against the same tests the reference passes (step 10). Verify (RAN, tips.mjs):
    tip 2 KATA_IMPL=./reference.mjs: exit 0 · tests 8 · pass 8 · fail 0
    tip 2 KATA_IMPL=<temp>/reduce-pipe.mjs (the reduce draft): exit 1 · tests 8 · pass 7 · fail 1 · red: T3
Source

Vendor and guide pages change, so their claims are labelled as of 2026-10-16; every behaviour this lesson grades is executed on Node 24.

  • Ramda documentation, pipe and compose (the curriculum row's first source), https://ramdajs.com/docs/#pipe (ramda-docs.txt): pipe "Performs left-to-right function composition. The first argument may have any arity; the remaining arguments must be unary." and compose "Performs right-to-left function composition. The last argument may have any arity; the remaining arguments must be unary." (steps 1, 3). As of 2026-10-16. Ramda keys no item; this lesson's reference is its own code.
  • MDN, Array.prototype.reduce() (the row's second source), https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/reduce (mdn-array-reduce.txt): TypeError is "Thrown if the array contains no elements and initialValue is not provided." (the keys of t1 and t2; step 4 links mr-explain-line-n step 7 for it). The page's own pipe examples are the drafts P3 and D2 serve. As of 2026-10-16.
  • MDN, Array.prototype.reverse(), https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/reverse (mdn-array-reverse.txt): "The reverse() method of Array instances reverses an array in place and returns the reference to the same array" (step 6). As of 2026-10-16.
  • Front End Interview Handbook, JavaScript coding, https://www.frontendinterviewhandbook.com/coding/javascript-utility-function (feih-javascript-coding.txt): candidates take "around 10-15 minutes for a basic question" (the time box, step 1), and it advises "writing test cases" (step 10). As of 2026-10-16.

Checked against the tools and docs as of 2026-10-16.