Lesson overview · Free interview practice
Functional Programming
Learn about pure functions, immutability, and managing side effects.
Topics in the full lesson
- Introduction to Functional Programming
- Pure Functions
- Immutability
- Side Effects
- Higher-Order Functions
- Functional Programming Techniques
- Best Practices
- Exercises
Practice Problems
Work through a question before revealing its explanation. These questions and answers are free; Premium adds the full lesson walkthrough, examples and implementation detail.
Completion marks record your own progress, not an automatically checked result. The task type does not determine whether it is optional.
Theory questions
Immutability in Practice
What does immutability mean in JavaScript, and what tools does the language give you to enforce it?
Take a moment to think about this before revealing the answer
Explain your reasoning or try an implementation before comparing answers.
Side Effects — What They Are and How to Corral Them
What is a side effect in a function, and how do FP-style codebases keep them under control?
Take a moment to think about this before revealing the answer
Explain your reasoning or try an implementation before comparing answers.
Pure Functions
What is a pure function, and what makes it "pure"? Why do FP-style codebases prize purity?
Take a moment to think about this before revealing the answer
Explain your reasoning or try an implementation before comparing answers.
Coding practice
Function Composition: `compose` and `pipe`
Implement and check your solution in the stated environment before revealing the answer.
Implement compose(...fns) and pipe(...fns). Explain their order and why pipe often reads more naturally.
Core practice. This extends the lesson's two-function composition to a list. Start by tracing two steps on paper. You should be comfortable returning a function and reducing an array.
Contract
- These are synchronous unary pipelines.
pipe(f, g)(x)meansg(f(x)).compose(f, g)(x)meansf(g(x)). - Reject any non-function step with
TypeErrorwhile building the pipeline. Do not execute steps during validation. - The returned function takes one flowing value. Missing input is
undefined. Extra call arguments are ignored. Each step receives exactly one argument, even the first step. - Forward the pipeline call's
thisto every step. A plain call in these modules suppliesundefined. Arrow steps keep their own lexicalthis. - With no steps, return the input unchanged, including object identity. With one step, run that step once per call.
- Return the last result. If a step throws, propagate that same exception and do not run later steps. This is not an async pipeline and does not await promises.
For example, trimming, lowercasing and replacing whitespace should turn ' Hello World ' into 'hello-world'. pipe(trim, lower, replaceSpaces) and compose(replaceSpaces, lower, trim) should agree.
The JavaScript contract allows one step's output type to differ from its input type when the next step accepts it. The optional TypeScript route below deliberately supports a homogeneous T throughout the pipeline. Typing every heterogeneous variadic chain is not required.
Set up an attempt
Use Node.js 24.21.0. Work in a small local folder, save the files below exactly as named, then edit only the starter. The .mjs extension makes the JavaScript files ES modules. No package install, browser, network request or project framework is needed.
Run the visible checks from that folder:
node check.mjs
The supplied starter intentionally throws an Implement ... error. That first failure confirms that the checker reaches your code. Work toward the named success message at the end of the check, not toward hiding assertions.
These are finite checks of the stated cases, not a proof of all possible correctness or mastery. Be ready to explain the behavior as well as run it.
starter.mjs
export function pipe(...fns) {
throw new Error('Implement pipe')
}
export function compose(...fns) {
throw new Error('Implement compose')
}
check.mjs
import assert from 'node:assert/strict'
const { pipe, compose } = await import(new URL(process.argv[2] ?? './starter.mjs', import.meta.url))
const trim = (s) => s.trim()
const lower = (s) => s.toLowerCase()
const replaceSpaces = (s) => s.replace(/\s+/g, '-')
assert.equal(pipe(trim, lower, replaceSpaces)(' Hello World '), 'hello-world')
assert.equal(compose(replaceSpaces, lower, trim)(' Hello World '), 'hello-world')
const object = {}
assert.equal(pipe()(object), object)
assert.equal(compose()(object), object)
assert.equal(pipe()(), undefined)
assert.equal(compose()(), undefined)
for (const build of [pipe, compose]) {
assert.equal(build((x) => x + 1)(5), 6)
const order = []
const first = (x) => { order.push('first'); return x + 1 }
const second = (x) => { order.push('second'); return x * 2 }
const run = build(first, second)
assert.equal(run(3), build === pipe ? 8 : 7)
assert.deepEqual(order, build === pipe ? ['first', 'second'] : ['second', 'first'])
order.length = 0
run(4)
assert.equal(order.length, 2)
const receivers = []
function step(value) {
assert.equal(arguments.length, 1)
receivers.push(this)
return value + this.delta
}
const context = { delta: 2 }
assert.equal(build(step, step).call(context, 3, 'ignored'), 7)
assert.deepEqual(receivers, [context, context])
assert.equal(build(function (value) { return this })(0), undefined)
let called = false
assert.throws(() => build(() => { called = true }, null), TypeError)
assert.equal(called, false)
}
const marker = new Error('step failed')
const fail = () => { throw marker }
let after = 0
const later = (x) => { after += 1; return x }
assert.throws(() => pipe(fail, later)(1), (error) => error === marker)
assert.throws(() => compose(later, fail)(1), (error) => error === marker)
assert.equal(after, 0)
assert.equal(pipe((n) => String(n), (s) => s.length)(123), 3)
console.log('composition checks passed')
Optional TypeScript attempt
Use this instead of the JavaScript starter if you want a typed implementation. Save starter.mts, types.mts and tsconfig.json below beside the same check.mjs. The @ts-expect-error lines are intentional negative type checks. Their guarded block is not executed.
With an existing TypeScript 5.9.3 compiler available, run both commands:
tsc -p tsconfig.json
node check.mjs ./starter.mts
The first command performs a strict no-emit type check. The second runs the behavioral checks using Node's type stripping. Stripping is not type checking. The unimplemented typed starter should type-check but fail the runtime check until you implement it.
The configuration uses standard library declarations only. No @types/node package is needed for these small fixtures.
starter.mts
export type Step<T, This> = (this: This, value: T) => T
export function pipe<T, This = unknown>(...fns: Step<T, This>[]): Step<T, This> {
throw new Error('Implement pipe')
}
export function compose<T, This = unknown>(...fns: Step<T, This>[]): Step<T, This> {
throw new Error('Implement compose')
}
types.mts
import { pipe, compose } from './starter.mts'
const slug = pipe<string>((s) => s.trim(), (s) => s.toLowerCase())
const result: string = slug(' HELLO ')
if (result !== 'hello') throw new Error('Typed usage failed')
const identity = compose<number>()
if (identity(3) !== 3) throw new Error('Typed identity failed')
type Context = { delta: number }
const run = pipe<number, Context>(function (n) { return n + this.delta })
if (run.call({ delta: 2 }, 3) !== 5) throw new Error('Typed receiver failed')
if (false) {
// @ts-expect-error The homogeneous alternative cannot change the value type.
pipe<string>((s) => s.length)
// @ts-expect-error This pipeline accepts strings.
slug(3)
// @ts-expect-error The receiver must provide delta.
run.call({}, 3)
}
tsconfig.json
{
"compilerOptions": {
"strict": true,
"noEmit": true,
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"allowImportingTsExtensions": true,
"lib": [
"ES2022",
"DOM"
],
"types": []
},
"files": [
"starter.mts",
"types.mts"
]
}
Take a moment to think about this before revealing the answer
Explain your reasoning or try an implementation before comparing answers.
Currying and Partial Application
Implement and check your solution in the stated environment before revealing the answer.
What is currying, and how does it differ from partial application? Implement curry(fn) and use it to prepare unary steps for a small composition.
Optional advanced extension. The lesson's hand-written two-argument curry is the first-pass goal. This generic utility adds reusable closures and receiver rules. Review closures, rest/spread parameters and this forwarding before attempting it.
Contract
fnis an ordinary callable function. Reject a non-function withTypeErrorwhen creating the wrapper. Constructors and callable proxies are outside this exercise.- Read
fn.lengthonce as the arity threshold. Collect positional arguments until their count reaches that threshold, then callfnonce and return its result. Do not callfnwhile creating the wrapper. - Support full, unary and grouped calls, such as
sum(1, 2, 3),sum(1)(2)(3)andsum(1, 2)(3). Forward extra arguments too. There are no placeholders. - Empty calls contribute no arguments. For positive arity they still return a callable partial. A zero-arity target runs on its first call, even
(). - Each chain keeps the receiver from its first call, including an empty first call. Later
.call(...)receivers do not replace it. Independent calls to the original wrapper may have different receivers. - A saved partial can be reused or branched without changing its captured arguments. A target exception propagates unchanged and must not corrupt the partial.
- Default and rest parameters do not reveal an intended total arity. Use JavaScript's actual
fn.lengththreshold:(a, b = 10) => a + bhas threshold 1, while(...xs) => xs.lengthhas threshold 0. This utility does not wait for default/rest arguments. Use a fixed-parameter adapter when you need a different threshold.
For example, a curried three-argument sum should produce 6 for every grouping above. Preparing filter(positive) and map(double) should let a pipeline turn [-1, 2, -3, 4] into [4, 8]. Explain why fixing one argument with bind is partial application rather than automatic currying.
Set up an attempt
Use Node.js 24.21.0. Work in a small local folder, save the files below exactly as named, then edit only the starter. The .mjs extension makes the JavaScript files ES modules. No package install, browser, network request or project framework is needed.
Run the visible checks from that folder:
node check.mjs
The supplied starter intentionally throws an Implement ... error. That first failure confirms that the checker reaches your code. Work toward the named success message at the end of the check, not toward hiding assertions.
These are finite checks of the stated cases, not a proof of all possible correctness or mastery. Be ready to explain the behavior as well as run it.
starter.mjs
export function curry(fn) {
throw new Error('Implement curry')
}
check.mjs
import assert from 'node:assert/strict'
const { curry } = await import(new URL(process.argv[2] ?? './starter.mjs', import.meta.url))
const sum3 = (a, b, c) => a + b + c
const sum = curry(sum3)
assert.equal(sum(1, 2, 3), 6)
assert.equal(sum(1)(2)(3), 6)
assert.equal(sum(1, 2)(3), 6)
assert.equal(sum(1)(2, 3), 6)
assert.equal(sum()(1)()(2)(3), 6)
const one = sum(1)
assert.equal(one(2)(3), 6)
assert.equal(one(4, 5), 10)
assert.equal(sum(4, 5, 6), 15)
let calls = 0
const zero = curry(function () { calls += 1; return [...arguments] })
assert.equal(calls, 0)
assert.deepEqual(zero(), [])
assert.deepEqual(zero('extra'), ['extra'])
assert.equal(calls, 2)
const extras = curry(function (a, b) { return [...arguments] })
assert.deepEqual(extras(1)(2, 3), [1, 2, 3])
const a = { base: 10 }
const b = { base: 100 }
const add = curry(function (x, y) { return this.base + x + y })
const partial = add.call(a, 1)
assert.equal(partial.call(b, 2), 13)
assert.equal(add.call(b, 1, 2), 103)
assert.equal(add.call(a).call(b, 1, 2), 13)
const receiver = curry(function (x) { return this })
assert.equal(receiver(1), undefined)
assert.equal(receiver.call(a, 1), a)
const marker = new Error('target failed')
const risky = curry((x, y) => {
if (y === 0) throw marker
return x / y
})
const fixed = risky(8)
assert.throws(() => fixed(0), (error) => error === marker)
assert.equal(fixed(2), 4)
assert.throws(() => curry(null), TypeError)
const defaults = curry((a, b = 10) => a + b)
assert.equal(defaults(2), 12)
assert.equal(defaults(2, 3), 5)
const rest = curry((...values) => values.length)
assert.equal(rest(), 0)
assert.equal(rest(1, 2, 3), 3)
const map = curry((fn, values) => values.map(fn))
const filter = curry((predicate, values) => values.filter(predicate))
const pipe = (...fns) => (value) => fns.reduce((x, fn) => fn(x), value)
const pipeline = pipe(filter((n) => n > 0), map((n) => n * 2))
assert.deepEqual(pipeline([-1, 2, -3, 4]), [4, 8])
assert.equal(sum3.bind(null, 1)(2, 3), 6)
console.log('curry checks passed')
Take a moment to think about this before revealing the answer
Explain your reasoning or try an implementation before comparing answers.
Explore the full Functional Programming material
Premium includes the complete lessons and implementation references. Free practice questions remain available without a subscription.