Learn JS Series (#15) - First-Class Functions: Passing, Returning, and Storing Functions

Words
2846
Reading
13 min
Listen
Play
13d

Learn JS Series (#15) - First-Class Functions: Passing, Returning, and Storing Functions

js-banner.png

What will I learn

  • You will learn what "first-class functions" actually means, and why it is the key idea that gives JavaScript its character;
  • how to store functions in variables, arrays, and objects, just like any other value;
  • how to pass a function as an argument to another function (the pattern behind map, filter, event handlers, and timers);
  • how to return a function from a function, and build small, tailored functions on the fly;
  • the difference between referring to a function and calling it -- a one-character distinction that causes real, hard-to-spot bugs.

Requirements

  • A working modern computer running macOS, Windows or Ubuntu;
  • An installed Node.js (20+) distribution, or just a modern browser console;
  • Episodes 1-14 read, so functions, objects, and bracket access are second nature.

Difficulty

  • Beginner

Curriculum (of the Learn JS Series):

Learn JS Series (#15) - First-Class Functions: Passing, Returning, and Storing Functions

Welcome to Phase 2. In episode 14 we closed Phase 1 by building a real command-line tool, and I promised that the next stretch would go deep on the single feature that gives JavaScript its real character. Here it is. Everything in this phase -- closures, higher-order functions, callbacks, currying, the whole async model we reach later -- grows from one deceptively small idea: in JavaScript, functions are values.

That sentence sounds almost too simple to matter. It does not. Once it truly clicks, a huge chunk of the language that used to look like magic (why you can hand a function to setTimeout, why map takes a function, why event handlers work the way they do) suddenly reads as the most natural thing in the world. So we are going to take it seriously and slowly, because this is the foundation the rest of Phase 2 is built on top of.

What "first-class" means

When we say a language has first-class functions, we mean functions are treated exactly like any other value -- a number, a string, an object. You can store a function in a variable, put it in an array, make it a property of an object, pass it into another function as an argument, and return it out of a function as a result. There is nothing special or ceremonial about it. A function is just data that happens to be callable.

We have quietly been relying on this since episode 1, every single time we wrote const greet = function () { ... }. That line is not "defining a function" in some separate namespace -- it is creating a function value and binding it to a const, the same way const x = 5 binds a number. Let's now do it deliberately and watch how much power falls out of it:

const sayHi = function () {
  return "hi";
};

console.log(typeof sayHi); // "function" - it is a value, and it has a type
console.log(sayHi);        // [Function: sayHi] - the function itself
console.log(sayHi());      // "hi" - the () runs it and gives back its return value

Look carefully at those last two lines, because they are the whole episode in miniature. The name sayHi holds a function value. Reading sayHi gives you the function itself -- an object you can pass around. Adding () invokes it and gives you back whatever it returns. Keep that pair in your head: sayHi is the thing, sayHi() is the result of running the thing. We will come back to it hard in a few minutes, because getting them confused is one of the most common beginner bugs there is.

Functions in variables, arrays, and objects

Because a function is an ordinary value, it can live anywhere a value can live. In a variable, obviously -- we just did that. But also inside an array, sitting right next to numbers and strings as if it were one:

const operations = [
  (n) => n + 1,
  (n) => n * 2,
  (n) => n - 3,
];

console.log(operations.length); // 3 - it is a normal array of three items
console.log(operations[1](10)); // 20 - grab the 2nd function, then call it with 10

That double-bracket-then-paren, operations[1](10), trips people up the first time, so let's read it left to right. operations[1] looks up index 1 and hands you back the second function (the doubler). Then the trailing (10) calls that function with 10. Two separate steps glued together: fetch the value, then invoke it. Nothing more mysterious than scores[1] + 5 would be.

Functions also live perfectly happily as object properties -- which, as it happens, is exactly what a "method" is. A method is nothing more special than a function stored under a key:

const calculator = {
  add: (a, b) => a + b,
  sub: (a, b) => a - b,
};

console.log(calculator.add(2, 3)); // 5  - dot access, then call

const op = "sub";
console.log(calculator[op](9, 4)); // 5  - pick the function by a string key, then call

That last line is quietly powerful, and it leans directly on the bracket-access we learned back in episode 12. We chose which function to run using a string variable, op, at runtime. Think about what that enables: the operation to perform can come from user input, from a config file, from a network message -- anywhere -- and we look it up in an object and run it. A table of functions keyed by name is one of the cleanest tools in the language, and we will build a proper one in a moment.

Passing a function as an argument

Here is where first-class functions stop being a curiosity and start being genuinely useful. Because a function is a value, you can hand one to another function, exactly the way you would hand it a number. The array methods you already met -- map, filter, reduce, forEach -- all work precisely this way: you give them a function, and they call it for you, at the right moments, with the right arguments.

Let's build our own function that accepts a function, so the pattern is not just something the standard library does behind a curtain:

function repeat(times, action) {
  for (let i = 0; i < times; i++) {
    action(i); // call the function we were handed, passing the current index
  }
}

repeat(3, (i) => console.log(`iteration ${i}`));
// iteration 0
// iteration 1
// iteration 2

Notice that repeat has no idea what action does, and it does not care. Its one job is to call action some number of times and pass it the loop counter. We handed in a small function that logs, but we could hand in anything. That separation -- generic machinery here, specific behaviour handed in there -- is the beating heart of reusable code. A function you accept as a parameter and call later has a name: it is a callback, and it is the pattern we build on for the rest of the series.

The function you pass does not have to be written inline. You can pass a named function by its bare name too, which reads beautifully when the behaviour is reused in more than one place:

function announceStep(i) {
  console.log(`step ${i} done`);
}

repeat(2, announceStep); // pass the function BY NAME - no parentheses
// step 0 done
// step 1 done

Read that call again and let it sink in: repeat(2, announceStep), not repeat(2, announceStep()). We are handing repeat the function itself so that repeat can call it whenever it likes. That distinction -- bare name versus name-with-parentheses -- is so important, and so easy to get wrong, that it deserves its own section. Having said that, let's tackle it head on.

The call-versus-reference bug (the one that bites everyone)

There is a critical difference between referring to a function (action) and calling it (action()). Referring to it gives you the function value. Calling it runs the function right now and gives you back whatever it returned. When you want to hand a function to something that will call it later -- a timer, an event handler, an array method -- you pass the bare name, without parentheses:

function boom() {
  return "BOOM";
}

const good = boom;   // REFERENCE: 'good' now IS the function
const bad = boom();  // CALL:      'bad' is "BOOM", the string it returned

console.log(typeof good); // "function" - we kept the function
console.log(typeof bad);  // "string"   - we kept the result of running it
console.log(good());      // "BOOM"     - 'good' is callable, so we can run it

Now watch how this exact mix-up produces a real bug. Timers and event handlers expect you to give them a function they will call at the right moment. If you accidentically add (), you call the function immediately and hand over its return value instead of the function -- and then the timer has nothing useful to call:

function sayLater() {
  console.log("one second has passed");
}

// WRONG: this RUNS sayLater right now, and passes its return value (undefined)
// setTimeout(sayLater(), 1000);   // logs immediately, timer gets undefined

// RIGHT: pass the function itself; setTimeout calls it after ~1000ms
// setTimeout(sayLater, 1000);     // nothing now, message after one second

(I have left those two lines commented so this block stays a clean, runnable file -- uncomment them in your own console to feel the difference.) The symptom of this bug is almost always the same confused question: "why did my handler run immediately instead of when I clicked / when the timer fired?" The answer is nearly always a stray pair of parentheses. sayLater is the function; sayLater() is what you get by running it. When something else is supposed to do the calling, you give it the bare name and get out of its way.

Returning a function from a function

Now the flip side, and it is where things get properly fun. A function can produce a function. It can build one, customize it, and hand it back to you. A function whose job is to manufacture other functions is often called a factory:

function makeMultiplier(factor) {
  return function (n) {
    return n * factor;
  };
}

const triple = makeMultiplier(3);
const tenX = makeMultiplier(10);

console.log(triple(5)); // 15
console.log(tenX(5));   // 50
console.log(triple(2)); // 6

makeMultiplier(3) returns a brand new function that multiplies its input by 3. makeMultiplier(10) returns a different new function that multiplies by 10. Each returned function remembers the factor it was born with, even though makeMultiplier has long since finished running. That remembering is called a closure, and it is so central that the very next episode is devoted entirely to it. For now, just savour the shape: you can write a function that stamps out specialized functions, tailored to whatever you pass in.

This is not an academic toy. Returning functions lets you write small, general-purpose wrappers that add behaviour to any function you feed them. Here is a classic one, once, which takes a function and returns a new version that will only ever run a single time -- handy for one-off setup that must never happen twice:

function once(fn) {
  let called = false;
  let saved;
  return function (...args) {
    if (!called) {
      called = true;
      saved = fn(...args); // run the real function the first time, remember the result
    }
    return saved;          // every later call just returns that saved result
  };
}

const setup = once(() => {
  console.log("running expensive setup...");
  return 42;
});

console.log(setup()); // "running expensive setup..." then 42
console.log(setup()); // 42  (the setup line does NOT print again)
console.log(setup()); // 42

Look at everything that came together there: once accepts a function (fn), returns a function (the wrapper), and the returned wrapper remembers state (called and saved) between calls via closure. Passing, returning, and remembering -- three ideas from this phase in eleven lines. This is the sort of small, sharp utility that first-class functions make trivial, and that clunkier languages need whole design patterns to imitate.

A dispatch table: retiring the big switch

Let me show you the single most practical everyday payoff of "functions in objects", because you will reach for it constantly. Back in episode 7 we used switch to branch on a value. That is fine for a handful of cases, but a long switch gets ugly fast and is annoying to extend. When each branch is really just "run this bit of code", you can store those bits as functions in an object and look them up by key. This is called a dispatch table:

const handlers = {
  add: (a, b) => a + b,
  sub: (a, b) => a - b,
  mul: (a, b) => a * b,
  div: (a, b) => (b === 0 ? NaN : a / b),
};

function calculate(op, a, b) {
  const handler = handlers[op];
  if (typeof handler !== "function") {
    throw new Error(`unknown operation: "${op}"`);
  }
  return handler(a, b);
}

console.log(calculate("mul", 6, 7)); // 42
console.log(calculate("div", 10, 0)); // NaN

Compare this to the equivalent switch. Adding a new operation here means adding one line to the handlers object -- no touching the calculate logic at all. The typeof handler !== "function" check is our guard against an unknown key (if op is "pow", handlers["pow"] is undefined, and calling undefined() would throw an ugly TypeError, so we catch it first and throw a clear message instead). Dispatch tables scale gracefully to dozens of cases, they are easy to test one entry at a time, and they read like a tidy list of capabilities rather than a sprawling branch. Quite some real-world routers, command parsers, and state machines are, under the hood, exactly this: an object full of functions and a lookup.

First-class functions in other languages

Many of you came over from the Learn Python Series (and a few from Rust and Go), so a look sideways helps place JavaScript in context. The good news: first-class functions are not a JavaScript invention, and seeing them elsewhere makes the concept feel less like a language quirk and more like a fundamental tool.

Python has first-class functions too, and the factory pattern looks almost identical to ours:

def make_multiplier(factor):
    def multiply(n):
        return n * factor
    return multiply          # return the inner function (no parentheses!)

triple = make_multiplier(3)
print(triple(5))             # 15

# functions in a dict - Python's dispatch table
handlers = {
    "add": lambda a, b: a + b,
    "mul": lambda a, b: a * b,
}
print(handlers["mul"](6, 7)) # 42

Notice the same bare-name-versus-call distinction: return multiply returns the function, return multiply() would call it and return its result. Python even has the identical trap -- sorted(nums, key=len) passes the len function by name, and beginners routinely write key=len() and get an error. The idea travels perfectly.

Rust has first-class functions as well, but its type system makes you name the shapes precisely. A function that returns a function has to spell out that it is returning "some type that implements the callable trait", and closures that capture a variable use the move keyword:

fn make_multiplier(factor: i32) -> impl Fn(i32) -> i32 {
    move |n| n * factor      // a closure that captures 'factor'
}

fn main() {
    let triple = make_multiplier(3);
    println!("{}", triple(5)); // 15
}

Where JavaScript lets you sling functions around with zero ceremony, Rust asks you to state the types up front -- more typing, but the compiler then guarantees you never call something that is not callable. Same capability, different amount of paperwork, which is the recurring theme every time we compare these languages.

Go also treats functions as values, and its factory reads very close to JavaScript's, just with explicit type signatures on the parameters and return:

package main

import "fmt"

func makeMultiplier(factor int) func(int) int {
    return func(n int) int {
        return n * factor
    }
}

func main() {
    triple := makeMultiplier(3)
    fmt.Println(triple(5)) // 15
}

The throughline across all four languages: a function is a value you can store, pass, and return. JavaScript simply has the loosest, most casual syntax for it (no type annotations, no move, no ceremony), which is exactly why the idea can feel invisible in JS until someone points at it -- as I am doing now. ;-)

Why this matters so much

Step back and take in what first-class functions actually buy you. You can:

  • Store behaviour in data structures -- a lookup table of functions, an array of steps, a config object full of handlers.
  • Pass behaviour into generic algorithms -- give map your transformation, give repeat your action, give an event system your reaction.
  • Generate specialized behaviour at runtime -- a factory that stamps out tailored functions, or a wrapper like once that adds behaviour to any function you feed it.

Languages that lack first-class functions have to reach for heavier machinery -- interfaces, function pointers, anonymous classes, whole design patterns -- to accomplish these same three things. JavaScript makes them natural and nearly invisible. And virtually every elegant pattern you will meet later, from event handling to functional data pipelines to the async control flow that runs the modern web, rests squarely on this one property. Here is a tiny taste that uses all three ideas at once:

const strategies = {
  loud: (s) => s.toUpperCase(),
  quiet: (s) => s.toLowerCase(),
};

function transform(text, strategy) {
  return strategy(text); // behaviour passed in, called here
}

console.log(transform("Hello", strategies.loud));  // "HELLO"
console.log(transform("Hello", strategies.quiet)); // "hello"

We stored functions in an object, picked one out by name, and passed it into a generic transform that neither knows nor cares which strategy it received. That miniature example -- store, select, pass -- is the seed of an enormous amount of real, professional JavaScript. Get genuinely comfortable here, and the forementioned "magic" of the rest of the language stops being magic and starts being obvious.

Try it yourself

Three exercises, increasing in difficulty. Type them out and run them -- reading is not the same as knowing. Full solutions open the next episode.

  1. Build an object mathOps holding functions add, sub, and mul. Then write compute(a, b, opName) that looks up the operation by its string name (bracket access!), guards against an unknown name, and calls it. Test compute(6, 4, "mul") and compute(6, 4, "nope").
  2. Write a function applyTwice(fn, value) that calls fn on value, then calls fn again on that result, and returns the final answer. Test it with a "double" function and the value 3 (you should get 12), and with a "shout" function that uppercases a string.
  3. Write a factory makeGreeter(greeting) that returns a function taking a name and returning "<greeting>, <name>!". Create a hi greeter and a bye greeter from it and call each. Then, in one sentence of a comment, explain why writing makeGreeter("Hi")() on one line (with the extra parentheses) is usually a mistake when what you actually want is a reusable greeter to keep around.

So what did we actually cover?

  • First-class functions means functions are ordinary values: storable, passable, and returnable, just like numbers, strings, and objects.
  • You can put functions in variables, arrays, and objects; a function stored under an object key is exactly what a "method" is.
  • Passing a function to another function (as map, filter, and our repeat do) lets you hand behaviour into generic code -- the callback pattern, which powers the rest of the series.
  • Referring to a function (fn) is not the same as calling it (fn()); pass the bare name when something else should do the calling later, or you will trigger the "why did my handler run immediately?" bug.
  • A function can return a function (a factory, or a wrapper like once), producing specialized behaviour at runtime -- powered by closures, which we tackle next.
  • A dispatch table (an object full of functions, looked up by key) is a clean, scalable replacement for a sprawling switch.
  • Python, Rust, and Go all have first-class functions too; JavaScript just has the most casual syntax for them.

Next episode we zoom in on arrow functions versus the function keyword: their syntax differences, and the one big behavioural difference around this that quietly decides which of the two you should reach for.

Thanks for reading, and see you in the next one.

scipio@scipio

Learn JS Series (#15) - First-Class Functions: Passing, Returning, ... | Ecency