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?
export const pipe = (...fns) => fns.reduce((f, g) => (...args) => g(f(...args))); export function scorerFor(steps) { const normalise = pipe(...steps); return (answer) => normalise(answer);}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
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
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
- 1empty
- 2empty
- 3empty
- 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?
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);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?
export const pipe = (...fns) => (x) => fns.reduce((acc, f) => f(acc), x); export const post = pipe((amount, currency) => `${amount} ${currency}`, (line) => `ledger: ${line}`);Question 4 of 8
A video app loads a show's title through this pipeAsync draft:
The page runs await loadTitle('s1').
What happens?
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}`);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?
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);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?
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`,);Go deeper
Pro tips
- Say what
pipe()returns before you write a loop, and write that test first. When: as soon as the interviewer says "implement pipe"; areduceover the functions with no initial value passes every other core test (step 4). Verify (RAN,tips.mjs): the list run on the opening question'sreducedraft.tip 1 the reduce draft (no initial value): 7 of 8 pass · red: T3 - 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,
pipeandcompose(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." andcompose"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):TypeErroris "Thrown if the array contains no elements and initialValue is not provided." (the keys of t1 and t2; step 4 linksmr-explain-line-nstep 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.
Related lessons
Checked against the tools and docs as of 2026-10-16.