Course outline
Full-Stack course / JavaScript fundamentals

Functions, scope and closures

Reading

The idea

So far every line you've written ran once, in order, and was gone. A function lets you write a set of instructions once, give it a name, and run it as many times as you like with different inputs.

Our picture is a vending machine. You put something in (the inputs), the machine does its work behind the glass, and exactly one thing comes out (the result). You don't need to know how it works inside to use it, and you can build as many machines as you need.

The rest of this lesson adds one thing at a time: how to build a machine, how to give it optional and unlimited slots, what each machine can and can't see, how a machine can remember a setting, how machines can use other machines, and how to build machines that stay out of everything else's business.

Keep using the practice folder from last time and run files with node. If you're on Windows, that's your WSL2 (Ubuntu) terminal. The exact steps are in Try it at the end, so you can read first and type later.

Function declarations and arrow functions

A function declaration builds one machine. You write the word function, a name, the input slots in parentheses, and the work inside curly braces. Using the machine is called calling it: you write its name and put the inputs in parentheses.

function squareOf(number) {
  return number * number;
}
console.log(squareOf(7));
console.log(squareOf(12) + 1);
49
145

number is a parameter: the label on an input slot. The 7 you put in when you call the function is an argument: the actual thing dropped into the slot. The keyword return is the chute where the result comes out, and it also ends the function straight away. Whatever sits after a return never runs.

If a machine never reaches a return, nothing comes out. JavaScript hands you undefined, the same "empty box" value from earlier:

function shout(word) {
  const message = `${word}!`;
}
console.log(shout("hey"));
undefined

The function built message and then threw it away. Printing a value inside a function is not the same as returning it, and only a returned value can be used by the code that called the machine.

A function can have several parameters, and a return can sit inside an if. This is how you give a machine more than one outcome:

function rectangleLabel(width, height) {
  if (width === height) {
    return `square ${width}`;
  }
  return `rectangle ${width} by ${height}`;
}
console.log(rectangleLabel(4, 4));
console.log(rectangleLabel(4, 9));
console.log(rectangleLabel(5));
square 4
rectangle 4 by 9
rectangle 5 by undefined

The first call takes the if track and returns early. The second falls through to the last return. In the third call we forgot the second argument, and JavaScript doesn't complain. An input slot nobody filled just holds undefined.

A function is also a value, like a number or a string. You can ask what shape its box is:

function squareOf(number) {
  return number * number;
}
console.log(typeof squareOf);
function

That fact matters later in this lesson. For now, meet the second way to build a machine. An arrow function is the same vending machine in a slimmer casing. You write the parameters, then => (an arrow), then the work. You normally store it in a const, so the name stays on that box for good.

const cubeOf = (number) => number * number * number;

const introduce = (name, role) => {
  const line = `${name}, ${role}`;
  return line;
};

console.log(cubeOf(3));
console.log(introduce("Ada", "engineer"));
27
Ada, engineer

There are two shapes. When the work is a single expression, write it right after the arrow with no braces, and its value is returned automatically, as with cubeOf. When you use curly braces, you're writing a normal body again, so you must write return yourself, as introduce does.

Both ways build working machines. A common habit is to use a declaration for the main named machines of a file and arrow functions for small ones you'll hand to something else, which you'll see later in this lesson.

Default and rest parameters

Some input slots usually get the same thing. A default parameter is a slot with a pre-pressed button: if you don't put anything in, the machine uses the default.

function greet(name = "friend", mark = "!") {
  return `Hello, ${name}${mark}`;
}
console.log(greet("Ada"));
console.log(greet());
console.log(greet(undefined, "?"));
console.log(greet(null));
Hello, Ada!
Hello, friend!
Hello, friend?
Hello, null!

You write the default after an = in the parameter list. The second call fills nothing, so both defaults apply. The third shows how to skip the first slot but fill the second: pass undefined, which means "I put nothing in here". The last call may surprise you. The default only fires for undefined. null is a deliberate "nothing here" that you packed yourself, so the machine takes it at face value.

Other machines need to accept any number of inputs. A rest parameter is a wide slot that collects every leftover argument into a single list. You write three dots before the name:

function biggest(first, ...others) {
  let best = first;
  for (const value of others) {
    if (value > best) {
      best = value;
    }
  }
  return best;
}
console.log(biggest(3, 9, 4));
console.log(biggest(5));
9
5

In the first call first is 3, and others is the list [9, 4]. You already know how to walk a list: for...of. In the second call there are no leftovers, so others is an empty list and the loop simply runs zero laps. The rest parameter must be the last one in the parentheses, because it swallows everything that's left.

Scope and hoisting

Each function is a room with one-way glass. From inside a room you can see out into the rooms around it, but nobody outside can see in. Scope is the name for where a variable can be seen. Anything made inside a function can only be used inside that function:

const siteName = "GoNomicon";

function banner() {
  const year = 2026;
  return `${siteName} ${year}`;
}

console.log(banner());
console.log(year);
GoNomicon 2026
ReferenceError: year is not defined

banner can see siteName (looking out through the glass), so it works. The last line tries to look in at year from outside, and Node stops with a ReferenceError: the word before the colon names the kind of mistake, and the sentence says the name isn't visible from where you asked.

Curly braces make smaller rooms too. A variable made with let or const inside { ... } of an if lives only inside those braces. This is called block scope:

const score = 75;
if (score >= 50) {
  const verdict = "pass";
  console.log(verdict);
}
console.log(verdict);
pass
ReferenceError: verdict is not defined

A loop's counter works the same way: let round in a for header exists only for that loop.

Looking outward works through several rooms. When you use a name, JavaScript checks the room you're in first, then the room around it, and so on outward, and uses the first match. If an inner room has a box with the same label, it hides the outer one. That's called shadowing:

const level = "outer";

function show() {
  const level = "inner";
  return level;
}

console.log(show());
console.log(level);
inner
outer

Two separate boxes carry the label level. Inside show, the inner one is the first match. Outside, the inner one can't be seen at all. Shadowing is legal but confusing, so give different boxes different names when you can.

Hoisting is about order. Before it runs a single line, JavaScript reads through your file and installs every function declaration, as if the machines were delivered before the shop opened. That means you can call a declared function from a line above where you wrote it:

console.log(doubleIt(21));

function doubleIt(number) {
  return number * 2;
}
42

Boxes made by let and const are different. Their labels exist from the start of the room, but the box stays sealed until the line that packs it. Using it earlier gives a special error. This matters for arrow functions, because an arrow function is stored in a const:

console.log(tripleIt(5));
const tripleIt = (number) => number * 3;
ReferenceError: Cannot access 'tripleIt' before initialization

Read it piece by piece: ReferenceError is the kind of mistake, tripleIt is the name, and "before initialization" means you reached for the box before the line that fills it. The fix is to move the call below the line that creates the arrow function.

Closures

A closure is a vending machine that remembers a setting from when it was built, even after it's been wheeled away from where it was made. You get one whenever a function returns another function: the inner machine keeps what it needs from the room it was built in.

function makeGreeter(greeting) {
  return (name) => `${greeting}, ${name}!`;
}

const sayHello = makeGreeter("Hello");
const sayHola = makeGreeter("Hola");

console.log(sayHello("Ada"));
console.log(sayHola("Ada"));
console.log(sayHello("Grace"));
Hello, Ada!
Hola, Ada!
Hello, Grace!

makeGreeter is a machine that builds machines. When it finishes, its room is closed, and yet the little machine it returned still knows its own greeting. sayHello and sayHola each carry their own setting.

The remembered setting can also change. Here the machine keeps a box of its own, and nobody else can reach it:

function makeHighScore() {
  let best = 0;
  return (score) => {
    if (score > best) {
      best = score;
    }
    return best;
  };
}

const player = makeHighScore();
console.log(player(40));
console.log(player(25));
console.log(player(90));

const rival = makeHighScore();
console.log(rival(10));
40
40
90
10

player remembers the highest score it has seen: 25 doesn't beat 40, so the answer stays 40. rival was built by a second call to makeHighScore, so it has its own best box starting at 0. The two machines never share anything. The only way to change best is to call the machine, which makes closures a tidy way to keep a value private while still letting it change.

Higher-order functions and callbacks

Because a function is a value, you can hand one machine to another machine. A higher-order function is a machine that takes a machine as an input, or returns one (you already built one of those with makeGreeter). The machine you hand over is called a callback: you're saying "call this one when it's your turn".

function doTimes(count, action) {
  for (let round = 1; round <= count; round++) {
    action(round);
  }
}

doTimes(3, (round) => {
  console.log(`Lap ${round}`);
});
Lap 1
Lap 2
Lap 3

doTimes doesn't know what the action is. It only knows to call it once per round and pass in the round number. The arrow function written right in the call is the callback. You can also hand over a machine you built earlier by name:

function doTimes(count, action) {
  for (let round = 1; round <= count; round++) {
    action(round);
  }
}

function announce(round) {
  console.log(`Round ${round} done`);
}

doTimes(2, announce);
Round 1 done
Round 2 done

Notice there are no parentheses after announce in the last line. Writing announce hands over the machine itself. Writing announce() would run it right now and hand over whatever it returned.

A callback can also return something that the outer machine uses:

const applyToBoth = (first, second, change) => change(first) + change(second);
const cubeOf = (number) => number * number * number;

console.log(applyToBoth(3, 4, (number) => number * 10));
console.log(applyToBoth(3, 4, cubeOf));
70
91

applyToBoth stays the same, and the callback changes what it does: 30 + 40 the first time, and 27 + 64 the second. Handing over different machines to one machine is how a single piece of code gets reused for many jobs.

Pure functions and side effects

A pure function is a vending machine that only uses what you put in and only gives out its result. Same inputs, same output, every time, and nothing else in the world changes. A side effect is anything else a machine does on the way: ringing a bell, changing a sign outside, or reading a sign that someone else can change.

function rectangleArea(width, height) {
  return width * height;
}

let visits = 0;
function countVisit() {
  visits++;
  return visits;
}

console.log(rectangleArea(3, 5));
console.log(rectangleArea(3, 5));
console.log(countVisit());
console.log(countVisit());
15
15
1
2

rectangleArea is pure. You can call it a hundred times and always get 15, and visits or anything else is untouched. countVisit isn't pure: it changes a box outside itself, so the same call (no inputs at all) gives 1, then 2. Printing with console.log is a side effect too, because it changes what appears on the screen.

Reading from outside is a quieter way to be impure:

let weather = "sunny";

function describeDay() {
  return `Today is ${weather}`;
}

console.log(describeDay());
weather = "rainy";
console.log(describeDay());
Today is sunny
Today is rainy

The call is identical and the answers differ, because the machine secretly depends on a sign that's outside of it. If weather had been a parameter, the machine would have shown everything it needed in its input slots.

Why care? A pure machine is easy to trust: you can call it twice, call it in any order, and move it to another file, and nothing breaks. It's also easy to check, because you only need to look at inputs and output. Side effects aren't bad, since a program that never prints or saves anything is useless. The habit is to keep the calculating machines pure and do the printing at the edges, in a few clearly marked places.

Common mistakes

  • Forgetting return. The function runs but the caller gets undefined. Printing inside the function doesn't help the code that called it.
  • Using braces on an arrow function and forgetting return. With braces, nothing is returned automatically:
const doubled = (number) => { number * 2 };
console.log(doubled(4));
undefined
  • Calling a function when you meant to hand it over. This runs announce straight away and passes its result, undefined, to doTimes, which then tries to call it:
function doTimes(count, action) {
  for (let round = 1; round <= count; round++) {
    action(round);
  }
}
function announce(round) {
  console.log(`Round ${round} done`);
}
doTimes(2, announce());
Round undefined done
TypeError: action is not a function

Read it from the bottom: doTimes tried to call action, but action held undefined and not a function.

  • Putting the rest parameter anywhere but last:
function collect(...items, last) {
  return last;
}
SyntaxError: Rest parameter must be last formal parameter
  • Expecting a default to cover null. Only a missing argument or undefined triggers it.
  • Passing arguments in the wrong order. JavaScript matches them to parameters by position, not by name.
  • Calling an arrow function from a line above the const that creates it. Declarations are hoisted and const boxes stay sealed until their line.
  • Reusing a name inside a function by accident, which shadows the outer one. You then change or read a different box from the one you meant.
  • Changing a variable that lives outside the function and wondering why other code behaves differently. If a function needs a value, pass it in. If the caller needs a value, return it.

Try it

Make a new file in your practice folder and run it after every step.

cd ~/js-basics
code functions.js
node functions.js

cd moves into the folder, code functions.js opens a new file in VS Code, and node functions.js runs it. Run the last command again after each step.

  1. Build a machine. Write function areaOfCircle(radius) that returns 3 * radius * radius (a rough area is fine), and print areaOfCircle(10). Then remove the return word and run it again to see undefined. Put it back.
  2. Slim it down. Rewrite the same machine as an arrow function stored in const areaOfCircleArrow and check both give the same answer. Then write an arrow function with braces and try it with and without return.
  3. Defaults. Write function welcome(name = "visitor", place = "GoNomicon") that returns a sentence. Call it with two arguments, with one, with none, and with undefined, "Orlando". Then call it with null and read the result.
  4. Rest. Write function longest(first, ...others) that returns the largest of all the numbers you give it, using for...of, and try it with one number and with five. Then move the rest parameter to the front, run it, and read the SyntaxError.
  5. Scope and hoisting. Make a function with a const inside it, then print that name outside to see the ReferenceError. Put a const inside an if block and do the same. Then call a declared function above its definition (it works), and call an arrow function above its const line (read the error out loud).
  6. Remember a setting. Write function makeMultiplier(factor) that returns an arrow function multiplying its input by factor. Build one that doubles and one that triples, and call each twice. Then write a machine with a let box inside it that remembers the lowest number it has been given so far.
  7. Hand machines to machines. Write function eachLap(count, task) that calls task with every number from 1 to count, and use it once with an arrow function written in the call and once with a named function. Then write function pickBigger(a, b, score) that returns whichever of a or b gets the higher result from the callback score.
  8. Pure or not. Write one machine that changes a let variable outside it, and one that reads a let variable outside it. Call each twice, change the outside variable in between, and see what breaks. Then rewrite both so everything they need is passed in as parameters and the result is returned, and check that repeating a call always gives the same answer.