Functional Programming Questions
Functional programming as a paradigm and a way of structuring code: pure functions and side-effect isolation (a pure core with effects at the edges, and how effects are described versus run), immutability and persistent data structures with structural sharing, updating nested immutable data including lenses, higher-order functions, function composition, currying and partial application, folds and recursion over data (including tail recursion), algebraic data types and pattern matching, total functions and Maybe/Either style error values, functors, applicatives and monads as patterns for composing effects and threading state, parser combinators as a worked example, lazy evaluation and lazy or streaming pipelines, and property-based testing of pure code. Covers reasoning about programs as composed transformations rather than mutable state, in a dedicated functional language such as Haskell or Scala, or in a multi-paradigm language such as JavaScript, TypeScript, Kotlin or Python. Boundary: closures and the this binding as language mechanics, the event loop and reactive streams, threads and STM-style concurrency, performance profiling, and exception or resource handling are covered elsewhere.
What is a pure function, and what does immutability buy you? Take one small piece of code that mutates shared state and show how you would rewrite it as pure functions over immutable data. Where does that change help with testing, reasoning about the code, and running it concurrently, and where does it cost you?
Sample Answer
Direct answer
A pure function returns a result that depends only on its arguments and does nothing observable besides returning it: no reading or writing of outside variables, no I/O, no clock, no randomness. Call it twice with the same arguments and you get the same value. Immutability means a value is never changed after it is created; an "update" builds a new value and leaves the old one intact. Together they let you reason about a function by reading it alone, test it with plain inputs and outputs, and share its data across concurrent tasks without locks. The cost is allocation (copying: building a fresh value on every update) and some friction where the problem is naturally about changing state.
Rewrite: mutating shared state to pure functions
The impure version changes a shared cart object. The pure version takes the old cart and returns a new one.
// Impure: mutates a shared cart object
const cart = { items: [], total: 0 };
function addItemImpure(name, price) {
cart.items.push(name);
cart.total += price;
}
// Pure: same inputs always give the same output, nothing outside is touched
const addItem = (cart, name, price) => ({
items: [...cart.items, name],
total: cart.total + price,
});
// Testing
addItemImpure("pen", 2);
addItemImpure("pen", 2);
console.log("impure after 2 calls:", JSON.stringify(cart));
const empty = Object.freeze({ items: Object.freeze([]), total: 0 });
const c1 = addItem(empty, "pen", 2);
const c2 = addItem(c1, "book", 10);
console.log("pure c1:", JSON.stringify(c1));
console.log("pure c2:", JSON.stringify(c2));
console.log("empty untouched:", JSON.stringify(empty), "| same input twice equal:",
JSON.stringify(addItem(empty, "pen", 2)) === JSON.stringify(addItem(empty, "pen", 2)));
// Frozen input makes a hidden mutation fail loudly (push throws on a frozen array in any mode)
try { empty.items.push("x"); } catch (e) { console.log("frozen push ->", e.constructor.name); }
// Concurrency: read-modify-write across an await loses updates on shared state
let shared = 0;
async function bump() { const v = shared; await null; shared = v + 1; }
await Promise.all([bump(), bump(), bump()]);
console.log("shared mutable counter after 3 concurrent bumps:", shared);
// Immutable: every task computes from its own value, then results are combined
const bumpPure = async (v) => { await null; return v + 1; };
const results = await Promise.all([0, 0, 0].map(bumpPure));
console.log("pure results:", results, "sum of increments:", results.reduce((a, b) => a + b, 0));
// Cost: a copy per update is O(n)
let big = Object.freeze({ items: [], total: 0 });
let copies = 0;
for (let i = 0; i < 1000; i++) { copies += big.items.length; big = addItem(big, "x", 1); }
console.log("elements copied across 1000 appends:", copies);
Run as an ES module in a node:22 container (Node 22), it prints:
impure after 2 calls: {"items":["pen","pen"],"total":4}
pure c1: {"items":["pen"],"total":2}
pure c2: {"items":["pen","book"],"total":12}
empty untouched: {"items":[],"total":0} | same input twice equal: true
frozen push -> TypeError
shared mutable counter after 3 concurrent bumps: 1
pure results: [ 1, 1, 1 ] sum of increments: 3
elements copied across 1000 appends: 499500
Notes on the choices:
- The impure
addItemImpurehas no way to be called "again from a clean slate": the second call sees the first call's leftovers.addItem(empty, ...)can be called any number of times from the sameempty. Object.freezeis shallow (it freezes the object it is given, not objects nested inside it), so the example freezes theitemsarray too. Freezing is a safety net that turns an accidental mutation into aTypeError: methods such aspushthrow in any mode, while a plain assignment such ascart.total = 5throws only in strict mode (ES modules are always strict) and is silently ignored in sloppy-mode scripts. In TypeScript,readonlyandReadonlyArray<T>give the same protection at compile time at no runtime cost.
Where it helps
| Concern | Impure and shared | Pure and immutable |
|---|---|---|
| Testing | Arrange global state, call, inspect the global, reset it | Call with an input, compare the returned value. No setup, no teardown, no mocks for the logic itself |
| Reasoning | Any function that can reach cart might have changed it; you must read all of them | The result of addItem(c1, ...) is determined by what is written at the call site; c1 is still valid afterwards, which also makes undo, history and "previous vs next" comparison trivial |
| Concurrency | Two tasks interleave their read and write steps and an update is lost | Nothing is shared and writable, so there is nothing to lock |
The concurrency row is the output above. The three bump() calls each read shared as 0 before any of them writes, so the final value is 1, not 3. A data race is two threads touching the same memory at once with at least one writing, so the result depends on timing. JavaScript has no data race in that thread sense, but any read-modify-write that spans an await has the same lost-update shape. The pure bumpPure takes its input as an argument, returns a new value, and the caller combines the three results, so nothing can be overwritten. With real threads (Kotlin, Java, Scala) the argument is stronger: data that nobody can modify can be read from many threads without locks.
A second payoff is for UI code: React's useState and Redux reducers compare old and new state by reference (prev === next). A pure update that returns a new object when something changed, and the same object when nothing did, makes "did it change?" a one-pointer comparison.
Where it costs
- Copying. Each
addItemcopies the array: the last line of the output shows 499,500 element copies for 1,000 appends (0 + 1 + ... + 999), so building a list by repeated copy is quadratic. (Big-O notation describes how work grows with the size n of the input: O(n) means work proportional to n, so copying a 1,000-item array costs about 1,000 steps; O(log n) means work grows only with the number of times you can halve n, so about 10 steps for 1,000 items.) Persistent data structures are collections that keep every old version usable after an update by sharing the unchanged parts between the old and new version instead of copying them. They are balanced or tree-shaped, so one update copies one path from the root, about O(log n) rather than O(n). Haskell'sData.Mapis the standard example in Haskell, Immutable.js is a JavaScript library of such collections, and Immer is a library that lets you write ordinary-looking mutations on a draft copy and reuses every untouched branch of a plain JS object when it produces the new one. If you only remember one tool for a React/Redux app, Immer is the usual choice. In small, user-sized collections the copy is irrelevant; in a hot loop over millions of elements, a local mutation is the right call. - Allocation pressure. More short-lived objects means more work for the garbage collector (the runtime component that finds objects nothing references any more and frees their memory).
- Awkward fits. Genuinely stateful things (a canvas, a socket, a running counter shared by users) do not become pure by wishing; the pure part is the decision about what to do, and the effect is performed at the boundary.
- Local mutation is fine. A function that builds an array with
pushinside and returns it is still pure from the outside, because no caller can observe the mutation. Purity is a property of the interface, not of every line.
Same idea in Haskell
In Haskell values are immutable and functions are pure by default: addItem :: Cart -> String -> Int -> Cart cannot touch the outside world, and the type says so. Impurity must appear in the type (IO). JavaScript and TypeScript do not enforce any of this, so purity there is a discipline backed by freezing, readonly and code review.
You are handed an imperative loop that builds a list of unique items in first-seen order using a mutable set. Rewrite it in a functional style with a fold or reduce and no mutation of the caller's data. What are the time and space trade-offs against the loop?
Sample Answer
Direct answer
The imperative loop keeps a Set of what it has seen and an output array, and appends an item when it is new. The functional rewrite is a fold (reduce: combine a list into one value by carrying an accumulator through each element) whose accumulator holds the same two things. The honest trade-off is that "no mutation at all" and "linear time" pull in opposite directions in JavaScript. A fold that returns a fresh array on every step ([...acc, x]) never mutates anything but is O(n squared), because it copies the accumulator each time. A fold whose accumulator is mutated only inside the function, and never the caller's array, keeps the pure interface and O(n) time. For this exact job the shortest correct answer is [...new Set(xs)], since a Set iterates in insertion order.
The versions
import assert from "node:assert/strict";
// Imperative: mutable Set plus an output array
function uniqueLoop(xs) {
const seen = new Set();
const out = [];
for (const x of xs) {
if (!seen.has(x)) { seen.add(x); out.push(x); }
}
return out;
}
// Functional, version 1: fold with a spread accumulator. Pure, but copies on every step.
let copied = 0;
const uniqueSpread = (xs) =>
xs.reduce((acc, x) => {
if (acc.includes(x)) return acc;
copied += acc.length; // elements copied by the spread below
return [...acc, x];
}, []);
// Functional, version 2: fold whose accumulator is a pair [seenSet, result], mutated only
// inside the function. The caller's data is never touched, and the cost is linear.
const uniqueFold = (xs) =>
xs.reduce(
(acc, x) => {
if (!acc.seen.has(x)) { acc.seen.add(x); acc.out.push(x); }
return acc;
},
{ seen: new Set(), out: [] }
).out;
// Built-in shortcut: Set keeps insertion order
const uniqueSet = (xs) => [...new Set(xs)];
// Unique by a key (objects): the first item seen for each key wins
const uniqueBy = (xs, key) =>
[...xs.reduce((m, x) => (m.has(key(x)) ? m : m.set(key(x), x)), new Map()).values()];
const input = [3, 1, 3, 2, 1, NaN, NaN, 0, -0];
const frozen = Object.freeze([...input]);
for (const [name, f] of [["loop", uniqueLoop], ["spread", uniqueSpread], ["fold", uniqueFold], ["set", uniqueSet]]) {
console.log(name.padEnd(7), f(frozen));
}
assert.deepEqual(uniqueLoop(input), uniqueFold(input));
console.log("input unchanged:", input);
// Cost: count work on n distinct items (worst case for the spread version)
const n = 2000;
const distinct = Array.from({ length: n }, (_, i) => i);
copied = 0;
uniqueSpread(distinct);
console.log(`n=${n}: elements copied by spread version =`, copied, "| n(n-1)/2 =", (n * (n - 1)) / 2);
// Includes-based membership checks cost up to acc.length comparisons each; Set.has does not scan
let comparisons = 0;
const countingIncludes = (arr, x) => { for (const y of arr) { comparisons++; if (y === x) return true; } return false; };
const acc = [];
for (const x of distinct) { if (!countingIncludes(acc, x)) acc.push(x); }
console.log("linear-scan comparisons:", comparisons);
console.log(uniqueBy([{ id: 1, v: "a" }, { id: 2, v: "b" }, { id: 1, v: "c" }], (o) => o.id));
Output (Node 22, one run of exactly this file):
loop [ 3, 1, 2, NaN, 0 ]
spread [ 3, 1, 2, NaN, 0 ]
fold [ 3, 1, 2, NaN, 0 ]
set [ 3, 1, 2, NaN, 0 ]
input unchanged: [
3, 1, 3, 2, 1,
NaN, NaN, 0, -0
]
n=2000: elements copied by spread version = 1999000 | n(n-1)/2 = 1999000
linear-scan comparisons: 1999000
[ { id: 1, v: 'a' }, { id: 2, v: 'b' } ]
Reading the output
- All four variants return
[3, 1, 2, NaN, 0]for the same input: first-seen order, withNaNkept once and0and-0treated as the same value. That is becauseSetandArray.prototype.includescompare with SameValueZero (MDN:NaNequalsNaN,0equals-0, objects are compared by reference).indexOfwould not findNaN, so anindexOf-based version would keep everyNaN. - Each variant ran on a frozen copy of the input (
Object.freeze), so any attempt to push to or reorder the caller's array would have thrown aTypeErrorin this strict-mode module. None did, and the originalinputprints unchanged afterwards. uniqueByshows the same fold with aMapkeyed by a function of the item, where the first item for each key wins (id: 1keeps"a", not"c"): the usual form when items are objects, since two distinct objects with equal content are never equal by reference.
Time and space
| Version | Time | Extra space | Mutation |
|---|---|---|---|
Loop with Set and output array | O(n) average | O(u), u = number of unique items | Local only |
reduce with [...acc, x] and includes | O(n squared) worst case | O(u) live, but O(n squared) total copying | None at all |
reduce with a local mutable { seen, out } | O(n) average | O(u) | Accumulator only |
[...new Set(xs)] | O(n) average | O(u), plus the final spread | None visible |
The measured counts for n = 2000 distinct items are in the output: the spread version copied 1,999,000 elements, which equals n(n-1)/2 (0 + 1 + ... + 1999), and a linear-scan membership test did 1,999,000 comparisons for the same reason. Those are counts, not timings, so they hold on any machine. With n = 20,000 the same formula gives 199,990,000 each, a hundredfold increase for a tenfold larger input, which is what O(n squared) means.
"Average" matters for the Set rows: the language specification only requires Set access to be sublinear on average (a hash table gives O(1) on average), so say "O(n) expected", not "guaranteed".
Edge cases
- Empty input returns
[]. - Items that are objects are unique by reference. Use
uniqueBywith a stable key for "same id" semantics. - Mixed
1and"1"are different values. - Very large arrays: the spread version also creates a garbage array per step; the loop and local-mutation fold do not.
Judgement
The fold with local mutation is "functional" at the boundary: same input gives the same output, the caller's array is untouched, and nothing escapes the function. That is the useful definition of purity for JavaScript in production. Purely persistent structures (which share structure between versions) can get a cheap non-mutating version, but they are not built in. Recommend [...new Set(xs)] for primitives, uniqueBy for objects, and the plain loop if a teammate finds the fold harder to read than the loop; the two have the same cost.
You have a deeply nested settings object that must be updated immutably, for example changing one field three levels down while sharing the rest. Show how to do it by hand. Then explain what a lens (getter/setter pair) is and how composing lenses makes this reusable, and when it is overkill.
Sample Answer
Direct answer
To change one field three levels down without mutating anything, copy every object on the path from the root to that field (with spread, { ...obj, key: newValue }) and reuse every object off the path by reference. That is structural sharing: the new root is a different object, but billing and any sibling branch are the very same objects as before, so === still says "unchanged" for them. A lens is a small value that bundles a getter and a setter for one place in a structure ({ get, set }); lenses compose, so a lens for user, one for prefs, one for notifications and one for push combine into a single lens for user.prefs.notifications.push that you build once and reuse. It is worth it when the same deep paths are updated from many places; it is overkill for one or two updates, where the spread is clearer.
By hand, then with a lens, then as a path API
import assert from "node:assert/strict";
const settings = {
user: { name: "Ada", prefs: { theme: "light", notifications: { email: true, push: false } } },
billing: { plan: "pro" },
};
// 1. By hand: spread every level on the path, reuse everything off the path
const byHand = {
...settings,
user: {
...settings.user,
prefs: {
...settings.user.prefs,
notifications: { ...settings.user.prefs.notifications, push: true },
},
},
};
assert.equal(settings.user.prefs.notifications.push, false); // original untouched
assert.equal(byHand.user.prefs.notifications.push, true);
assert.equal(byHand.billing, settings.billing); // shared, same reference
assert.equal(byHand.user.prefs.theme, settings.user.prefs.theme);
assert.notEqual(byHand.user, settings.user); // path nodes are new
console.log("by hand: billing shared =", byHand.billing === settings.billing);
// 2. A lens is a getter/setter pair for one place in a structure
const lens = (get, set) => ({ get, set });
const prop = (key) => lens((o) => o[key], (o, v) => (Object.is(o[key], v) ? o : { ...o, [key]: v }));
// compose(outer, inner): focus on outer first, then inside it
const compose = (outer, inner) =>
lens((o) => inner.get(outer.get(o)), (o, v) => outer.set(o, inner.set(outer.get(o), v)));
const over = (l, f, o) => l.set(o, f(l.get(o)));
const pushLens = ["user", "prefs", "notifications", "push"].map(prop).reduce(compose);
console.log("get:", pushLens.get(settings));
const viaLens = pushLens.set(settings, true);
assert.deepEqual(viaLens, byHand);
assert.equal(viaLens.billing, settings.billing);
console.log("lens set equals by-hand:", JSON.stringify(viaLens) === JSON.stringify(byHand));
console.log("over (toggle):", over(pushLens, (b) => !b, settings).user.prefs.notifications.push);
// setting the same value returns the SAME object (no pointless new identity)
assert.equal(pushLens.set(settings, false), settings);
// 3. Path-based API with type validation on set and reference identity for untouched branches
const FORBIDDEN = new Set(["__proto__", "constructor", "prototype"]);
const isPlain = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
function setIn(obj, path, value, check) {
if (path.length === 0) return value;
const [key, ...rest] = path;
if (FORBIDDEN.has(key)) throw new Error(`illegal path segment: ${key}`);
if (!isPlain(obj) || !Object.hasOwn(obj, key)) throw new Error(`no such path: ${path.join(".")}`);
if (rest.length === 0 && check && !check(value, obj[key])) {
throw new TypeError(`type mismatch at ${key}: expected ${typeof obj[key]}, got ${typeof value}`);
}
const child = setIn(obj[key], rest, value, check);
return child === obj[key] ? obj : { ...obj, [key]: child };
}
const sameType = (next, prev) => typeof next === typeof prev;
const out = setIn(settings, ["user", "prefs", "theme"], "dark", sameType);
console.log("theme:", out.user.prefs.theme, "| billing shared:", out.billing === settings.billing,
"| notifications shared:", out.user.prefs.notifications === settings.user.prefs.notifications);
assert.equal(setIn(settings, ["user", "prefs", "theme"], "light", sameType), settings);
for (const [path, v] of [[["user", "prefs", "theme"], 5], [["user", "nope"], 1], [["__proto__", "polluted"], true]]) {
try { setIn(settings, path, v, sameType); console.log("NOT REJECTED", path); }
catch (e) { console.log("rejected:", e.constructor.name, "-", e.message); }
}
assert.equal({}.polluted, undefined);
// The guard itself must be what rejects it: pin the message, and cover an OWN "__proto__" key
assert.throws(() => setIn(settings, ["__proto__", "polluted"], true, sameType), /illegal path segment/);
const parsed = JSON.parse('{"__proto__": {"x": 1}}'); // JSON.parse creates an OWN "__proto__" key
assert.equal(Object.hasOwn(parsed, "__proto__"), true);
assert.throws(() => setIn(parsed, ["__proto__", "x"], 2), /illegal path segment/);
Output (Node 22, one run of exactly this file; every assert in the file passes, so nothing else is printed):
by hand: billing shared = true
get: false
lens set equals by-hand: true
over (toggle): true
theme: dark | billing shared: true | notifications shared: true
rejected: TypeError - type mismatch at theme: expected string, got number
rejected: Error - no such path: nope
rejected: Error - illegal path segment: __proto__
What each part is doing
By hand. Object spread makes a shallow copy, which means it copies the top level only and the nested objects stay shared (MDN's spread page states this). That is exactly what you want off the path and exactly what you must override on the path, which is why you spread at every level you walk through. The asserts show the original push is still false, the copy's is true, and billing is the same object (reused, not copied). The theme assert only shows the value survived: it is a string, so it would be equal even in a deep copy; the object-valued billing is what proves sharing. Cost: O(depth) object allocations plus the width of each copied level, not the size of the whole tree.
Lens. prop("user") is a lens for one key. compose(outer, inner) gets by getting through both and sets by setting the inner value into the outer's current value and then setting the outer. Worked on the example, take outer as the lens for user.prefs.notifications and inner as the lens for push. Get: outer.get(settings) is { email: true, push: false }, and inner.get of that is false. Set to true: first read the outer's current value, { email: true, push: false }; then inner.set on it gives a new object { email: true, push: true } (the email field is untouched); then outer.set(settings, thatNewObject) puts it back, and because outer is itself a composition, it does the same trick one level up through prefs and user. Each level contributes one fresh copy and every field off the path is reused. Reducing the four props gives pushLens, whose get returns false, and whose set(settings, true) produces the same tree as the hand-written version (checked by assert.deepEqual) while keeping billing shared. over(lens, f, obj) is "get, transform, set" in one call, used here to toggle. The setter returns the same object when the value is unchanged (Object.is check, the built-in test for "exactly the same value"), so a no-op update keeps identity and a UI memoisation (caching a result and reusing it while the input reference is unchanged, as React.memo does) does not re-render.
Path-based API with validation. Use this form when the path arrives as data (a list of keys from a config file or a form field) instead of being fixed in code. setIn(obj, ["user", "prefs", "theme"], "dark", sameType) does the same walk with a path array:
- It returns a new root where only the three path objects are new. The output line shows
billingandnotificationsare the same references as in the original. - Setting the value it already has returns the original object (asserted).
- Validation on set: writing the number
5tothemethrowsTypeErrorbecausesameTypecomparestypeofthe new value with the existing one. A missing key (user.nope) throws, so typos do not create fields. - Safety: the path segments
__proto__,constructorandprototypeare rejected. A path API built from untrusted strings (config files, URLs, JSON) is otherwise a route to prototype pollution, where an attacker writes toObject.prototypeand changes behaviour of every object.{}.pollutedstaysundefined.Object.hasOwnis used for the existence check so inherited names liketoStringare not treated as fields, and on an ordinary object literal that check alone already refuses__proto__(it is inherited, not own). The explicit list is what protects an object built byJSON.parse, which can create an own"__proto__"key thathasOwnaccepts; the final asserts require theillegal path segmentmessage for both cases, so deleting theFORBIDDENcheck makes the file fail. This path builder copies with spread rather than assigning through the path, so it is defence in depth here, and the same segments must be blocked in any variant that mutates in place.
When a lens is overkill
| Situation | Use |
|---|---|
| One or two updates, depth 2 to 3 | Spread by hand |
| Many call sites updating the same deep path, or paths chosen at runtime | A lens or a path helper |
| Large state with lots of arrays and many edits in one handler | An Immer-style draft (the Immer library hands your function a temporary copy to mutate with ordinary assignments, then returns a new immutable object with only the changed parts copied) |
| Very large collections where copying a level per edit is too slow | A persistent data structure library (collections designed so that each update returns a new version that shares most of its memory with the old one) |
A hand-rolled lens is about ten lines, but it does not handle arrays or optional fields; a library for lenses earns its place only when you need that. The TypeScript wrinkle is that a typed path API (setIn(obj, ["user", "prefs", "theme"], v) where the compiler checks the path and the value type) needs advanced TypeScript features (template-literal types that build string types such as "user.prefs.theme", and tuple types that fix the length and element types of the path array) and is much more work than the runtime code above. Often a single typed setter per hot path is the pragmatic answer.
Pitfalls
Object.freezeis shallow, so freezing the root does not protect nested objects.- Arrays on the path need
[...arr.slice(0, i), newItem, ...arr.slice(i + 1)]orarr.map(...), not a key spread. - Spread copies own enumerable properties only, so class instances lose their prototype and non-enumerable fields.
- Spreading a level you did not need to change (for example
{ ...settings.billing }) breaks reference identity for no reason and defeats memoisation.
What is the difference between function composition, currying and partial application? Write a compose helper and a curry helper in a language you know, and show how they change the way you design function signatures and argument order.
Sample Answer
Direct answer
- Composition joins functions end to end:
compose(f, g)(x)isf(g(x)). It is about the flow of one value through several steps. - Currying rewrites a function of several arguments into a chain of functions that each take one:
f(a, b, c)becomesf(a)(b)(c). It is a change in the shape of a single function. - Partial application fixes some arguments of a function now and returns a function waiting for the rest:
partial(f, a, b)gives(c) => f(a, b, c). It is an operation you apply to a function.
Currying makes partial application cheap (calling a curried function with one argument is a partial application), and both exist to produce small one-argument functions that composition can chain. They are three ideas, not three names for one.
Helpers, run
// compose: right to left, one input flows through
const compose = (...fns) => (x) => fns.reduceRight((acc, f) => f(acc), x);
const pipe = (...fns) => (x) => fns.reduce((acc, f) => f(acc), x);
// curry: turns f(a, b, c) into f(a)(b)(c) (also accepts f(a, b)(c) etc.)
const curry = (f) => function curried(...args) {
return args.length >= f.length ? f(...args) : (...more) => curried(...args, ...more);
};
// partial application: fix some arguments now, supply the rest later
const partial = (f, ...preset) => (...rest) => f(...preset, ...rest);
const add3 = (a, b, c) => a + b + c;
console.log("curried:", curry(add3)(1)(2)(3), curry(add3)(1, 2)(3), curry(add3)(1)(2, 3));
console.log("partial:", partial(add3, 1, 2)(3));
console.log("curry is not partial: curry(add3)(1) is a", typeof curry(add3)(1), "that still needs 2 more calls");
const inc = (x) => x + 1, dbl = (x) => x * 2;
console.log("compose(dbl, inc)(5) =", compose(dbl, inc)(5), "| pipe(dbl, inc)(5) =", pipe(dbl, inc)(5));
// Argument order: put the data last so partial application yields reusable functions
const mapBad = (arr, f) => arr.map(f); // data first
const map = curry((f, arr) => arr.map(f)); // data last
const filter = curry((p, arr) => arr.filter(p));
const sumSquaresOfEvens = pipe(filter((n) => n % 2 === 0), map((n) => n * n), (a) => a.reduce((s, n) => s + n, 0));
console.log("sumSquaresOfEvens([1..6]) =", sumSquaresOfEvens([1, 2, 3, 4, 5, 6]));
const double = map(dbl);
console.log("double([1,2,3]) =", double([1, 2, 3]), "| data-first needs a wrapper:", ((arr) => mapBad(arr, dbl))([1, 2, 3]));
// Pitfall: curry reads f.length, which ignores default and rest parameters
const withDefault = (a, b = 10) => a + b;
const withRest = (a, ...rest) => a + rest.length;
console.log("lengths:", add3.length, withDefault.length, withRest.length);
console.log("curry(withDefault)(1) =", curry(withDefault)(1), "(called immediately with b = 10)");
Run in a node:22 container it prints:
curried: 6 6 6
partial: 6
curry is not partial: curry(add3)(1) is a function that still needs 2 more calls
compose(dbl, inc)(5) = 12 | pipe(dbl, inc)(5) = 11
sumSquaresOfEvens([1..6]) = 56
double([1,2,3]) = [ 2, 4, 6 ] | data-first needs a wrapper: [ 2, 4, 6 ]
lengths: 3 1 1
curry(withDefault)(1) = 11 (called immediately with b = 10)
How the helpers work:
- Start with
curry(add3)(1)(2)(3)traced call by call.f.lengthis 3 (the number of parametersadd3declares).curried(1)has collected 1 argument, 1 < 3, so it returns a new function that remembers1. Calling that with2runscurried(1, 2): 2 < 3, another waiting function. Calling that with3runscurried(1, 2, 3): 3 >= 3, so it finally callsadd3(1, 2, 3)and returns 6. compose(...fns)reduces from the right, socompose(dbl, inc)(5)appliesincfirst (6) and thendbl(12).pipeis the same list read left to right, givingdbl(5)= 10 theninc= 11. Choosepipewhen you want code to read in execution order; the two differ only in direction.currycompares the number of arguments collected so far withf.length(the declared parameter count) and either callsfor returns a function that collects more. Because thecurriedfunction accepts several arguments per call,curry(add3)(1, 2)(3)works too. This is the usual JavaScript flavour; in a strictly one-argument-at-a-time curry,(1, 2)would not be allowed.partial(f, ...preset)stores the preset arguments in a closure (a function that remembers the variables around where it was created) and prepends them when the rest arrive. It can fix any number of arguments but only from the left. Fixing a middle argument needs a wrapper arrow function such as(b) => f(1, b, 3), or a library helper that accepts a placeholder value marking which argument stays open.
How it changes signature and argument-order design
Composition feeds one value into the next function, so steps work best when they take one input in the position the pipeline supplies. Therefore:
- Put the data last and configuration first.
map(f)(arr)andfilter(p)(arr)can be configured once and then dropped into apipe. In the exampledouble = map(dbl)is a reusable function; with the built-in data-first shapemapBad(arr, f)you need a wrapper arrow function every time (the sixth output line).sumSquaresOfEvensis a pipeline of the curriedfilterandmapplus one plain reducing step. - Order parameters from most stable to most variable. The thing you will apply many times (the formatter, the predicate, the base URL) comes first.
- Prefer one input and one output per step. Several related values travel better as one object parameter than as a long positional list, because a long positional list makes currying order matter.
- Keep the shape predictable. A function whose result type changes depending on how many arguments you give it is hard to type in TypeScript and hard to compose.
In Haskell every function is curried by default: add3 :: Int -> Int -> Int -> Int really is a function from Int to a function, so partial application is just calling with fewer arguments, and composition is the . operator.
add3 :: Int -> Int -> Int -> Int
add3 a b c = a + b + c
main :: IO ()
main = do
let f = add3 1 -- partial application is just calling with fewer arguments
print (f 2 3)
print ((subtract 1 . (* 2)) 5) -- composition: (* 2) first, then subtract 1
6
9
(subtract 1 . (* 2) doubles first, then subtracts one: 5 gives 10 then 9.)
Pitfalls
f.lengthlies. The output linelengths: 3 1 1shows that default and rest parameters are not counted:withDefault(a, b = 10)reports 1, socurry(withDefault)(1)calls it immediately and returns 11 instead of waiting forb. Thecurryshown above has no way to override that, so a helper based onf.lengthneeds a variant that takes an explicit arity (the number of arguments a function expects) for functions with defaults or rest parameters:
const curryN = (f, n = f.length) => function curried(...args) {
return args.length >= n ? f(...args) : (...more) => curried(...args, ...more);
};
console.log(typeof curryN(withDefault, 2)(1), curryN(withDefault, 2)(1)(5));
Run in a node:22 container (after the definitions above) it prints function 6: with the arity stated as 2, curryN(withDefault, 2)(1) waits instead of reading the declared 1.
- Extra arguments from callers. Passing a curried function straight into
maphands it(element, index, array), which can trigger early evaluation. Withadd = curry((a, b) => a + b),[1, 2, 3].map(add)callsadd(1, 0, [1, 2, 3]): three arguments are at least the 2 needed, soaddruns at once and adds the index, giving[1, 3, 5]instead of three waiting functions. Run in anode:22container,[1, 2, 3].map(add)prints[ 1, 3, 5 ]while[1, 2, 3].map(add(10))prints[ 11, 12, 13 ]. Wrap it, as inmap((x) => add(x)(10)), or limit the arity by passing only the one argument you mean ((x) => f(x)). - Debuggability. Deep pipelines give anonymous stack frames; name the steps and consider a small
tap(console.log)step while debugging. - Overuse. Curry what you actually reuse. A currying helper everywhere makes TypeScript signatures hard to read.
- TypeScript typing. A generic
currythat gets its types exactly right needs advanced TypeScript features (tuple types, and conditional types that call themselves to peel off one parameter at a time). In practice use a small library or write the few curried signatures by hand.
Your team is debating whether a small UI widget that keeps a counter and re-renders a view should be written with classes and mutable fields or as plain functions over immutable state. Sketch both versions in JavaScript or TypeScript, then tell me which you would ship and what you would give up by choosing it.
Sample Answer
Direct answer
I would ship the functional version: state is an immutable value, a pure reducer (a function (state, action) => newState) is the only code that decides what the next state is, a view function turns state into output, and a few lines of mutable shell connect it to the page. I would give up a little brevity and some in-place speed, and I would keep a class only for the parts that own a resource (a socket, a timer, a canvas). Run on the same steps, the two versions show the difference in testability and in how exposed the state is.
Both versions
// ---------- Version A: class with mutable fields ----------
class CounterWidget {
private count = 0;
history: number[] = [];
constructor(private root: { text: string }) {}
increment(): void {
this.history.push(this.count);
this.count += 1;
this.render();
}
undo(): void {
const prev = this.history.pop();
if (prev !== undefined) this.count = prev;
this.render();
}
private render(): void {
this.root.text = `Count: ${this.count}`;
}
}
// ---------- Version B: plain functions over immutable state ----------
type State = { readonly count: number; readonly history: readonly number[] };
type Action = { type: "increment" } | { type: "undo" };
const initial: State = { count: 0, history: [] };
const reduce = (s: State, a: Action): State => {
switch (a.type) {
case "increment":
return { count: s.count + 1, history: [...s.history, s.count] };
case "undo": {
if (s.history.length === 0) return s;
return { count: s.history[s.history.length - 1], history: s.history.slice(0, -1) };
}
}
};
const view = (s: State): string => `Count: ${s.count}`;
// the thin imperative shell: the only place with mutation and effects
const mount = (root: { text: string }) => {
let state = initial;
const dispatch = (a: Action) => {
const next = reduce(state, a);
if (next === state) return; // nothing changed, skip the re-render
state = next;
root.text = view(state);
};
root.text = view(state);
return dispatch;
};
// ---------- Checks ----------
let failures = 0;
const eq = (label: string, got: unknown, want: unknown) => {
const ok = JSON.stringify(got) === JSON.stringify(want);
if (!ok) failures++;
console.log((ok ? "pass " : "FAIL ") + label + ": " + JSON.stringify(got));
};
// B needs no widget, no root, no setup: input state and action, output state
const s1 = reduce(initial, { type: "increment" });
const s2 = reduce(s1, { type: "increment" });
eq("B reduce twice", s2, { count: 2, history: [0, 1] });
eq("B old state untouched", s1, { count: 1, history: [0] });
eq("B undo on empty returns same object", reduce(initial, { type: "undo" }) === initial, true);
eq("B view", view(s2), "Count: 2");
const rootB = { text: "" };
const dispatch = mount(rootB);
dispatch({ type: "increment" });
dispatch({ type: "increment" });
dispatch({ type: "undo" });
eq("B mounted", rootB.text, "Count: 1");
// The skip-render guard in dispatch: count how often the view is written
let writes = 0;
const rootC = { get text() { return ""; }, set text(_v: string) { writes++; } };
const dispatchC = mount(rootC); // initial render: 1 write
dispatchC({ type: "undo" }); // reducer returns the same state: no write
dispatchC({ type: "increment" }); // new state: 1 write
eq("B skips the render when nothing changed", writes, 2);
// A needs a root object and drives it through methods
const rootA = { text: "" };
const w = new CounterWidget(rootA);
w.increment();
w.increment();
w.undo();
eq("A mounted", rootA.text, "Count: 1");
// the cost of exposed mutable state: any holder of `history` can corrupt the widget
const leaked = w.history;
leaked.push(999);
w.undo();
eq("A after outside code pushed to history", rootA.text, "Count: 999");
if (failures > 0) throw new Error(failures + " checks failed");
Compiled and run (TypeScript 5, node:22 container):
npx -y -p typescript@5 tsc --strict --target es2022 --module commonjs --outDir out counter.ts
node out/counter.js
pass B reduce twice: {"count":2,"history":[0,1]}
pass B old state untouched: {"count":1,"history":[0]}
pass B undo on empty returns same object: true
pass B view: "Count: 2"
pass B mounted: "Count: 1"
pass B skips the render when nothing changed: 2
pass A mounted: "Count: 1"
pass A after outside code pushed to history: "Count: 999"
The eq helper counts every mismatch and the last line of the file throws if any were counted. The checks at the end are both a test and the argument. Two of them guard the claims made below: deleting the if (s.history.length === 0) return s; line makes the undo-on-empty check print FAIL, and deleting the if (next === state) return; line in dispatch makes the render-count check print FAIL (3 writes instead of 2).
The trade-offs, against the code
- Testability.
reducetakes a value and returns a value: the fourBchecks need no widget, no root element and no setup, and each one sets its own input. The class needs arootto be constructed and is driven by calling methods in order, so a test reads as a story rather than an assertion, and tests that share an instance can leak into each other. - Mutability. The class keeps
historyas a public array so the demo can show the hazard: the last check pushes999into it from outside and the nextundo()sets the count to 999. Makinghistoryprivate closes that hole, but the underlying issue stays: any method can change state in place, so you must read every method to know what can happen. In version B,reduce(s1, ...)leavess1untouched (the "old state untouched" check), so undo, time travel (stepping the app backward and forward through earlier states, which is possible because every past state is still intact) and "did anything change?" (next === state) come for free. - Composition. Reducers combine by calling each other on slices of state; views combine as ordinary function calls. Class widgets combine by holding references to each other, which tends to spread state across objects.
- Maintainability. Version B puts every state change in one
switch, which is the place to look when a bug report says "the count is wrong". The cost is that adding a feature means adding an action type and a case, more lines thanthis.count += 1. - What you give up. (1) Ceremony: actions, a union type, a dispatch shell. (2) Allocation: every update creates a new state object. For a counter this is nothing; for large state updated thousands of times per second it can matter, and you would then use structural sharing (the new state reuses every unchanged part of the old one by reference and copies only the path that changed) or Immer (a library that lets you write ordinary mutating code against a temporary draft and returns a new immutable state with structural sharing). (3)
readonlyin TypeScript is a compile-time promise only, not enforced at runtime, so a strayas anycan still mutate. (4) Teammates who think in objects need a short ramp.
They blend in practice
The functional core is exactly what frameworks ask of you. React's useReducer takes the same (state, action) function, and its documentation says the reducer must be pure and must not mutate state: if you return the same object, React skips re-rendering, which is the same next === state check the shell does above. A hook-based component is a function over state, and a class is still the right tool for a thing with identity and a lifecycle, such as a Web Component (a custom HTML tag you define yourself, for example <my-counter>, by writing a class that extends the browser's built-in HTMLElement base class, which the browser instantiates and then notifies when it is attached to or removed from the page). The decision is "where does the state change logic live", not "classes or functions everywhere".
What would flip the choice
Choose the class if the widget owns a resource with setup and teardown, if the codebase and its tests are already object-oriented and consistency is worth more, or if profiling shows allocation per update is a measured cost. For a counter and a view with history, B is smaller to reason about and easier to test.
Unlock Full Question Bank
Get access to all 7 Functional Programming interview questions and detailed answers.
Sign in to ContinueJoin thousands of developers preparing for their dream job.