DEV Community

Cover image for Unpacking the Trickiest Concepts in JavaScript
Daniel Bemsen Akosu
Daniel Bemsen Akosu

Posted on • Updated on

Unpacking the Trickiest Concepts in JavaScript

JavaScript is a powerful and versatile language that has become a cornerstone of web development. However, as developers build more complex and dynamic applications, they often encounter tricky concepts that can be challenging to master. From scopes and closures to prototypes and type coercion, JavaScript presents a range of challenges that can trip up even experienced developers.
In this article, we'll unpack some of the trickiest concepts in JavaScript and provide tips and strategies for understanding and working with them. Whether you're a beginner looking to build a strong foundation or an experienced developer seeking to deepen your knowledge, this guide will help you tackle some of the most complex and confusing aspects of JavaScript. So let's dive in and unravel the mysteries of JavaScript together.

Closures

Closures are a powerful concept in JavaScript that allows a function to access variables from its parent function's scope, even after the parent function has returned. Closures are created whenever a function is created and the inner function retains access to its parent function's scope, including any variables or functions declared within it. Closures are created when a function is defined inside another function and has access to the outer function's variables, which are "closed over" and retained by the closure.
Closures are often used to create private variables and functions in JavaScript. By enclosing a variable or function inside another function, we can prevent other parts of the program from accessing or modifying it directly. This can help to ensure data privacy and prevent naming conflicts.
Here's an example of how closures can be used to create a private variable in JavaScript:

    const outerFunction = () => {
      const outerVar = 'I am in the outer function!';

      const innerFunction = () => {
        console.log(outerVar);
      }

      return innerFunction;
    }

    const finalFunc = outerFunction();
    finalFunc(); // Output: "I am in the outer function!"

Enter fullscreen mode Exit fullscreen mode

In this example, outerFunction defines a variable outerVar and a function innerFunction, which references outerVar. When outerFunction is called, it returns innerFunction, which is then assigned to the variable finalFunc.
When finalFunc is called, it logs the value of outerVar to the console, even though outerVar is declared in the parent function outerFunction. This is because innerFunction retains a reference to its parent function's scope, and can access outerVar even after outerFunction has returned.
Closures are particularly useful for creating private variables and functions in JavaScript, as they can be used to encapsulate data and functionality within a function's scope. They are also used extensively in functional programming, where higher-order functions that return other functions are common.
However, closures can also cause memory leaks if they are not used carefully, as they can keep references to variables and functions in memory even when they are no longer needed. It's important to understand how closures work and how to use them effectively to avoid potential issues.

Scopes

In JavaScript, scope refers to the visibility and accessibility of variables and functions within a program. Every function in JavaScript creates its own scope, which determines the lifetime and visibility of the variables and functions declared inside the function. The scope of a function can be divided into two parts: local scope and global scope.

Local Scopes

Local scope is created every time a function is called and is destroyed when the function returns. Any variables and functions declared inside the function are only visible within that function and its nested functions. Local scope is also known as function scope

    const add = (a, b) => {
      const result = a + b;
      return result;
    }

    console.log(add(2, 3)); // logs 5
    console.log(result); // Uncaught ReferenceError: result is not defined

Enter fullscreen mode Exit fullscreen mode

In this example, the add function creates a local variable result that is only accessible inside the function. When add is called with the arguments 2 and 3, it returns the result 5. However, when we try to log the value of result outside of the function, we get a ReferenceError because result is not visible outside the function ( global scope).

Global Scope

Global scope, on the other hand, is created when the program starts and is accessible from anywhere in the program. Variables and functions declared in the global scope are visible and accessible from any part of the program including inside any functions that are defined.


  const message = "Hello, world!";

  const showMessage = () => {
    console.log(message);
  }

  showMessage(); // logs "Hello, world!"
  console.log(message); // logs "Hello, world!"

Enter fullscreen mode Exit fullscreen mode

In this example, the message variable is declared in the global scope and is accessible from within the showMessage function. When showMessage is called, it logs the value of message to the console. It is also accessible outside the function when we tried to log the value of message to the console because it was declared outside the function.

The scope of a variable is determined by the keyword used to declare it. There are three main keywords used to declare variables in JavaScript: var, let, and const.

Var

Variables declared with var are function-scoped, meaning they are accessible anywhere within the function in which they are defined. If a variable is defined with var inside a block (such as a loop or conditional statement), it will still be accessible outside the block, in the function scope.

  const example = () => {
    var x = 1;

    if (true) {
      var y = 2;
    }

    console.log(x); // logs 1
    console.log(y); // logs 2
  }

  example();

Enter fullscreen mode Exit fullscreen mode

both x and y are accessible inside the example function, despite y being declared inside the if block.

Let

Variables declared with let are block-scoped, meaning they are only accessible within the block in which they are defined. This includes function bodies, loops, and conditional statements.

  const example = () => {
    let x = 1;

    if (true) {
      let y = 2;
      console.log(x); // logs 1
      console.log(y); // logs 2
    }

    console.log(x); // logs 1
    console.log(y); // ReferenceError: y is not defined
  }

  example();

Enter fullscreen mode Exit fullscreen mode

x is accessible both inside and outside the if block, while y is only accessible within the if block. If we try to access y outside the if block, we get a ReferenceError.

Const

Variables declared with const are also block-scoped, but they cannot be reassigned a new value after they are declared. The variable is constant, hence the name const. However, it's important to note that when we declare a variable with const that points to an object or an array, we can still mutate the object or array.

  const example = () => {
    const x = 1;
    x = 2; // TypeError: Assignment to constant variable.

    if (true) {
      const y = [1, 2, 3];
      y.push(4);
      console.log(y); // logs [1, 2, 3, 4]
    }
  }

  example();

Enter fullscreen mode Exit fullscreen mode

we try to reassign x to a new value, which results in a TypeError. However, we can still add elements to the array y that is defined with const.

Understanding how variables are scoped in JavaScript is an essential part of writing maintainable and readable code. By using the appropriate keywords (var, let, const) and understanding their scoping rules, developers can avoid naming conflicts, manage data privacy, and write more robust applications.

Closures and scope are similar in that they both deal with the visibility and accessibility of variables and functions in JavaScript. Closures can be seen as a way to "close over" variables and functions from the outer function's scope, creating a private environment that can only be accessed by the closure. This can be useful for creating modular and reusable code that is more secure and less prone to naming conflicts.

โ€œThisโ€ Keyword

The this keyword is a special variable in JavaScript that refers to the object that the function is a method of. When a function is called as a method of an object, the this keyword inside the function refers to the object that the method is called on.

  const person = {
    name: "John",
    age: 30,
    greet: function() {
      console.log("Hello, my name is " + this.name + " and I am " + this.age + " years old.");
    }
  };

  person.greet(); // logs "Hello, my name is John and I am 30 years old."

Enter fullscreen mode Exit fullscreen mode

The greet method is defined inside the person object, and when it is called using person.greet(), the this keyword inside the method refers to the person object. This allows the method to access the name and age properties of the object using this.name and this.age.

The value of this can change depending on how the function is called. If a function is called without an explicit context (i.e., without being called as a method of an object), the this keyword will refer to the global object (window in a browser or global in Node.js). This can lead to unexpected behavior and is a common source of bugs in JavaScript code.

To avoid this problem, it is often necessary to bind the this keyword to a specific object using the bind, call, or apply methods. These methods allow you to explicitly set the value of this when calling a function, ensuring that it always refers to the correct object.
Here's an example of how to use the bind method to set the this keyword in a function:

    const person1 = {
      name: "John",
      age: 30,
      greet: function() {
        console.log("Hello, my name is " + this.name + " and I am " + this.age + " years old.");
      }
    };

    const person2 = {
      name: "Jane",
      age: 25
    };

    const greetPerson2 = person1.greet.bind(person2);

    greetPerson2(); // logs "Hello, my name is Jane and I am 25 years old."

Enter fullscreen mode Exit fullscreen mode

the bind method is used to create a new function greetPerson2 that has its this keyword set to the person2 object. When greetPerson2 is called, it uses the greet method from person1, but with this bound to person2. This allows the method to access the name and age properties of person2 instead of person.

When a function is called with the this keyword, its value is determined by the way the function is called. Here are the four main ways that the value of this can be set:

  • Global context: When a function is called outside of any object or function, this refers to the global object (e.g. window in a browser or global in Node.js).

  • Object context: When a function is called as a method of an object, this refers to the object itself.

 const person = {
   name: "John",
   greet: function() {
     console.log("Hello, my name is " + this.name);
   }
 }

 person.greet(); // logs "Hello, my name is John"

Enter fullscreen mode Exit fullscreen mode

the greet method is called as a method of the person object, so this refers to the person object.

  • Constructor context: When a function is called with the new keyword to create a new object, this refers to the new object being created.
 const Person = (name) => {
   this.name = name;
 }

 const john = new Person("John");

 console.log(john.name); // logs "John"

Enter fullscreen mode Exit fullscreen mode

the Person function is called with the new keyword to create a new object, so this refers to the new object being created. The name property is then set on the new object.

  • Explicit binding: When a function is called using the call or apply method, this is explicitly set to a specific object.
 const person1 = { name: "John" };
 const person2 = { name: "Mary" };

 const greet = () => {
   console.log("Hello, my name is " + this.name);
 }

 greet.call(person1); // logs "Hello, my name is John"
 greet.call(person2); // logs "Hello, my name is Mary"

Enter fullscreen mode Exit fullscreen mode

the greet function is called with the call method and the this keyword is explicitly set to either person1 or person2. This allows us to reuse the same function with different objects.

Hoisting

Hoisting is a JavaScript mechanism that allows variables and functions to be used before they are declared. This means that you can declare a variable or function after you use it, and the JavaScript interpreter will still be able to access it. It is a behavior where variable and function declarations are moved to the top of their respective scopes. This means that even if a variable or function is declared later in the code, it can still be used before it is declared. All variable declarations are "hoisted" to the top of their scope (either the global scope or the scope of a function) and given an initial value of undefined. This means that even if you declare a variable at the bottom of a function, you can still use it at the top of the function without causing an error.

For example, consider the following code:

 const foo = () => {
   console.log(x);
   const x = 10;
 }

 foo();

Enter fullscreen mode Exit fullscreen mode

Even though x is declared after it is used in the console.log statement, the code still works because the variable declaration is hoisted to the top of the foo function and given an initial value of undefined. This means that the console.log statement logs undefined instead of throwing an error.

Hoisting also applies to function declarations, which are fully hoisted to the top of their scope. This means that you can call a function before it is declared in your code, like this:

 foo();

 const foo = () => {
   console.log("Hello!");
 }

Enter fullscreen mode Exit fullscreen mode

In this example, the foo function is declared after it is called, but because function declarations are hoisted to the top of the scope, the code still works and logs "Hello!" to the console.

However, it's important to note that hoisting only applies to function and variable declarations, not to function expressions or variable assignments. For example, if you declare a variable using let or const, you cannot use it before it is declared, like this:

 console.log(x); // throws an error
 let x = 10;

Enter fullscreen mode Exit fullscreen mode

It's considered best practice to declare all variables and functions at the top of their scope to avoid confusion and bugs caused by hoisting. By writing clear and readable code, you can ensure that your code works as expected and is easy to maintain over time.

Destructuring

Destructuring is a way to extract data from arrays and objects in JavaScript. It allows you to assign values to variables in a more concise and readable syntax.

Destructuring Arrays

To destructure an array, you can use square brackets [] on the left-hand side of an assignment to match the structure of the array. It works by matching the positions of the elements in the array with the variables being assigned to it.

 const numbers = [1, 2, 3];
 const [a, b, c] = numbers;

 console.log(a); // logs 1
 console.log(b); // logs 2
 console.log(c); // logs 3

Enter fullscreen mode Exit fullscreen mode

the values in the numbers array are destructured into the variables a, b, and c. This is equivalent to the following code:

 const numbers = [1, 2, 3];
 const a = numbers[0];
 const b = numbers[1];
 const c = numbers[2];

Enter fullscreen mode Exit fullscreen mode

Destructuring Objects

To destructure an object, you can use curly braces {} on the left-hand side of an assignment to match the keys of the object. Itworks by using the keys of the object to assign values to variables. For example:

 const person = { name: "John", age: 30 };
 const { name, age } = person;

 console.log(name); // logs "John"
 console.log(age); // logs 30

Enter fullscreen mode Exit fullscreen mode

In this example, the values in the person object are destructured into the variables name and age. This is equivalent to the following code:

 const person = { name: "John", age: 30 };
 const name = person.name;
 const age = person.age;

Enter fullscreen mode Exit fullscreen mode

You can also use default values and aliasing when destructuring objects as well as to destructure nested objects and arrays:

 const person = {
   name: "John",
   age: 30,
   address: { city: "New York", state: "NY" },
 };

 const {
   name,
   age,
   address: { city, country = "USA" },
 } = person;

 console.log(name); // logs "John"
 console.log(age); // logs 30
 console.log(city); // logs "New York"
 console.log(country); // logs "USA"

Enter fullscreen mode Exit fullscreen mode

In this example, the address property of the person object is destructured into the variables city and country, with a default value of "USA" for country if it is not present in the address object.

Destructuring can help to make code more concise and readable, especially when working with complex data structures. It is also a useful tool for extracting values from arrays and objects in a more intuitive and straightforward way.

Currying

Currying is a technique in functional programming where a function that takes multiple arguments is transformed into a sequence of functions that each take a single argument. The result of each function call is a new function that takes the next argument in the sequence until all arguments have been processed and the final result is returned. In is simply a technique in JavaScript that involves transforming a function that takes multiple arguments into a series of functions that each take a single argument.

When a curried function is called with a single argument, it returns a new function that expects the next argument. This process can continue until all the arguments have been supplied, at which point the original function is finally called.

To understand currying, let's first look at a function that takes multiple arguments:

 const add = (a, b, c) => {
   return a + b + c;
 }

 console.log(add(1, 2, 3)); // 6

Enter fullscreen mode Exit fullscreen mode

Here, the add function takes three arguments (a, b, and c) and returns their sum. We can use currying to transform this function into a series of functions that each take a single argument:

 const add = (a) => {
   return function(b) {
     return function(c) {
       return a + b + c;
     };
   };
 }

 console.log(add(1)(2)(3)); // 6

Enter fullscreen mode Exit fullscreen mode

In this example, we've defined the add function using three nested functions. The first function takes the first argument (a) and returns a new function that takes the second argument (b). The second function returns another new function that takes the third argument (c). Finally, the third function returns the sum of all three arguments.

The result of each function call is a new function that takes the next argument in the sequence. This allows us to pass the arguments one at a time, in any order, and still get the correct result.

Currying is useful in situations where you need to create new functions based on existing ones with partially applied arguments. This can simplify code and reduce the amount of redundant code. It can also make code more reusable by creating generic functions that can be used with different arguments.

Type Coercion

Type coercion is a concept in JavaScript that refers to the automatic conversion of values from one data type to another.
JavaScript is a dynamically typed language, which means that variables can hold values of different types at different times during the execution of a program. However, when an operation is performed on a value of a certain type, JavaScript may automatically convert that value to another type, if necessary, to complete the operation.

For example, when a string is added to a number in JavaScript, the number is automatically converted to a string before the concatenation occurs:

 const x = 5;
 const y = '10';

 console.log(x + y); // '510'

Enter fullscreen mode Exit fullscreen mode

In this example, we're adding a number (5) to a string ('10'). Because the + operator can be used for both addition and concatenation in JavaScript, the number 5 is automatically converted to a string ('5') before the concatenation occurs. The result of the operation is a string ('510').

Type coercion can sometimes lead to unexpected behavior and bugs in JavaScript programs, especially when it involves complex expressions or implicit conversions. To avoid these issues, it's often recommended to use explicit type conversion functions, such as Number(), String(), and Boolean(), to convert values to the desired type before performing operations on them:

 const x = 5;
 const y = '10';

 console.log(x + Number(y)); // 15

Enter fullscreen mode Exit fullscreen mode

In this example, we're using the Number() function to explicitly convert the string '10' to a number before adding it to the number 5. The result of the operation is a number (15), as expected.

JavaScript has two types of coercion: explicit coercion and implicit coercion.

Explicit coercion involves using built-in functions or operators to convert values from one type to another. For example, you can use the Number() function to convert a string to a number:

 const numString = "123";
 const num = Number(numString);

 console.log(typeof num); // "number"

Enter fullscreen mode Exit fullscreen mode

In this example, we're using the Number() function to explicitly convert a string containing the value "123" to a number. We then log the type of the num variable, which is "number".

Implicit coercion, on the other hand, happens automatically when JavaScript tries to perform an operation on values of different types. For example, the + operator can be used to concatenate strings, but it can also add numbers:

 console.log(1 + "2"); // "12"
 console.log("2" + 1); // "21"
 console.log(1 + true); // 2

Enter fullscreen mode Exit fullscreen mode

In these examples, JavaScript is implicitly coercing values of different types to perform the requested operation. In the first example, JavaScript is concatenating a string and a number, resulting in the string "12". In the second example, it's doing the same thing in reverse order. In the third example, JavaScript is coercing the boolean value true to a number (1) and adding it to the number 1, resulting in the number 2.

IIFE (Immediately Invoked Function Expression)

An IIFE, or Immediately Invoked Function Expression, is a JavaScript function that is executed as soon as it is defined. It's a commonly used pattern in JavaScript for creating a new scope and avoiding namespace collisions.

The syntax for defining an IIFE is to wrap a function expression in parentheses, followed by another set of parentheses that immediately invoke the function:

    (function () {
      // code to be executed immediately
    })();

    (() => {
      // code to be executed immediately
    })();

    (async () => {
      // code to be executed immediately
    })();

Enter fullscreen mode Exit fullscreen mode

we're defining an anonymous function expression and immediately invoking it. The function executes as soon as it is defined, without the need for a separate function call.

IIFEs are often used to encapsulate code and prevent it from interfering with other code in the global namespace. For example, if you have a variable with the same name in two different files, it can cause a collision and result in unexpected behavior. By wrapping your code in an IIFE, you can avoid these types of collisions.

IIFEs can also be used to create modules in JavaScript. By returning an object from the IIFE, you can expose only the public properties and methods of the module, while keeping private variables and functions hidden from the global scope:

 const myModule = ( () => {
   const privateVar = "Hello, world!";

   const privateFunc = () => {
     console.log(privateVar);
   }

   return {
     publicVar: "I'm a public variable",
     publicFunc: () => {
       console.log(this.publicVar);
       privateFunc();
     }
   };
 })();

 console.log(myModule.publicVar); // "I'm a public variable"
 myModule.publicFunc(); // logs "I'm a public variable" and "Hello, world!"

Enter fullscreen mode Exit fullscreen mode

In this example, we're defining a module using an IIFE. The module has a private variable called privateVar and a private function called privateFunc. It also has a public variable called publicVar and a public function called publicFunc, which calls privateFunc.

We then execute the IIFE and assign the resulting object to a variable called myModule. We can then access the public properties and methods of the module using the myModule variable, while keeping the private variables and functions hidden from the global scope.

IIFEs can also be used to create private variables and functions that are not accessible from outside the function. Here's an example of how this can be done:

IIFEs can also be used to create private variables and functions that are not accessible from outside the function. Here's an example of how this can be done:

 const myModule = ( () => {
   const privateVariable = "Hello, world!";

   const privateFunction = () => {
     console.log(privateVariable);
   }

   return {
     publicFunction: () => {
       privateFunction();
     }
   };
 })();

 myModule.publicFunction(); // logs "Hello, world!"

Enter fullscreen mode Exit fullscreen mode

In this example, we're using an IIFE to create a module with a private variable and function. The private variable and function are not accessible from outside the module, but we've also included a public function that can be called from outside the module. When the public function is called, it calls the private function, which logs the value of the private variable to the console.

IIFEs can be a powerful tool in JavaScript for creating private variables and functions, as well as for avoiding namespace collisions and creating new scopes.

Prototype Inheritance

Prototype Inheritance is a fundamental concept in JavaScript that allows objects to inherit properties and methods from other objects. In JavaScript, every object has a prototype, which is a reference to another object from which it inherits its properties and methods.
In JavaScript, objects have a prototype property, which points to another object. When you access a property or method on an object and it doesn't exist on that object, JavaScript will look for the property or method on the object's prototype. If the property or method is found on the prototype, it will be used.

To illustrate how prototype inheritance works in JavaScript, let's consider an example. Suppose we have a Person object with a name property and a sayHello() method:

 const Person = (name) => {
   this.name = name;
 }

 Person.prototype.sayHello = () => {
   console.log("Hello, my name is " + this.name);
 };

Enter fullscreen mode Exit fullscreen mode

We can create a new Person object and call its sayHello() method like this:

 const person = new Person("John");
 person.sayHello(); // logs "Hello, my name is John"

Enter fullscreen mode Exit fullscreen mode

Now suppose we want to create a Student object that inherits from Person and also has a grade property:

 const Student = (name, grade) => {
   Person.call(this, name);
   this.grade = grade;
 }

 Student.prototype = Object.create(Person.prototype);
 Student.prototype.constructor = Student;

Enter fullscreen mode Exit fullscreen mode

In this example, we're using the Object.create() method to create a new object that inherits from Person.prototype, which is the prototype of the Person object. We're then setting the constructor property of the Student.prototype object to Student.

Now we can create a new Student object and call its sayHello() method, which is inherited from the Person object:

 const student = new Student("Jane", 12);
 student.sayHello(); // logs "Hello, my name is Jane"
 console.log(student.grade); // logs 12

Enter fullscreen mode Exit fullscreen mode

In this example, we're able to create a Student object that inherits properties and methods from the Person object. This allows us to reuse code and create new objects with similar functionality.

Here's another example of how prototype inheritance works:

 // create a new object
 const person = {
   name: "John",
   age: 30,
   greet: () => console.log(`Hello, my name is ${this.name} and I'm ${this.age} years old`);

 };

 // create a new object that inherits from the person object
 const student = Object.create(person);

 // add a new property to the student object
 student.major = "Computer Science";

 // call the greet method on the student object
 student.greet(); // logs "Hello, my name is John and I'm 30 years old"

 // change the name property on the person object
 person.name = "Jane";

 // call the greet method on the student object again
 student.greet(); // logs "Hello, my name is Jane and I'm 30 years old"

Enter fullscreen mode Exit fullscreen mode

In the above code block, we're creating a person object with a name, age, and greet property. We then create a new object called student that inherits from the person object using Object.create(). We add a new major property to the student object.

When we call the greet() method on the student object, JavaScript first looks for the greet() method on the student object itself. Since the greet() method doesn't exist on the student object, JavaScript looks for it on the person object, which is the object that the student object inherits from.
When we change the name property on the person object, it affects the name property on the student object as well. This is because the student object is inheriting the name property from the person object through prototype inheritance.

Prototype inheritance allows you to create objects that share properties and methods, which can help reduce duplication in your code and make your code more modular and maintainable.

Conclusion

In conclusion, JavaScript is a powerful language with many advanced concepts that can be difficult to understand. In this article, we've explored some of the trickiest concepts in JavaScript, including closures and scope, hoisting, destructuring, currying, type coercion, IIFE, prototype inheritance, and the event loop.
Understanding these concepts is crucial for writing efficient and effective JavaScript code. While they can be challenging to learn, they can also unlock new possibilities for developers and make their code more robust and flexible.
As you continue to improve your skills in JavaScript, don't be afraid to dive deeper into these concepts and explore their nuances. The more you understand about the language, the better equipped you'll be to tackle any coding challenge that comes your way.

Top comments (4)

Collapse
 
jonrandy profile image
Jon Randy ๐ŸŽ–๏ธ • Edited

Closures are created whenever a function is defined inside another function

This isn't correct, a closure is created every time a function is created - regardless of whether it was created inside another function.

Collapse
 
danireptor profile image
Daniel Bemsen Akosu

Thank you very much. I just confirmed it. closures are created every time a function is created, at function creation time. Iโ€™ll update it

Collapse
 
fruntend profile image
fruntend

ะกongratulations ๐Ÿฅณ! Your article hit the top posts for the week - dev.to/fruntend/top-10-posts-for-f...
Keep it up ๐Ÿ‘

Collapse
 
danireptor profile image
Daniel Bemsen Akosu

Thank you! it means alot to me.