Mastering closures in JavaScript

tahazsh

Taha Shashtari

Posted on March 20, 2024

Mastering closures in JavaScript

Closures are one of the most confusing concepts for new JavaScript learners. They are very powerful. Once you understand them well, your JavaScript skills will move to the next level.

In this article, I will give you all the knowledge you need to understand closures well. We will start with defining what scoping is and the different types of scoping. Then, we will learn the different ways to define variables in JavaScript. And we will move in small steps until we see how closures are defined. At the end of this article, I will show 5 different practical applications of closures that you can use now in your JavaScript code.

What is scoping?

In simple terms, scoping is the rules that determine how a variable (or function or class) is evaluated and accessed.

For example, we say that this variable's scope is some function or some if statement, and sometimes we say that its scope is global, meaning we can access it and modify it from anywhere.

Here are a couple of scoping examples.

// a is global variable
var a = 5

function fun() {
  // b's scope is only within the function fun
  let b = 'foo'

  if (b === 'foo') {
    // c's scope is only within this if statement
    let c = 'bar'
  }
}
Enter fullscreen mode Exit fullscreen mode

Static (lexical) vs. dynamic scoping

We always hear that JavaScript is a lexical scoping language, but it's not really clear what that means.

To understand what that means, we need to understand the alternative: dynamic scoping.

In some old languages, like Lisp and Bash, dynamic scoping was used. However, most modern languages stopped using it because dynamic scoping can lead to unpredictable behavior.

It can lead to that because variable scopes are determined based on the calling context at runtime. Let me make this clear with an example.

Take a look at this code:

let v = 10

// Called by b()
function a() {
  return v
}

// b() has its own variable named as v and calls a()
function b() {
  let v = 20
  return a()
}

console.log(b())
Enter fullscreen mode Exit fullscreen mode

If I asked you what value console.log(b()) will log, it will be an obvious answer: 10.

It's 10 because function a() returns v, and v is defined above and has the value 10.

That's how static (also called lexical) scoping works. You can know the value just by looking at the code.

If dynamic scoping were what JavaScript uses, the logged value would be 20. It would be 20 because when running the code, function b() would override the scope used for the variable v to the one defined in b(), and that would make a() use the last one, which is 20.

Thankfully, JavaScript doesn't use dynamic scoping and uses lexical scoping instead.

So you can simply think of lexical scoping as the scoping that allows us to know what variable will be used just by looking at the code at compile time (if the language is compiled) and not at runtime.

let and const vs. var

Advice everyone gets when learning JavaScript is to always use let and const instead of var. But why is that? Is var bad? Not necessarily.

It's all about scoping.

let and const are block scoped, while var is function scoped. What does that mean?

It means that any variable declared with let or const will be available only within the block it was defined in. By block, I mean {} (curly braces).

This literally means this:

let a = 'foo'

{
  let b = 'bar'
  console.log(b) // Output: 'bar'
}

console.log(b) // Error: undefined
Enter fullscreen mode Exit fullscreen mode

Look how I defined a block even without any additional keyword like if or for. And that's a valid syntax, but in real-world code, the blocks are usually preceded with some keywords like when defining if statements, while loops, and functions.

This is why when we use let and const in if statements, for example, we can't use the variable outside of it.

if (true) {
  // Here is a new scope (inner scope)
  const a = 'foo'
}

// Here is the outer scope (another one)
console.log(a) // Error: undefined
Enter fullscreen mode Exit fullscreen mode

So console.log logged undefined because it's in a different scope from the if statement. This means you can define the variable a again in the outer scope without affecting the one in the inner scope.

if (true) {
  const a = 'foo'
}

const a = 'bar'
console.log(a) // bar
Enter fullscreen mode Exit fullscreen mode

So that's how let and const scoping works. Let's see how var works.

var is for defining function-scoped variables. This means it will create a new scope only if it was used within a function, like this:

function foo() {
  var a = 'foo'
}

console.log(a) // Error: undefined
Enter fullscreen mode Exit fullscreen mode

Note that var is only function scoped (not block scoped), which means defining it within an if statement, for example, will define it globally (on the window object if in the browser).

if(true) {
  var a = 'foo'
}

console.log(a) // foo
Enter fullscreen mode Exit fullscreen mode

So var will define a new scope if it's used in a function; otherwise, it will define the variable in the global scope.

Thus, we get the advice to prefer let and const over var because block-scoped variables are more predictable and easier to reason about.

Access direction

An important rule to remember in lexical scoping: inner scopes can access outer scopes, but not the other way around.

Here's an example:

let a = 1

// cannot access b or c

{
  // can access a
  // cannot access c

  let b = 2

  {
    // can access a
    // can access b

    let c = 3
  }
}
Enter fullscreen mode Exit fullscreen mode

You can think of it as: you can't look inside things, but you can look outside them.

You need to always remember this rule as closures heavily depend on it as you will see next.

It also works for functions

Until now, we've been looking at examples that show how scoping works for variables. But the same is true for functions.

Let's take a look at this example:

function a() {
  function b() {
    console.log('from b')
  }

  b()

  console.log('from a')
}

b() // Error: b is not defined
Enter fullscreen mode Exit fullscreen mode

It errors because function b is not defined within the scope of the caller.

If we modify the example to call a() instead, it will work because a is defined within the scope of the caller.

function a() {
  function b() {
    console.log('from b')
  }

  b()

  console.log('from a')
}

a()
// Output:
// from b
// from a
Enter fullscreen mode Exit fullscreen mode

Also note how it was able to call function b from function a because b is defined within the scope of a.

In this example, we have nested functions, so we are really close to the concept of closures (get ready).

Returning a function from another function

Let's take the previous example and modify it to return the function b from the function a.

function a() {
  return function b() {
    console.log('from b')
  }
}

const nestedFunction = a()

nestedFunction() // Output: from b
Enter fullscreen mode Exit fullscreen mode

Notice how we were able to overcome the restriction of scopes by returning the function. In the previous example, we weren't able to call function b directly in the outer scope, but when we returned the function b, we were able to access it and call it.

Let's stop for a moment here to talk about the idea of returning a function.

When in a language a function can return another function (or take functions as arguments), we say two things:

  • this language supports functions as first-class citizens
  • and we call that function a Higher-order function (HoF).

Actually, this is why we say JavaScript supports functional programming.

One more step to create a closure

Let's take the previous example and make function b use (return) a variable defined in function a.

function a() {
  let v = 10
  return function b() {
    return v
  }
}

const nestedFunction = a()

console.log(nestedFunction()) // Output: 10
Enter fullscreen mode Exit fullscreen mode

Believe it or not, you have created your first closure.

Here's the simplest definition of a closure:

A closure is a function that uses and retains access to a variable defined outside of its scope.

Two important things to note here: uses and retains access.

The word uses here means that the function can use the outside variables without the need to pass them as parameters.

And retains access means that the inner function will still have access to the outside variables even after the outer function finishes executing.

So in the example above, even after the function a() finished executing, the variable v was not removed by the garbage collector because the inner function b() might need to use it in the future.

A small note here: a function doesn't have to use outside variables to be considered a closure; it's still called a closure even if it's empty, but that's not a common thing we see.

Closures can also modify outer variables

Not only can closures access outer variables, but they can also modify them if they were defined with let.

function a() {
  let counter = 0
  return function b() {
    return counter++
  }
}

const nestedFunction = a()

console.log(nestedFunction()) // Output: 0
console.log(nestedFunction()) // Output: 1
console.log(nestedFunction()) // Output: 2
Enter fullscreen mode Exit fullscreen mode

You can return multiple closures from a function

This is not a feature specific to closures, but it's a feature of JavaScript that is worth mentioning.

Let's modify the previous example to return two functions instead of one by returning an object.

function makeCounter() {
  let counter = 0
  return {
    inc() {
      return ++counter
    },

    getValue() {
      return counter
    }
  }
}

const counter = makeCounter()

console.log(counter.inc()) // Output: 1
console.log(counter.inc()) // Output: 2
console.log(counter.inc()) // Output: 3

console.log(counter.getValue()) // Output: 3
Enter fullscreen mode Exit fullscreen mode

It's still the same idea, but this is how we usually return multiple closures.

Why closures?

Like anything in programming, there are many different ways to implement something. And closures are no different. While anything can be implemented without closures (for example, in languages that don't support closures), closures provide a much simpler and more intuitive way to do so.

The first case that comes to mind for closures in JavaScript is event handling. Let's take a look at this example:

let counter = 0

button.addEventListener('click', () => {
  counter++
  console.log(counter) // 1 .. 2 .. 3 ..
})
Enter fullscreen mode Exit fullscreen mode

While this is a simple example, there are a few things to unpack:

  • We have two scopes here: outer scope (where counter is defined), and inner scope for the callback defined in addEventListener.
  • Since the inner function has access to the outer scope, we consider it a closure.
  • This function gets called every time the user clicks the button.
  • Every time it's called, it increments the outer variable counter and logs it.

Look how easy event handling is because of closures. Because the closure maintains access to its outer scope, we were able to modify and log counter on each click.

This example shows us how closures are everywhere, and we might be using them without realizing.

There are other cases where you want to create closures intentionally. And here are five different real-world examples of closures.

Example 1: Encapsulation

Like in Object-oriented programming (OOP), we can achieve encapsulation through closures. Encapsulation essentially means hiding part of the data and the internal implementation.

In OOP, we can do this using the private keyword. In closures, we can simply do that by defining the data outside the closure, like this:

function makeCounter() {
  // Private data
  let counter = 0

  // Private function
  function a() {}

  // Public
  return {
    inc() {
      counter++
    },

    dec() {
      counter--
    },

    getCounter() {
      return counter
    }
  }
}

const counter = makeCounter()

counter.inc()

console.log(counter.getCounter())
Enter fullscreen mode Exit fullscreen mode

In this example, there's no way you can access counter or the function a() directly. You can only do that through its public API; in other words, the object returned from it.

Example 2: Partial application

In mathematics, partial application means fixing a certain number of arguments in a function to create a new function with fewer arguments.

If that's a little bit confusing, don't worry. Here's a simpler explanation.

Let's say you have a function f(a, b, c). This function takes three arguments: a, b, and c. Partial application means creating another function from function f where some arguments are hardcoded. For example, we can have a new function called g(b, c), where a is always the value 10, for example.

We can do the same in JavaScript because functions are first-class citizens.

Let's take a look at this example:

function add(a, b) {
  return a + b
}

function add10(b) {
    return add(10, b)
}

console.log(add10(5)) // Output: 15
Enter fullscreen mode Exit fullscreen mode

In this example, we created another function called add10 based on the original one add. The new function does the same but always uses the number 10 as the first argument.

This example is just for demonstration purposes, so it might not look useful. Let's take a look at a useful example that you might use in your project.

function createFormatter(prefix, suffix) {
  return function (string) {
    return `${prefix}${string}${suffix}`
  }
}

const bracketsFormatter = createFormatter('[', ']')
const paragraphFormatter = createFormatter('<p>', '</p>')

console.log(bracketsFormatter('name')) // [name]
console.log(paragraphFormatter('some text')) // <p>some text</p>
Enter fullscreen mode Exit fullscreen mode

You can see how we were able to create different types of formatters using the general one createFormatter. If I want to create another formatter that surrounds text with curly braces, I just need to do this:

const curlyFormatter = createFormatter('{', '}')
Enter fullscreen mode Exit fullscreen mode

So, with partial applications, we can make a certain function more reusable and flexible by fixing some of its arguments. All thanks to closures for enabling us to do that.

Example 3: Validation functions

In this example, I want to create a way to validate values. If they are valid, don't do anything; otherwise, throw an error. With closures, we can do that easily.

function createValidator(validatorFunction, errorMessage) {
  return function (value) {
    if (!validatorFunction(value)) {
      throw new Error(errorMessage)
    }
  }
}

const positiveValidator = createValidator((value) => {
  return value > 0
}, 'The provided number must be positive')

try {
  positiveValidator(-10) // will throw an error
} catch (error) {
  console.log('error', error)
}
Enter fullscreen mode Exit fullscreen mode

positiveValidator is just one example. You can use createValidator to create any validator you want.

Example 4: Throttling

Another good application of closures is throttling, where a function is called only once per some duration.

function throttle(callback, duration) {
  let lastExecuted = 0

  return function (...args) {
    const now = Date.now()
    if (now - lastExecuted >= duration) {
      callback(...args)
      lastExecuted = now
    }
  }
}

const saveRequest = throttle((data) => {
  console.log('saving data', data)
}, 1000)

// Only one request is sent
saveRequest({ user: 'test' })
saveRequest({ user: 'test' })
Enter fullscreen mode Exit fullscreen mode

As an exercise, see how you can modify the throttle function to be a debounce function.

Example 5: Memoization

Memoization simply means caching the result of some expensive function.

In the following example, I'm writing a memoization function for addition, but in the real world, it will be something more expensive.

function memoizedAdd() {
  const cache = {}

  function addFunc(a, b) {
    console.log('adding')
    return a + b
  }

  return function (a, b) {
    const key = `${a},${b}`
    if (cache[key]) {
      return cache[key]
    }
    const result = addFunc(a, b)
    cache[key] = result
    return result
  }
}

const add = memoizedAdd()

console.log(add(10, 5))
console.log(add(10, 5))
console.log(add(10, 1))

// Output:
// adding
// 15
// 15
// adding
// 11
Enter fullscreen mode Exit fullscreen mode

You can see how the word adding was logged only once for adding 10 and 5, since the second time used the cached result. Imagine how beneficial this would be for expensive operations.

Conclusion

Closures are a fundamental part of JavaScript, and we are already using them every day without realizing—just like in the event handling example.

Understanding them well helps us write code that's more reusable and flexible. In this article, I've shown you five different examples of how closures can be used, but there's no limit to what you can come up with.

Whenever you forget what closures are, just remember that they're basically functions that remember their surroundings.


There are countless applications of closures. Are there any that haven't been mentioned in this article? Please share them in the comments section.


🔗 Let's stay connected:

💖 💪 🙅 🚩
tahazsh
Taha Shashtari

Posted on March 20, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related