Command Palette

Search for a command to run...

Hectal
PHASE 2Beginner ~26 min· topic 4 of 7

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.

repeat.tswhole filets
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".

arrays.tswhole filets
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); // number

04Without 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.

multiplier.tswhole filets
function multiplier(factor: number): (n: number) => number {
  return (n) => n * factor;
}
const triple = multiplier(3);
console.log(triple(5)); // 15
A function that returns a functiondiagram
Rendering diagram…

Try it yourself

  1. 1

    Change what map returns

    In the first example change names.map((n) => n.length) to names.map((n) => n.length > 4). Predict the new type of lengths before you look: boolean[]. The result type follows the callback's return.

  2. 2

    Fix the handler

    In the second example fix handler two ways: annotate it ((round: number) =>), or delete it and write the arrow function inline in the repeat(1, ...) call. Which one leaves less to write?

  3. 3

    Call a returned function too early

    In the third example write triple("5") and multiplier("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

Contextual typing in array methods

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 MEERA
Typing a callback parameter, and when inference is missing

The 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
1
Functions that return functions

The 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
hello
filter, find and the empty-array trap

find 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.

terminal
$ Types panel
── what you'll see ──
index.ts(6,18): error TS7006: Parameter 'round' implicitly has an 'any' type.

Break #2

Annotate a callback with the wrong type

Pass (round: string) => console.log(round) where (round: number) => void is expected.

terminal
$ Types panel
── what you'll see ──
index.ts(5,11): error TS2345: Argument of type '(round: string) => void' is not assignable to parameter of type '(round: number) => void'.

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 of strict) function-typed properties and callbacks are checked contravariantly (the callback must accept at least what the caller will pass). This is why (round: string) => void is rejected for (round: number) => void. Topic 16.5 revisits this.

  • ▸

    An empty array literal [] is typed any[] only while it is being built (an evolving array type): after empty.push(1) its type becomes number[]. If you initialise reduce with [], the accumulator is inferred as never[] 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. 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) gives map a 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) => void says "a function that receives a number".

  2. 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), n is a string because names is a string[]. Hover n to confirm. Annotating it anyway is allowed, but a wrong annotation is an error (TS2345, with lines explaining which parameter disagrees).

  3. 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: round has no type, and strict mode reports TS7006: 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. 4

    A function that gets fewer parameters than the callback type offers is fine: JavaScript calls forEach with (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. 5

    Functions can return functions. function multiplier(factor: number): (n: number) => number returns a function that remembers factor (a closure, see the JavaScript course). The return type is a function type, so multiplier(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. 6

    The array methods you use every day are higher-order functions, and TypeScript infers their results from the callback: map returns an array of whatever the callback returns, filter keeps the same item type, reduce returns the type of its starting value, find returns the item type or undefined. Their exact declarations use generics (Phase 7); here you only need to read the results. Two traps: filter(Boolean) does not remove null from the type, and reduce with an empty-array start ([]) infers never[], which rejects every item you try to add.

Explain it without notes

01

What is a callback and how do you type one?

02

What is contextual typing, and when does it not apply?

03

How does TypeScript decide the type that map, filter and find return?

04

How would you type a function that returns a function?

Practice

01

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.

02

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?

03

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, find and reduce.

  • 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.