Avoid Using Functions as Callbacks Unless They Are Designed for It – Common Pitfalls in JavaScript and TypeScript
The article explains how using functions that were not intended as callbacks—such as library utilities, Web APIs, or option objects—can break when array.map or other higher‑order functions pass extra arguments, and it shows safer patterns, TypeScript behavior, and linting hints to prevent these bugs.
Original article: Don’t use functions as callbacks unless they’re designed for it (Jake Archibald). Translator: DaBai.
This pattern, which seems to be resurfacing, involves passing a function that was not meant to be used as a callback to Array.prototype.map or similar APIs.
Example converting numbers to human‑readable strings:
// Convert some numbers to a human‑readable string
import { toReadableNumber } from 'some-library';
const readableNumbers = someNumbers.map(toReadableNumber);The implementation of toReadableNumber in the external library is simple:
// some-library
export function toReadableNumber(num) {
// Convert num to a readable string and return
// e.g., 10000000 might become '10,000,000'
}When the library was updated, the code started to fail because the library never intended toReadableNumber to be used as an array.map callback. The map method actually calls the callback with three arguments (item, index, array), so the function receives extra parameters it does not expect.
Illustrating the real call signature:
// We think this is equivalent:
const readableNumbers = someNumbers.map(toReadableNumber);
// But it actually runs like this:
const readableNumbers = someNumbers.map((item, index, arr) =>
toReadableNumber(item, index, arr)
);Because the original toReadableNumber only accepted one parameter, the extra arguments caused errors after the library added a second optional parameter with a default value:
export function toReadableNumber(num, base = 10) {
// Convert num to a readable string, default base 10
}The safe fix is to wrap the library function in an arrow function that forwards only the intended argument:
// Explicitly pass only the number
const readableNumbers = someNumbers.map(n => toReadableNumber(n));Web platform functions also suffer from the same issue
Example creating a promise for the next animation frame:
const nextFrame = () => new Promise(requestAnimationFrame);This works today because requestAnimationFrame only accepts a single callback, but if a future browser version adds extra parameters, the code could break.
Another classic example is using parseInt as a callback:
const parsedInts = ['-10','0','10','20','30'].map(parseInt);
// Result: [-10, NaN, 2, 6, 12] because parseInt receives (string, radix)Option objects can have the same problem
Chrome 90 allows an AbortSignal to be passed as an option to addEventListener for cancelling listeners:
const controller = new AbortController();
const { signal } = controller;
el.addEventListener('mousemove', callback, { signal });
el.addEventListener('pointermove', callback, { signal });
el.addEventListener('touchmove', callback, { signal });
// Later:
controller.abort();Some code mistakenly passes the controller itself as the third argument:
const controller = new AbortController();
const { signal } = controller;
el.addEventListener(name, callback, controller);While this works now because the controller object only shares the signal property with the expected options shape, future additions (e.g., a capture flag) could cause unexpected behavior. The recommended approach is to create a dedicated options object:
const controller = new AbortController();
const options = { signal: controller.signal };
el.addEventListener(name, callback, options);
// Or reuse the destructured signal:
const { signal } = controller;
el.addEventListener(name, callback, { signal });A lint rule (e.g., eslint-plugin-unicorn/no-array-callback-reference ) can catch some of these patterns.
TypeScript cannot solve this problem
TypeScript correctly flags a call that passes too many arguments to a function with a single parameter, but it allows a function to be passed as a callback where the callback signature is broader:
function oneArg(arg1: string) {
console.log(arg1);
}
oneArg('hello', 'world'); // Error: Expected 1 argument, got 2.However, TypeScript accepts this:
function twoArgCallback(cb: (arg1: string, arg2: string) => void) {
cb('hello', 'world');
}
twoArgCallback(oneArg); // No errorBecause the language treats the extra arguments as harmless in the callback context, it provides a special‑case allowing the pattern. This behavior is understandable for JavaScript compatibility, but it means TypeScript cannot prevent the underlying issue of functions being used as callbacks with unintended parameters.
The article suggests that future WebIDL or browser APIs could emit console warnings when extra arguments are supplied, but such changes must remain backward compatible.
Option objects and TypeScript
Consider a function that destructures an options object:
interface Options {
reverse?: boolean;
}
function whatever({ reverse = false }: Options = {}) {
console.log(reverse);
}Passing an object that also contains prototype methods (e.g., toString , valueOf ) is currently allowed, even though the function only cares about reverse . A stricter check could ignore built‑in Object properties.
In summary, developers should avoid using functions as callbacks or option objects unless those functions were explicitly designed for that role, and they can rely on linting tools to catch many accidental cases.
Thanks to the author’s podcast partner Surma and reviewer Ryan Cavanaugh for their feedback.
ByteFE
Cutting‑edge tech, article sharing, and practical insights from the ByteDance frontend team.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.