JAVASCRIPT / FUNCTIONS
Callback functions and inversion of control
Pass functions as arguments and reason about who controls the call: how many times it runs, which arguments it receives, and what its return value means.
What you will learn
- Pass a function by its name, not name(), so the receiving code can call it later
- Read a callback contract: how many arguments arrive and what the return value means
- Declare only the parameters you need so extra host arguments cannot leak in
- Wrap callbacks you hand to code you do not own so a repeat call cannot corrupt state
Understanding Callback functions and inversion of control
A callback is nothing more than a function value passed as an argument so that some other function can invoke it. Nothing marks it as special where you define it; the role appears the moment you write sortBy(list, byName) instead of calling byName yourself. The receiving function stores the reference in a parameter and calls it through that parameter, which is why you pass byName and not byName() — the parentheses would run it immediately and hand over its return value instead of the function.
Inversion of control is the consequence of that handoff. In ordinary code you own the call site: you decide when a function runs, what it receives, and what happens to its result. Once you pass the function away, every one of those decisions moves to the host, which may call you once per array element, only on failure, twice by accident, or never; it picks the arguments, and it decides whether your return value is a signal or garbage. You still write the body, but you are renting out the call site.
That is why most callback bugs are contract bugs rather than logic bugs. map passes three arguments, so map(parseInt) quietly reads the index as a radix; forEach throws your return value away, so return there ends one invocation instead of the loop; setTimeout runs your function after the current script finishes, so the next line still sees the old values. Before handing a function to code you did not write, find out how many arguments arrive, when the call happens, and what the host does with the result — and when you cannot find out, defend your side of the boundary with a wrapper.
The practical habit is to keep the callback's parameter list as narrow as the job requires and to treat its signature as part of an agreement with the host.
function tryUpTo(limit, task) {
for (let n = 1; n <= limit; n++) {
console.log('host: calling task, attempt', n);
if (task(n) === true) {
return 'succeeded on attempt ' + n;
}
}
return 'gave up after ' + limit;
}
const report = tryUpTo(4, function (attemptNumber) {
console.log(' task: I was handed', attemptNumber);
return attemptNumber >= 3;
});
console.log(report);Passing a function as an argument transfers control of the call site: the receiving code decides whether, when, how often, and with which arguments your function runs.
Worked examples
Extra arguments you never asked for
map hands its callback three arguments, which changes the meaning of a function that accepts a second parameter.
const raw = ['10', '10', '10'];
console.log('map(parseInt) ->', raw.map(parseInt).join(','));
console.log('wrapped ->', raw.map(function (s) { return parseInt(s, 10); }).join(','));Example explained
Line 1map calls the callback as callback(value, index, array), not callback(value).
Line 2parseInt takes a radix as its second parameter, so the index arrives there: parseInt('10', 1) is NaN and parseInt('10', 2) is 2.
Line 3The first item looks fine only because radix 0 falls back to base 10, which hides the bug.
Line 4The wrapper declares a single parameter, so index and array are dropped and the radix stays 10.
The host decides when
A callback registered with setTimeout runs after the surrounding code, so the line below it sees the earlier state.
let status = 'pending';
setTimeout(function () {
status = 'done';
console.log('callback ran, status =', status);
}, 0);
console.log('after setTimeout call, status =', status);Example explained
Line 1setTimeout only stores the function and returns; it does not call it.
Line 2The bottom console.log is ordinary synchronous code, so it runs while status is still 'pending'.
Line 3A delay of 0 means at the earliest opportunity after the current script finishes, not now.
Line 4Any code that depends on the new value has to live inside the callback, because that is the only place where the host has already run it.
Guarding against a double call
When the host invokes your callback more than once, a small wrapper keeps the second call from repeating the work.
function once(fn) {
let called = false;
return function (...args) {
if (called) {
console.log('ignored extra call');
return;
}
called = true;
return fn(...args);
};
}
function flakyHost(onDone) {
onDone('receipt-1');
onDone('receipt-1');
}
let charges = 0;
flakyHost(once(function (id) {
charges++;
console.log('charged for', id, '| total charges:', charges);
}));Example explained
Line 1once returns a new function that closes over called, so the flag survives between invocations.
Line 2flakyHost fires onDone twice; you cannot patch the host, only the function you hand it.
Line 3The second invocation hits the guard and returns early, so charges stays at 1.
Line 4Returning fn(...args) keeps the wrapper transparent for hosts that read the callback's return value.
Important notes
Passing a method by name detaches it from its receiver: setTimeout(counter.tick, 0) calls tick with a this chosen by the host, not counter, so pass () => counter.tick() instead.
A callback's return value matters only if the host reads it: filter and sort depend on it, while forEach and addEventListener discard it entirely.
Common mistakes
Passing handler() instead of handler: the function runs immediately, the host stores its return value, and the intended later call either never happens or blows up with 'is not a function'.
Assuming the host passes only the argument you care about: give forEach a function like saveItem(item, options) and the index 0, 1, 2 arrives as options, so the option lookups silently read from a number.
Reading state on the line after registering an asynchronous callback: you get the value from before the callback ran, and the code seems to work only when the host happens to call back synchronously.
Try it yourself
Change, predict, then run
Write pick(list, test) that loops over list and returns the first item for which test(item, index) is true, then call it with a callback that logs both arguments. Check from the logs that your callback stops being invoked as soon as it returns true.
Open the JavaScript workspaceCheck your understanding
Given [1, 2, 3, 4].forEach(n => { if (n === 3) return; found.push(n); }), what ends up in found and why?
- 1,2,4 — the return ends only that one invocation, and forEach owns the loop and ignores the returned value
- 1,2 — return exits forEach the same way break exits a for loop
- 1,2,3,4 — return inside a callback does nothing, so push still runs for 3
- 3 — forEach restarts from the element whose callback returned early
Show answer
forEach calls the callback once per element and throws away whatever it returns, so the return finishes that single call and the loop continues with 4. Option 1 is tempting because return looks like break, but break only works on a loop you wrote; here the loop lives inside forEach, and stopping early requires a host that reacts to the return value, such as some or find, or a plain for loop. Option 2 misses that return leaves the callback body before push executes.