What is scope and closure in Javascript?

In this article, we’ll look at two important Javascript concepts: scope and closure.

Filip Razek
8 min readMay 17, 2023

Function scope

Let’s start with the easiest of scopes. If you’ve written some Javascript, you’ve probably come across this behavior.

Have a look at this piece of code:

function addOne(a) {
const b = a + 1;
}
console.log(b); // ReferenceError: b is not defined

We define b in the addOne function, and try to access it later. This gives us a ReferenceError - variables declared in a function are automatically invisible outside of it. b is said to be function-scoped.

This is natural and expected - we like being able to define new temporary variables in functions, without interfering with the code outside.

Global scope

The complement to function scope is what is called global scope. This refers to all the environment containing all variables accessible anywhere in our code.

In our previous example, the function addOne was defined in the global scope.

What we tend to forget is that global scope often contains many more functions. For example, when we call console.log, the console object exists in the global scope - indeed, it is callable from anywhere in our code.

The distinction we’ve covered so far was all there was to scope before ES6, and it feels pretty natural - in addition to being similar to how other languages work.

Block scope

Now, let’s discuss what block scope is. To understand it, let’s start with what a block is.

A block is simply a group of zero or more statements. To delimit a block, we use curly braces:

// Block beginning
{
const a = 2; // Statement 1;
let b = a + 2; // Statement 2;
}
// Block end

Blocks are used everywhere in JS - you’ve probably already used them with if/else conditionals, loops, switch statements…

Block-scoped declarations

When you define a variable in a block with let, const or class, it is completely invisible outside of it:

if (1 + 1 === 2) {
// This block will run only if 1 + 1 === 2 is true (it should be)
const me = "Math expert";
}
console.log(me); // ReferenceError: me is not defined :(

Interestingly, you don’t even need a conditional to create a block. You can use it to hide certain variables from other parts of your code:

const friends = ["Elise", "Joseph"];

{
const secretFriend = "Darkus";
friends.push(secretFriend);
}

console.log(friends); // [ 'Elise', 'Joseph', 'Darkus' ]
console.log(secretFriend); // ReferenceError: secretFriend is not defined

As for functions, we say these hidden variables are “scoped”. Since thay are only visible in a block, we call them block-scoped.

As another example, when using a for loop, the index you declare with let is also scoped in the for block - that is, invisible outside of it:

for (let i = 0; i < 5; i++) {
console.log(i);
if (i ** 2 >= 3) {
break;
}
}
console.log(i); // ReferenceError: i is not defined

Isn’t block scope the same as function scope?

Block and function scopes are quite similar - we could be tempted to say that the body of a function creates a block.

The problem is, look at how var behaves in a block:

if (Math.random() >= 0) {
var wonAmount = 10_000; // Cool way to use the 10000 literal btw
const lostAmount = 100;
}
console.log(wonAmount); // 10000
console.log(lostAmount); // ReferenceError: lostAmount is not defined

Even though we created a block for our if statement, the wonAmount variable was available outside of it.

The lostAmount variable behaves as expected, being invisible outside of the if block.

But var declared variables simply ignore block scope: they are scoped in the higher scope (here the global one). In short, block scoping is only useful for const and let. var declared variables basically ignore it.

Quick note #1: Temporal Dead Zone

Now that we know that var declared variables behave differently from those declared with let/const, let’s look at what happens in a block before we define our variables:

function hideFromOutside() {
console.log(old); // (1) undefined
console.log(young); // (2) ReferenceError

if (1 + 2 === 3) {
console.log(old); // (3) undefined

// Temporal Dead Zone:
// (4) ReferenceError: Cannot access 'young' before initialization
console.log(young);

var old = 3;
let young = 2;

console.log(old); // (5) 3
console.log(young); // (6) 2
}
}

console.log(old); // (7) ReferenceError
console.log(young); // (8) ReferenceError

hideFromOutside();

In the if block of the hideFromOutside function, we declare two variables, old with var and young with let.

As we’ve seen, the old is declared at the top of its scope, the function scope (since it doesn’t see the block scope). In (1) and (3), it has not been defined yet, but doesn’t give a ReferenceError. Once we define it, we can access its value in (5). Outside of the function scope (in (7)), it is invisible.

The same is expected for young: let declares it in the if scope, so it’s invisible in (2) and (8) - giving a ReferenceError. Once we define it, we can access its value in (6).

But what happens in (4), in the block scope but before its initialization? young is in the so-called Temporal Dead Zone (TDZ) (this is not a joke), where it can’t be accessed yet. We get a different type of ReferenceError, “Cannot access ‘young’ before initialization”.

Quick note #2: Redefining variables

Since we now have two distinct scopes (environments) for our variables, it is natural to ask: what if we create variables with the same name in different scopes, what happens?

The shorter answer is “don’t do it, you’ll confuse yourself”.

The longer answer is that the inner-most definition we see is used. Let’s look at an example to make it clearer.

const name = "Jonah"; // Global name

function logName() {
// No function-scoped name, look in the global scope
console.log(name); // Jonah
if (true) {
console.log(name); // ReferenceError: Cannot access 'name' before initialization
const name = "Alice"; // Block-scoped name
console.log(name); // Alice
}
console.log(name); // Jonah
}

console.log(name); // Jonah

In the if block in the logName function, we redefine the name variable. Outside of the block, its value is inherited from the global scope, where it’s set to “Jonah”. In the block, the variable is set to “Alice”. Before this initialization, it is in the TDZ (see the previous section).

Quick note #3: Scoping in ES modules

We’ll talk about ES modules later, but importing one effectively creates a scope for each file.

Hopefully this should help you understand how variables are scoped in modern Javascript.

Closure

Now that we understand scope, let’s try spicing things up a little!

Take a look at the following example:

function createGreeter(greeting) {
return function greet(name) {
console.log(`${greeting}, ${name}!`);
};
}

const formalGreeter = createGreeter("Hello");
const oldSchoolFriendlyGreeter = createGreeter("Wasssup");

formalGreeter("world"); // Hello, world!
oldSchoolFriendlyGreeter("folks"); // Wasssup, folks!

You can probably see what’s happening here: in createGreeter, we return a function whose output depends on the initial greeting argument.

Why does this work? Whenever we create a function in Javascript, it creates an environment with everything it can see at that moment. Here, the greet function has access to the greeting argument, so we can use it in its body.

This combination of function + outer environment is called a closure. You might see it as the outer function closing over the inner one, or enclosing the variables in a context the inner function can access.

Using closures to curry functions

If you like functional programming, you’re probably familiar with the concept of currying.

Currying is simply converting a function that takes multiple arguments into the evaluation of a sequence of functions.

Suppose we have a simple downloading function, that takes a URL and a format, GETs a JSON from the URL and outputs it in the chosen format (log, text, csv, excel…).

We may have implemented it this way:

const download = async (url, format) => {
const data = await get(url);
switch (format) {
case "log":
console.log(data);
break;
case "txt":
writeToFile(data);
break;
default:
throw new Error(`Unknown format ${format}`);
}
};

await download("https://jsonplaceholder.typicode.com/todos", "log"); // logs an object

You might have noticed that our function flow is not optimal: we always fetch the data, even if the format is unknown and will throw an Error.

Also, if we want to save the same data in a text and an Excel file, we currently have to fetch it twice.

To solve these issues, we can first create a closure:

const download = async (url, format) => {
const data = await get(url);
const exportData = () => {
switch (format) {
case "log":
console.log(data);
break;
case "txt":
writeToFile(data);
break;
default:
throw new Error(`Unknown format ${format}`);
}
};
exportData();
};

await download("https://jsonplaceholder.typicode.com/todos", "log"); // logs an object

Now, our outer function closes over the inner one, giving it access to data.

The next step is to move the format parameter into the inner function and return it instead of calling it:

const download = async (url) => {
const data = await get(url);
return (format) => {
switch (format) {
case "log":
console.log(data);
break;
case "txt":
writeToFile(data);
break;
default:
throw new Error(`Unknown format ${format}`);
}
};
};

Finally, we can use our function to pre-load the data, and return a formatting function. We can then format the data to log it or save it to a file. Our download function is curried!

// Run this as a module to use the top-level await below
const todosExporter = await download(
"https://jsonplaceholder.typicode.com/todos"
);
todosExporter("log"); // Logs an object
todosExporter("csv"); // Fails with Error: Unknown format csv

But why is it useful?

The nice thing about closure is that they allow you to hide data from other parts of your program.

For example, say you want to create a counter for people, but want to ensure that your variable can only increase.

const createCounter = function () {
let guestCount = 0;
return {
addGuests: (newGuests) => (guestCount += newGuests),
print: () => console.log(guestCount),
};
};

const counter = createCounter();
counter.print(); // 0
counter.addGuests(5);
counter.print(); // 5
console.log(counter.guestCount); // undefined

When the inner functions are defined, they have access to guestCount. But since we return an object, we can choose to expose only the data we want, hiding the guestCount.

It works in the same way as the previous example: the inner functions have access to the environment they were defined in (the enclosing context), and the outer function scopes the guestCount variable to ensure it is invisible from the outside.

Could we use private fields for this?

If you’ve been following JS changes recently, you might know that you can now define private fields on objects, using the #property syntax (pronounced hash property).

Our previous example could be rewritten like this:

class Counter {
#guests = 0;
print() {
console.log(this.#guests);
}
add(newGuests) {
this.#guests += newGuests;
}
}

const counter = new Counter();
counter.print(); // 0
counter.add(3);
counter.print(); // 3
console.log(counter.#guests); // SyntaxError

This code would only work in the newest environments though, so we better come back to it in a few months (or years).

Conclusion

That’s it, now you know what scope and closure are in Javascript!

--

--

Filip Razek
Filip Razek

Written by Filip Razek

A CentraleSupélec student living in the Czech Republic. Check out my other projects at https://github.com/FilipRazek/

No responses yet