Emmanuel Akinfulubi

Functions all 4 written

Parameters, closures, this, and functions as values: the machinery underneath every callback in the rest of the course.

9 Functions: parameters, defaults and returns

A function is a named, reusable piece of a program: it takes some values in, does something, and usually hands a value back. Everything from here on is built from them, and in Node — where you pass functions to other functions constantly — they matter even more than usual.

Three ways to write one

Declaration, expression, arrow

ran in node · runs here too

example.mjs

// A function declaration.
function area(width, height) {
  return width * height;
}

// A function expression, stored in a const.
const perimeter = function (width, height) {
  return 2 * (width + height);
};

// An arrow function. With one expression, the braces and "return" can go.
const diagonal = (width, height) => Math.hypot(width, height);

console.log(area(3, 4), perimeter(3, 4), diagonal(3, 4));

check("all three are just functions", [area, perimeter, diagonal].every((f) => typeof f === "function"), true);
check("3-4-5", diagonal(3, 4), 5);

What node printed, when the build ran it

node example.mjs

12 14 5
ok   all three are just functions
ok   3-4-5

For now the differences hardly matter. Declarations can be called from above the line where they are written (lesson 10 explains why), and arrow functions handle this differently (lesson 11). Most modern code uses declarations for named, top-level functions and arrows for small functions passed to other functions.

Arguments that do not match

JavaScript does not check that a call passes the right number of arguments. Too few, and the missing ones are undefined. Too many, and the extras are ignored.

Too few, too many

ran in node · runs here too

example.mjs

function greet(first, last) {
  return `Hello, ${first} ${last}`;
}

console.log(greet("Ada", "Lovelace"));
console.log(greet("Ada"));                         // last is undefined
console.log(greet("Ada", "Lovelace", "Byron"));    // "Byron" is silently dropped

check("a missing argument is undefined, not an error", greet("Ada"), "Hello, Ada undefined");
check("the function says how many it expects", greet.length, 2);

What node printed, when the build ran it

node example.mjs

Hello, Ada Lovelace
Hello, Ada undefined
Hello, Ada Lovelace
ok   a missing argument is undefined, not an error
ok   the function says how many it expects

Defaults

Default parameters

ran in node · runs here too

example.mjs

function makeTag(label, colour = "grey", size = label.length > 8 ? "wide" : "normal") {
  return { label, colour, size };
}

console.log(makeTag("draft"));
console.log(makeTag("needs review"));
console.log(makeTag("urgent", "red"));
console.log(makeTag("urgent", undefined, "wide"));   // undefined means "use the default"

check("a default can use an earlier parameter", makeTag("needs review").size, "wide");
check("passing undefined triggers the default", makeTag("x", undefined).colour, "grey");
check("passing null does not", makeTag("x", null).colour, null);

What node printed, when the build ran it

node example.mjs

{ label: 'draft', colour: 'grey', size: 'normal' }
{ label: 'needs review', colour: 'grey', size: 'wide' }
{ label: 'urgent', colour: 'red', size: 'normal' }
{ label: 'urgent', colour: 'grey', size: 'wide' }
ok   a default can use an earlier parameter
ok   passing undefined triggers the default
ok   passing null does not

A default applies when the argument is undefined — whether because nothing was passed or because undefined was passed on purpose. null is a real value, and does not trigger it.

A default is evaluated on every call

ran in node · runs here too

example.mjs

function addItem(item, list = []) {
  list.push(item);
  return list;
}

const first = addItem("a");
const second = addItem("b");

console.log(first, second);

check("each call gets its own fresh array", first === second, false);
check("so the second call did not see the first item", second, ["b"]);

What node printed, when the build ran it

node example.mjs

[ 'a' ] [ 'b' ]
ok   each call gets its own fresh array
ok   so the second call did not see the first item

The default expression runs fresh on every call that needs it, so each call gets a new array. That sounds obvious, and it is worth saying because Python does the opposite — a default list there is created once and shared, and the course next door spends a whole lesson on the bugs that causes.

Any number of arguments: rest parameters

...rest and ...spread

ran in node · runs here too

example.mjs

function total(...amounts) {           // everything passed, collected into an array
  return amounts.reduce((sum, n) => sum + n, 0);
}

function log(level, ...parts) {        // the first named, the rest collected
  return `[${level}] ${parts.join(" ")}`;
}

console.log(total(), total(5), total(1, 2, 3, 4));
console.log(log("info", "server", "started", "on", 8080));

const prices = [4, 12, 7];
console.log(total(...prices));        // spread: an array, passed as separate arguments

check("rest collects into a real array", total(1, 2, 3, 4), 10);
check("spread is the reverse of rest", total(...prices), 23);

What node printed, when the build ran it

node example.mjs

0 5 10
[info] server started on 8080
23
ok   rest collects into a real array
ok   spread is the reverse of rest

What comes back

return

ran in node · runs here too

example.mjs

function noReturn() {
  const x = 1 + 1;
}

function earlyReturn(n) {
  if (n < 0) return "negative";
  return "fine";
}

function twoThings() {
  return { min: 1, max: 9 };        // return an object to hand back several values
}

console.log(noReturn(), earlyReturn(-5), twoThings());

const { min, max } = twoThings();    // and take them apart again (lesson 16)

check("no return statement means undefined", noReturn(), undefined);
check("return exits the function immediately", earlyReturn(-5), "negative");
check("several values, via an object", [min, max], [1, 9]);

What node printed, when the build ran it

node example.mjs

undefined negative { min: 1, max: 9 }
ok   no return statement means undefined
ok   return exits the function immediately
ok   several values, via an object

The return that returns nothing

A line break after return

ran in node · runs here too

example.mjs

function config() {
  return
  {
    port: 8080
  };
}

console.log(config());

check("this returns undefined, not the object", config(), undefined);

What node printed, when the build ran it

node example.mjs

undefined
ok   this returns undefined, not the object

That function returns undefined. JavaScript inserts semicolons where it thinks a statement ends, and a return followed by a line break is a complete statement: it becomes return;. The object on the following lines is then a block that is never reached. Keep the opening brace on the same line as return, always.

Should you write semicolons? JavaScript inserts them automatically in most places, and some style guides omit them. The return case above goes wrong either way — adding semicolons elsewhere would not have saved it. What saves it is a formatter such as Prettier, which would have moved the brace up and made the bug impossible to write.

Functions are values

A function is an object like any other. You can store it, put it in an array, pass it to another function and return it from one. That last pair is how nearly all of Node's asynchronous code is written, and it is the subject of lesson 12.

Storing functions

ran in node · runs here too

example.mjs

const operations = {
  add: (a, b) => a + b,
  multiply: (a, b) => a * b,
};

function apply(name, a, b) {
  const op = operations[name];
  return op(a, b);
}

console.log(apply("add", 3, 4), apply("multiply", 3, 4));
console.log(operations.add.name, "<- an arrow stored in a property still has a name");

check("functions can live in an object", apply("multiply", 3, 4), 12);
check("and know their own name", operations.add.name, "add");

What node printed, when the build ran it

node example.mjs

7 12
add <- an arrow stored in a property still has a name
ok   functions can live in an object
ok   and know their own name

Calling a function that is not there

dies with TypeError

example.mjs

const operations = { add: (a, b) => a + b };

console.log(operations.subtract(5, 2));

What node printed as it died, when the build ran it

node example.mjs

file:///app/example.mjs:3
console.log(operations.subtract(5, 2));
                       ^

TypeError: operations.subtract is not a function
    at file:///app/example.mjs:3:24
    at ModuleJob.run (node:internal/modules/esm/module_job:569:25)
    at async node:internal/modules/esm/loader:650:26
    at async asyncRunEntryPointWithESMLoader (node:internal/modules/run_main:101:5)

Node.js v26.7.0
  • Declaration, expression or arrow. Arrows can drop the braces and return for a single expression.
  • Missing arguments are undefined; extra ones are ignored; nothing is checked.
  • Defaults apply to undefined (not null), can use earlier parameters, and are evaluated fresh on each call.
  • ...rest collects arguments into an array; ...spread expands one into arguments.
  • No return means undefined. Never break the line straight after return.
  • Functions are values: store them, pass them, return them.

10 Scope, closures and hoisting

Where a name can be used is called its scope. That sounds like bookkeeping, and it is — until you pass a function somewhere else and it still remembers the variables around it. That memory is a closure, and it is how every callback in Node works.

Blocks make scopes

Block scope

ran in node · runs here too

example.mjs

const outside = "visible everywhere in this file";

if (true) {
  const inside = "visible only inside these braces";
  console.log(outside);
  console.log(inside);
}

console.log(typeof inside, "<- gone once the block ends");

check("a const inside a block does not leak out", typeof inside, "undefined");

What node printed, when the build ran it

node example.mjs

visible everywhere in this file
visible only inside these braces
undefined <- gone once the block ends
ok   a const inside a block does not leak out

Every pair of braces — an if, a loop, a function body — makes a scope. A const or let declared inside exists only inside. Code inside can see outwards; code outside cannot see in. When a name is used, JavaScript looks in the current scope, then the one enclosing it, and so on outwards until it finds it.

Shadowing

ran in node · runs here too

example.mjs

const level = "outer";

function show() {
  const level = "inner";           // a new name that hides the outer one
  return level;
}

console.log(show(), level);

check("the inner name shadows the outer", show(), "inner");
check("without changing it", level, "outer");

What node printed, when the build ran it

node example.mjs

inner outer
ok   the inner name shadows the outer
ok   without changing it

A function remembers where it was made

A closure

ran in node · runs here too

example.mjs

function makeCounter() {
  let count = 0;                      // lives in makeCounter's scope
  return function next() {
    count += 1;                        // ...and this function can still reach it
    return count;
  };
}

const a = makeCounter();
const b = makeCounter();

console.log(a(), a(), a());
console.log(b());

check("a kept its own count between calls", a(), 4);
check("b has a separate count", b(), 2);

What node printed, when the build ran it

node example.mjs

1 2 3
1
ok   a kept its own count between calls
ok   b has a separate count

makeCounter has finished running, and yet count is still alive, because the function it returned still refers to it. Each call to makeCounter makes a fresh count, so a and b do not share. That is a closure: a function together with the variables it could see when it was created.

It is also how JavaScript does private state without classes. Nothing outside makeCounter can read or change count except by calling next.

Private state, with no class

ran in node · runs here too

example.mjs

function createAccount(opening) {
  let balance = opening;
  return {
    deposit(amount) {
      if (amount <= 0) throw new Error("deposit must be positive");
      balance += amount;
      return balance;
    },
    get balance() {
      return balance;
    },
  };
}

const account = createAccount(100);
account.deposit(50);

let refused;
try {
  account.balance = 1_000_000;         // there is no way in from outside
} catch (err) {
  refused = err.name;
}

console.log("balance:", account.balance);
console.log("setting it directly:", refused);

check("only deposit can change the balance", account.balance, 150);
check("a getter with no setter refuses writes", refused, "TypeError");

What node printed, when the build ran it

node example.mjs

balance: 150
setting it directly: TypeError
ok   only deposit can change the balance
ok   a getter with no setter refuses writes

The loop bug, and why let fixed it

Here is the bug that made let necessary. Three timers are scheduled in a loop, each meant to print its own loop number.

var versus let, in a loop with callbacks

ran at build time

example.mjs

const withVar = [];
const withLet = [];

for (var i = 0; i < 3; i++) {
  setTimeout(() => withVar.push(i), 0);
}
for (let j = 0; j < 3; j++) {
  setTimeout(() => withLet.push(j), 0);
}

setTimeout(() => {
  console.log("var:", withVar);
  console.log("let:", withLet);
  check("all three var callbacks saw the final value", withVar, [3, 3, 3]);
  check("each let callback saw its own", withLet, [0, 1, 2]);
}, 10);

What node printed, when the build ran it

node example.mjs

var: [ 3, 3, 3 ]
let: [ 0, 1, 2 ]
ok   all three var callbacks saw the final value
ok   each let callback saw its own

The timers run after the loop has finished. A var is scoped to the whole function (or file), not to the loop, so there is exactly one i, and by the time any timer runs it is 3. A let in a for header gets a fresh binding on every turn, so each callback closes over its own. This is the single best reason never to write var.

Hoisting

JavaScript reads a whole scope before running any of it, and some declarations take effect from the top of their scope regardless of where they are written. This is called hoisting, and each kind of declaration does it differently.

function and var, used before their lines

ran in node · runs here too

example.mjs

const fromAbove = double(4);         // a function declaration works above its line
const earlyValue = early;            // a var exists already, but holds undefined

function double(n) {
  return n * 2;
}
var early = "assigned now";

console.log(fromAbove, earlyValue, early);

check("function declarations are hoisted whole", fromAbove, 8);
check("var is hoisted without its value", earlyValue, undefined);
check("which arrives when its line runs", early, "assigned now");

What node printed, when the build ran it

node example.mjs

8 undefined assigned now
ok   function declarations are hoisted whole
ok   var is hoisted without its value
ok   which arrives when its line runs

An arrow function is not hoisted

dies with ReferenceError

example.mjs

console.log(triple(4));

const triple = (n) => n * 3;

What node printed as it died, when the build ran it

node example.mjs

file:///app/example.mjs:1
console.log(triple(4));
            ^

ReferenceError: Cannot access 'triple' before initialization
    at file:///app/example.mjs:1:13
    at ModuleJob.run (node:internal/modules/esm/module_job:569:25)
    at async node:internal/modules/esm/loader:650:26
    at async asyncRunEntryPointWithESMLoader (node:internal/modules/run_main:101:5)

Node.js v26.7.0

A function declaration is available from the top of its scope, which is why you can put a program's helper functions at the bottom of a file. An arrow function stored in a const is a const, and follows the const rule from lesson 4: it exists but cannot be read until its line runs.

Nothing leaks into the global scope

Module scope

ran in node · runs here too

example.mjs

const settings = { debug: true };
var legacy = "also module-scoped here";

console.log(globalThis.settings, globalThis.legacy);
console.log(typeof globalThis.console, "<- real globals are still there");

check("a top-level const is not a global", globalThis.settings, undefined);
check("in a module, not even var is", globalThis.legacy, undefined);

What node printed, when the build ran it

node example.mjs

undefined undefined
object <- real globals are still there
ok   a top-level const is not a global
ok   in a module, not even var is

Each module — each .mjs file — has its own top-level scope. Names declared at the top of one file are not visible from another unless exported (lesson 20), and never become properties of globalThis. In an old-style browser script, a top-level var would become a global, which is how two scripts on one page used to break each other.

  • Braces make scopes. Code can see outwards, not in. An inner name can shadow an outer one.
  • A closure is a function plus the variables it could see when it was made. They stay alive as long as the function does.
  • Closures give private state: only the returned functions can reach it.
  • var is function-scoped, so callbacks in a loop share one variable. let gives each turn its own.
  • Function declarations are hoisted whole; var is hoisted as undefined; const and let throw until their line.
  • Each module has its own scope. Nothing becomes a global by accident.

11 this, and why arrow functions do not have one

Inside a function, this refers to the object the function was called on. The catch is in the word called: the same function gives a different this depending on how it is invoked, and the most common way to call a function in Node — passing it to something else — loses it entirely.

The object before the dot

this follows the call

ran in node · runs here too

example.mjs

const user = {
  name: "Ada",
  greet() {
    return `Hi, I am ${this.name}`;
  },
};

const other = { name: "Alan", greet: user.greet };   // the same function, borrowed

console.log(user.greet());
console.log(other.greet());

check("this is the object the method was called on", user.greet(), "Hi, I am Ada");
check("so the same function answers differently", other.greet(), "Hi, I am Alan");

What node printed, when the build ran it

node example.mjs

Hi, I am Ada
Hi, I am Alan
ok   this is the object the method was called on
ok   so the same function answers differently

user.greet() is called on user, so this is user. other.greet() runs exactly the same function, called on other. The rule is: look at the call, and this is whatever is to the left of the dot.

No dot, no this

A detached method

dies with TypeError

example.mjs

const user = {
  name: "Ada",
  greet() {
    return `Hi, I am ${this.name}`;
  },
};

const greet = user.greet;     // take the function off the object
console.log(greet());         // and call it with nothing before the dot

What node printed as it died, when the build ran it

node example.mjs

file:///app/example.mjs:4
    return `Hi, I am ${this.name}`;
                            ^

TypeError: Cannot read properties of undefined (reading 'name')
    at greet (file:///app/example.mjs:4:29)
    at file:///app/example.mjs:9:13
    at ModuleJob.run (node:internal/modules/esm/module_job:569:25)
    at async node:internal/modules/esm/loader:650:26
    at async asyncRunEntryPointWithESMLoader (node:internal/modules/run_main:101:5)

Node.js v26.7.0

Called with nothing before the dot, this is undefined (modules are always in strict mode), and reading .name from undefined throws. You will rarely write const greet = user.greet on purpose. You will constantly write the same thing by accident, by passing a method to another function:

The same mistake, as a callback

ran in node · runs here too

example.mjs

const counter = {
  count: 0,
  increment() {
    this.count++;
  },
};

let failure;
try {
  [1, 2, 3].forEach(counter.increment);   // passes the function, not the object
} catch (err) {
  failure = err.name;
}

console.log("count:", counter.count);
console.log("error:", failure);

check("the method never touched counter", counter.count, 0);
check("it threw, reading count from an undefined this", failure, "TypeError");

What node printed, when the build ran it

node example.mjs

count: 0
error: TypeError
ok   the method never touched counter
ok   it threw, reading count from an undefined this

Three fixes

Keeping this

ran in node · runs here too

example.mjs

const counter = {
  count: 0,
  increment() {
    this.count++;
  },
};

[1, 2].forEach(() => counter.increment());        // 1. call it on the object yourself
[1, 2].forEach(counter.increment.bind(counter));  // 2. bind: fix this permanently
[1, 2].forEach(counter.increment, counter);       // 3. some methods take a thisArg

console.log(counter.count);

check("all three kept this", counter.count, 6);

What node printed, when the build ran it

node example.mjs

6
ok   all three kept this

The first is the most common in modern code, because it is the plainest: the arrow function calls counter.increment() with counter before the dot, so there is nothing to lose. bind returns a new function whose this is fixed forever.

Arrow functions have no this of their own

An arrow function does not get a this from its call. It uses the this of the code around it, exactly as it would use any other outer variable. That makes arrows the right choice inside a method, and the wrong choice as a method.

Arrows inside methods, not as them

ran in node · runs here too

example.mjs

const team = {
  name: "Platform",
  members: ["Ada", "Alan"],

  // A regular method, with an arrow inside it: the arrow borrows the method's this.
  roster() {
    return this.members.map((m) => `${m} (${this.name})`);
  },

  // An arrow AS the method: its this is the module's top level, which is undefined.
  broken: () => typeof this,
};

console.log(team.roster());
console.log(team.broken());

check("the inner arrow saw the method's this", team.roster()[0], "Ada (Platform)");
check("an arrow used as a method has no object to see", team.broken(), "undefined");

What node printed, when the build ran it

node example.mjs

[ 'Ada (Platform)', 'Alan (Platform)' ]
undefined
ok   the inner arrow saw the method's this
ok   an arrow used as a method has no object to see

call and apply

Choosing this explicitly

ran in node · runs here too

example.mjs

function describe(greeting, punctuation) {
  return `${greeting}, ${this.name}${punctuation}`;
}

const ada = { name: "Ada" };

console.log(describe.call(ada, "Hello", "!"));      // this, then the arguments
console.log(describe.apply(ada, ["Hello", "?"]));   // this, then an array of them

check("call sets this for one call", describe.call(ada, "Hi", "."), "Hi, Ada.");

What node printed, when the build ran it

node example.mjs

Hello, Ada!
Hello, Ada?
ok   call sets this for one call

You will meet call and apply in older code and in libraries more than you will write them. They show that this is just a hidden extra argument, one that JavaScript normally fills in from whatever is before the dot.

Two Node specifics

A regular function passed to a timer is called by Node's timer machinery, and gets its object as this. It is never the object you wanted.

this in a timer callback

ran at build time

example.mjs

const job = {
  name: "nightly report",
  start() {
    setTimeout(function () {
      console.log("regular function sees:", this.constructor.name);
      console.log("regular function name:", this.name);
    }, 0);
    setTimeout(() => {
      console.log("arrow function sees:  ", this.name);
      check("the arrow kept the job", this.name, "nightly report");
    }, 5);
  },
};

job.start();

What node printed, when the build ran it

node example.mjs

regular function sees: Timeout
regular function name: undefined
arrow function sees:   nightly report
ok   the arrow kept the job

And at the very top of a file, outside any function, this depends on which module system the file uses — one of several differences lesson 21 lists.

Top-level this, in a CommonJS file

ran at build time

example.cjs

console.log("top-level this in CommonJS:", this);
console.log("is it module.exports?", this === module.exports);

check("in a .cjs file, top-level this is module.exports", this === module.exports, true);

What node printed, when the build ran it

node example.cjs

top-level this in CommonJS: {}
is it module.exports? true
ok   in a .cjs file, top-level this is module.exports

Top-level this, in an ES module

ran at build time

example.mjs

console.log("top-level this in an ES module:", this);

check("in a .mjs file, top-level this is undefined", this, undefined);

What node printed, when the build ran it

node example.mjs

top-level this in an ES module: undefined
ok   in a .mjs file, top-level this is undefined
  • this is set by the call: it is whatever is before the dot.
  • Passing obj.method as a callback detaches it, and this becomes undefined.
  • Fix it by wrapping in an arrow (() => obj.method()) or with bind.
  • Arrows have no this of their own: right inside a method, wrong as a method.
  • In Node, a regular function passed to setTimeout gets a Timeout as this. Top-level this is module.exports in CommonJS and undefined in a module.

12 map, filter, reduce, and functions as values

A function that takes another function as an argument, or returns one, is called a higher-order function. Arrays have a set of them that replace most loops you would otherwise write, and learning them now is also learning the shape of almost every Node API: you hand over a function, and it gets called for you.

map, filter, find

Transforming, keeping, finding

ran in node · runs here too

example.mjs

const orders = [
  { id: 1, total: 40, paid: true },
  { id: 2, total: 15, paid: false },
  { id: 3, total: 90, paid: true },
];

const totals = orders.map((o) => o.total);            // transform each one
const unpaid = orders.filter((o) => !o.paid);         // keep the ones that pass
const big = orders.find((o) => o.total > 50);         // the first that passes
const where = orders.findIndex((o) => o.id === 2);

console.log(totals);
console.log(unpaid);
console.log(big, where);

check("map returns one result per item", totals, [40, 15, 90]);
check("filter keeps only the matches", unpaid.map((o) => o.id), [2]);
check("find returns the item itself", big.id, 3);
check("find gives undefined when nothing matches", orders.find((o) => o.total > 1000), undefined);

What node printed, when the build ran it

node example.mjs

[ 40, 15, 90 ]
[ { id: 2, total: 15, paid: false } ]
{ id: 3, total: 90, paid: true } 1
ok   map returns one result per item
ok   filter keeps only the matches
ok   find returns the item itself
ok   find gives undefined when nothing matches

None of these change the original array. map and filter build new ones, which is why you can chain them without worrying about what came before.

some, every, and reduce

Answering a question about the whole list

ran in node · runs here too

example.mjs

const scores = [72, 88, 45, 91];

console.log(scores.some((s) => s < 50), scores.every((s) => s >= 40));

const sum = scores.reduce((running, s) => running + s, 0);
const highest = scores.reduce((best, s) => (s > best ? s : best), -Infinity);
const byBand = scores.reduce((bands, s) => {
  const band = s >= 70 ? "distinction" : "pass";
  bands[band] = (bands[band] ?? 0) + 1;
  return bands;
}, {});

console.log(sum, highest, byBand);

check("some: is anything below 50?", scores.some((s) => s < 50), true);
check("reduce folds a list into one value", sum, 296);
check("the accumulator can be an object", byBand, { distinction: 3, pass: 1 });

What node printed, when the build ran it

node example.mjs

true true
296 91 { distinction: 3, pass: 1 }
ok   some: is anything below 50?
ok   reduce folds a list into one value
ok   the accumulator can be an object

reduce is the general one: it walks the array carrying a running value — the accumulator — and each call returns the next version of it. The second argument is where the accumulator starts. Leave it out and something bad happens on the one input nobody tests:

reduce on an empty array, with no starting value

dies with TypeError

example.mjs

const refunds = [];          // came back empty from the database today

const total = refunds.reduce((sum, r) => sum + r.amount);
console.log(total);

What node printed as it died, when the build ran it

node example.mjs

file:///app/example.mjs:3
const total = refunds.reduce((sum, r) => sum + r.amount);
                      ^

TypeError: Reduce of empty array with no initial value
    at Array.reduce (<anonymous>)
    at file:///app/example.mjs:3:23
    at ModuleJob.run (node:internal/modules/esm/module_job:569:25)
    at async node:internal/modules/esm/loader:650:26
    at async asyncRunEntryPointWithESMLoader (node:internal/modules/run_main:101:5)

Node.js v26.7.0

With no starting value, reduce uses the first element instead — and an empty array has none. Always pass the initial value.

Chaining

One step per line

ran in node · runs here too

example.mjs

const people = [
  { name: "Ada", team: "core", hours: 32 },
  { name: "Alan", team: "web", hours: 40 },
  { name: "Grace", team: "core", hours: 45 },
];

const coreHours = people
  .filter((p) => p.team === "core")
  .map((p) => p.hours)
  .reduce((a, b) => a + b, 0);

const names = people.map((p) => p.name).join(", ");

console.log(coreHours, names);

check("filter, then map, then reduce", coreHours, 77);

What node printed, when the build ran it

node example.mjs

77 Ada, Alan, Grace
ok   filter, then map, then reduce

Grouping

Object.groupBy

ran in node · runs here too

example.mjs

const tickets = [
  { id: 1, status: "open" },
  { id: 2, status: "closed" },
  { id: 3, status: "open" },
];

const byStatus = Object.groupBy(tickets, (t) => t.status);
console.log(byStatus);

check("groupBy builds the buckets for you", byStatus.open.map((t) => t.id), [1, 3]);

What node printed, when the build ran it

node example.mjs

[Object: null prototype] {
  open: [ { id: 1, status: 'open' }, { id: 3, status: 'open' } ],
  closed: [ { id: 2, status: 'closed' } ]
}
ok   groupBy builds the buckets for you

That [Object: null prototype] is Node telling you the result is an object with no prototype at all — so a group whose key happens to be "constructor" or "toString" cannot collide with anything built in. Lesson 15 shows why that matters, and lesson 17 what a prototype is.

Sorting, which is different

sort compares strings unless told otherwise

ran in node · runs here too

example.mjs

const n = [10, 9, 1, 100];

console.log([...n].sort());                      // !!
console.log([...n].sort((a, b) => a - b));       // numbers, ascending
console.log([...n].sort((a, b) => b - a));       // descending

check("the default sort compares as text", [...n].sort(), [1, 10, 100, 9]);
check("a comparator sorts numbers properly", [...n].sort((a, b) => a - b), [1, 9, 10, 100]);

What node printed, when the build ran it

node example.mjs

[ 1, 10, 100, 9 ]
[ 1, 9, 10, 100 ]
[ 100, 10, 9, 1 ]
ok   the default sort compares as text
ok   a comparator sorts numbers properly

With no function, sort converts every element to a string and compares those, so "100" comes before "9". For numbers, always pass a comparator: (a, b) => a - b returns a negative number when a should come first.

And unlike the others in this lesson, sort changes the array it is called on. That is why the panel above sorted copies ([...n]). toSorted is the version that does not.

sort mutates; toSorted does not

ran in node · runs here too

example.mjs

const original = [3, 1, 2];

const copy = original.toSorted((a, b) => a - b);
console.log(original, copy);

original.sort((a, b) => a - b);
console.log(original);

check("toSorted leaves the original alone", copy, [1, 2, 3]);
check("sort rearranged it in place", original, [1, 2, 3]);

What node printed, when the build ran it

node example.mjs

[ 3, 1, 2 ] [ 1, 2, 3 ]
[ 1, 2, 3 ]
ok   toSorted leaves the original alone
ok   sort rearranged it in place

Functions that make functions

Returning a function

ran in node · runs here too

example.mjs

const multiplier = (factor) => (n) => n * factor;

const double = multiplier(2);
const triple = multiplier(3);

console.log([1, 2, 3].map(double), [1, 2, 3].map(triple));

// Run something at most once, however often it is called.
function once(fn) {
  let done = false;
  let result;
  return (...args) => {
    if (!done) {
      done = true;
      result = fn(...args);
    }
    return result;
  };
}

let connections = 0;
const connect = once(() => ++connections);
connect(); connect(); connect();

check("each multiplier remembers its own factor", triple(5), 15);
check("once() let the function run only once", connections, 1);

What node printed, when the build ran it

node example.mjs

[ 2, 4, 6 ] [ 3, 6, 9 ]
ok   each multiplier remembers its own factor
ok   once() let the function run only once

Both of those are closures (lesson 10): the returned function keeps factor, or done and result, alive. once is a real pattern — Node's own events.once is a version of it — and a good example of adding behaviour to a function without changing the function.

forEach cannot be stopped, and does not wait. There is no way to break out of a forEach, and if the function you give it is async, forEach starts every call and returns immediately without waiting for any of them. Use for...of whenever either matters. Lesson 27 shows the second problem happening.

  • map transforms, filter keeps, find returns the first match or undefined. None of them changes the original.
  • some and every answer yes/no questions about the whole list.
  • reduce folds a list into one value. Always give it a starting value.
  • sort compares as strings without a comparator, and mutates. Prefer toSorted.
  • A function can return a function that remembers its arguments. That is how once, and much of Node, is built.

Newsletter

New writing, when it lands.

Occasional notes on shipping software with AI agents. No schedule, no filler, unsubscribe in one click.