Topic 2.4
Callbacks and Higher-Order Functions
In one line
A callback is a function you pass to another function. TypeScript works out the callback's parameter types from where you pass it, so you rarely write them, and you can describe functions that return functions.
Think of it like this
Leaving a note with a plumber: "when you finish, call me on this number." You didn't do the job, and you didn't wait; you gave a way to reach you. A callback is that note: a function you hand to someone else so they can call it at the right time. A function that takes or returns other functions is a higher-order function.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Callback
- A function passed to another function so it can be called later.
- Higher-order function
- A function that takes a function as an argument, returns a function, or both.
- Contextual typing
- TypeScript deducing a function's parameter types from where the function is used, so you don't write them.
- Closure
- A function that remembers the variables around it, even after the outer function has finished.
- Event handler
- A callback that runs when something happens, like a click.
- map
- An array method that builds a new array by applying a function to every item.
- filter
- An array method that keeps only the items for which the function returns true.
- reduce
- An array method that folds all items into one value, starting from an initial value.
- Currying
- Turning a function of two arguments into a function of one that returns a function of the other, like
add(1)(2).
Step by step
01Typing a callback parameter
Write the callback's type where the parameter goes: action: (round: number) => void. It reads "a function taking a number and ignoring its result".
Now both sides are checked: the function that takes the callback calls it with a number, and every function you pass in must accept a number.
function repeat(times: number, action: (round: number) => void) {
for (let i = 1; i <= times; i++) action(i);
}
repeat(2, (round) => console.log("round", round));02You don't annotate the inline callback
In repeat(2, (round) => ...) the arrow function has no annotation. TypeScript reads the expected type (round: number) => void and gives round the type number. That is contextual typing.
Hover round in the live example to see number.
03Array methods infer from the callback
names.map((n) => n.length): n is a string because names is a string[]. The result is an array of what the callback returns: number[].
filter keeps the item type, reduce follows the starting value, and find adds undefined for "not found".
const names = ["Asha", "Ravi", "Meera"];
const lengths = names.map((n) => n.length); // number[]
const long = names.filter((n) => n.length > 4); // string[]
const total = lengths.reduce((sum, n) => sum + n, 0); // number04Without a context there is no inference
If you store the arrow function first, no expected type reaches it. Its parameter has no type, and strict mode refuses to guess (TS7006).
Annotate the parameter ((round: number) => ...) or write the function inline.
05A function that returns a function
The return type is itself a function type. The inner function remembers factor from the outer call.
multiplier(3) gives back (n: number) => number, which you can name and call later.
function multiplier(factor: number): (n: number) => number {
return (n) => n * factor;
}
const triple = multiplier(3);
console.log(triple(5)); // 15Try it yourself
- 1
Change what map returns
In the first example change
names.map((n) => n.length)tonames.map((n) => n.length > 4). Predict the new type oflengthsbefore you look:boolean[]. The result type follows the callback's return. - 2
Fix the handler
In the second example fix
handlertwo ways: annotate it ((round: number) =>), or delete it and write the arrow function inline in therepeat(1, ...)call. Which one leaves less to write? - 3
Call a returned function too early
In the third example write
triple("5")andmultiplier("2")(10). Predict which call each error belongs to. The error is reported on the argument that is wrong, even in a chain of calls.
Code & diagrams
Nobody annotated n, name or index: they all came from the context. The last line uses n (a string) in a multiplication, and the error is on n itself.
Console
(nothing was printed)Expected console output
0 ASHA
1 RAVI
2 MEERAThe first call needs no annotation. In the second, the annotation disagrees with the expected type. In the third, handler was written alone, with no context, so round has no type.
Console
(nothing was printed)Expected console output
round 1
round 2
1
2
1The inner arrow in multiplier gets n: number from the declared return type. add's type is inferred as a function returning a function. once wraps any callback so it runs only the first time.
Console
(nothing was printed)Expected console output
15 20
3
hellofind may find nothing, so its result includes undefined. filter(Boolean) removes null at runtime but the type still says (string | null)[]. An empty array literal starts as any[] and TypeScript tracks what you push into it.
Console
(nothing was printed)Expected console output
[ 2, 4 ] 3 [ '1', '3' ]Break it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Save a callback before passing it
Write const handler = (round) => console.log(round); and pass handler to repeat.
Break #2
Annotate a callback with the wrong type
Pass (round: string) => console.log(round) where (round: number) => void is expected.
Myth vs fact
Myth
I have to annotate the parameters of every callback.
Fact
Not when the callback is written where its type is known. The context supplies them; annotating adds nothing and can only go wrong.
Myth
filter(Boolean) removes null and undefined from the type.
Fact
At runtime yes; in the type system no. The result type is the same as the input. Use a type guard function (Topic 4.7) when you need the type narrowed.
Myth
A callback with fewer parameters than the caller provides is an error.
Fact
It is fine. Extra arguments are simply ignored, like (item) => ... passed to forEach. Only the opposite, requiring more than are given, is an error.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
Contextual typing flows from the expected type into the expression. It works for arrow functions, function expressions, object literal methods and array literal elements, and it also drives inference of generic parameters (Phase 7). It is a big part of why you write so few annotations in idiomatic TypeScript.
- ▸
Parameter bivariance matters here: in method declarations, TypeScript compares parameters bivariantly (either direction may fit), but with
strictFunctionTypes(part ofstrict) function-typed properties and callbacks are checked contravariantly (the callback must accept at least what the caller will pass). This is why(round: string) => voidis rejected for(round: number) => void. Topic 16.5 revisits this. - ▸
An empty array literal
[]is typedany[]only while it is being built (an evolving array type): afterempty.push(1)its type becomesnumber[]. If you initialisereducewith[], the accumulator is inferred asnever[]and rejects every item you add; give the start value a type:[] as number[], or pass the type explicitly (Phase 7). - ▸
Passing a method as a callback (
names.map(parseInt)) type-checks if the shapes line up, but can still be wrong at runtime. Arity compatibility only means "will not crash", not "does what you want".
Remember this
- 1
The problem this solves: A function passed to another function can have the wrong shape, and the mistake appears only when it is called later. A function type describes the shape the receiver expects. JavaScript is full of callbacks.
names.map((n) => n.length)givesmapa function to apply to each item.button.addEventListener("click", handler)hands the browser a function to call later.setTimeout(fn, 1000)asks for a call after a second. To type them you write the callback's own type as a parameter:action: (round: number) => voidsays "a function that receives a number". - 2
You almost never need to annotate the callback you write at the call site. TypeScript knows what the receiving parameter expects, so it fills in your parameters' types from there. This is contextual typing: the context (the place the function appears) decides the types. In
names.map((n) => n.length),nis astringbecausenamesis astring[]. Hovernto confirm. Annotating it anyway is allowed, but a wrong annotation is an error (TS2345, with lines explaining which parameter disagrees). - 3
Contextual typing only works when there is a context. If you first save the callback in a variable with no type (
const handler = (round) => ...) and pass it afterwards, the function is on its own:roundhas no type, and strict mode reportsTS7006: Parameter 'round' implicitly has an 'any' type. Fix it by annotating the variable's parameter, or by writing the function inline where it is used. - 4
A function that gets fewer parameters than the callback type offers is fine: JavaScript calls
forEachwith(item, index, array), and(item) => ...just ignores the rest. A callback needing more parameters than the caller provides is an error. This is why callbacks of different lengths can be passed to the same place. - 5
Functions can return functions.
function multiplier(factor: number): (n: number) => numberreturns a function that remembersfactor(a closure, see the JavaScript course). The return type is a function type, somultiplier(3)(5)is checked: the first call gets a number, the result is a function, and its call gets a number. Arrow functions can chain the same way:const add = (a: number) => (b: number) => a + b. - 6
The array methods you use every day are higher-order functions, and TypeScript infers their results from the callback:
mapreturns an array of whatever the callback returns,filterkeeps the same item type,reducereturns the type of its starting value,findreturns the item type orundefined. Their exact declarations use generics (Phase 7); here you only need to read the results. Two traps:filter(Boolean)does not removenullfrom the type, andreducewith an empty-array start ([]) infersnever[], which rejects every item you try to add.
Explain it without notes
What is a callback and how do you type one?
What is contextual typing, and when does it not apply?
How does TypeScript decide the type that map, filter and find return?
How would you type a function that returns a function?
Practice
Write function applyTwice(fn: (n: number) => number, start: number): number that returns fn(fn(start)). Call it with an inline arrow function and check what type n gets. Then call it with a function that takes a string and note the error.
Given const words = ["apple", "fig", "banana"], use map and filter to produce the uppercase words longer than 3 letters. What is the type of the result?
Write function counter(): () => number that returns a function counting 1, 2, 3 on each call. Call it twice and show that two counters have separate counts.
Trade-offs
- ↔
Inline callbacks get types for free, but long inline callbacks hurt readability. Pulling them into a named function means you must annotate it (or give the variable a function type), which is also what makes it testable.
- ↔
Returning functions (currying, factories) is flexible and keeps data private in closures, but call chains like
add(1)(2)are harder to read and debug than a plain two-argument function. Use them when you need to configure once and reuse many times.
Done when you can
I can write a callback parameter type and explain what it promises.
I can explain contextual typing and why a detached callback needs annotations.
I can read the result types of
map,filter,findandreduce.I can type a function that returns a function and call it in a chain.
I can explain why
filter(Boolean)doesn't change the type.