Advanced JavaScript Concepts
Introduction
JavaScript is a powerful programming language extensively used for web development, server-side scripting, and more. While it has an easy learning curve for beginners, JavaScript is also used to build complex applications and systems that require many advanced programming concepts.
In this article, I will explain some of the most advanced JavaScript concepts that should be known by every experienced software developer. I will start with closures, a powerful way to create private variables and functions in JavaScript. Then, I explain the this keyword in detail.
Next, I will dive into prototypal inheritance, a key feature of JavaScript allowing objects to inherit properties and methods from other objects. Also, I will explain asynchronous programming, which is essential for building scalable and efficient web applications.
Afterward, I will cover hoisting, the JavaScript event loop, and type coercion.
In the end, I will show destructuring, another powerful feature of JavaScript that allows you to extract values from arrays and objects in a concise and readable way.
Whether you're a senior JavaScript developer or just getting started with the language, this tutorial will provide a comprehensive overview of many advanced concepts which are essential for building powerful and efficient web applications.
Closures
Closures are one of the most fundamental and powerful concepts in JavaScript. In a nutshell, a closure is a function that preserves variables and functions that were in scope when it was created, even if those variables and functions are no longer in scope. By doing this, the function is allowed to access and manipulate those variables and functions, even if they are in a different scope.
To completely understand closures, it is paramount to understand JavaScript's function scope. In JavaScript, variables declared inside functions are only accessible within that function's scope and cannot be accessed from the outside.
However, if you declare a function inside another function, the inner function can access the outer function's variables and functions, even if they are not passed as arguments. This is working, because the inner function creates a closure that preserves the outer function's variables and functions, and retains access to them.
This simple example should illustrate how closures work:
function outer() {
const name = "Paul";
function inner() {
console.log("Hello, " + name + "! Welcome to paulsblog.dev");
}
return inner;
}
const sayHello = outer();
sayHello(); // logs "Hello, Paul! Welcome to paulsblog.dev"
In this example, outer() declares a variable name and an inner function called inner(). inner() simply logs a message using the outer name variable. Then, outer() returns inner.
Now, calling outer() and assigning its return value to sayHello, a closure is created that includes the inner() function and the name variable. This closure keeps access to name even after outer() has completed its execution enabling calling sayHello to log the greeting message.
Closures are used extensively in JavaScript for a variety of purposes, such as creating private variables and functions, implementing callbacks, and handling asynchronous code.
This
In JavaScript, this is a special keyword that refers to the object executing a function or method. The value of this is depending on how a function is called, and it is determined dynamically at runtime. Additionally, the value of this can be different within the same function depending on how the function is called.
To write effective and flexible JavaScript code, it is paramount to understand this and how it can be used to reference the current object, pass arguments to a function, and create new objects based on existing ones.
Consider the following code snippet:
let person = {
firstName: "Paul",
lastName: "Knulst",
fullName: function() {
return this.firstName + " " + this.lastName;
}
};
console.log(person.fullName()); // Output: "Paul Knulst"
In this example, this refers to the person object. When person.fullName() is called, this refers to person, so this.firstName and this.lastName refer to the object properties.
Now, let's have a look at the following code snippet:
function myFunction() {
console.log(this);
}
myFunction(); // Output: Window object
In this example, this refers to the global object (i.e., the "window" object in a web browser). When the function is called, it defaults to the global object.
The following snippet will show that this will always refer to the current execution context, which can vary depending on how the function is called:
let obj = {
myFunction: function() {
console.log(this);
}
}
obj.myFunction(); // Output: {myFunction: ƒ}
let func = obj.myFunction
func(); // Output: Window object
In this snippet, obj is defined containing myFunction which is logs this to the console.
Calling myFunction by using the dot notation (obj.myFunction()) will result in this referring to the obj object itself. Therefore, the output will be {myFunction: ƒ} which is the obj object.
Calling the myFunction by assigning it to a new variable called func and calling it will result in logging the Window object because func is called without an object state and this will refer to the global object (which is the Window object).
In JavaScript, this is an important keyword. Also, it is necessary for every software developer to fully understand its functionality. After understanding everything explained in this chapter, you should read about bind and call which are used to manipulate this in JavaScript.
Prototypal inheritance
Prototypal inheritance is a mechanism available in JavaScript for objects to inherit properties and methods from other objects. Every object in JavaScript has a prototype, which properties and methods are inherited from.
For example:
- Date objects inherit from Date.prototype.
- Array objects inherit from Array.prototype.
If an object does not have a specific property or method, JavaScript looks up the prototype chain until it finds the property or method in the prototype of an object: Object.prototype
Consider the following example:
function Person(name, website) {
this.name = name;
this.website = website;
}
Person.prototype.sayHello = function() {
console.log(`Hello, my name is ${this.name} and this is my website: ${this.website}`);
};
const paul = new Person("Paul", "https://www.paulsblog.dev);
paul.sayHello(); // Output: Hello, my name is Paul and this is my website: https://www.paulsblog.dev
Copy
In this example, a constructor function for a Person object is created and a method is added to its prototype using the prototype property. Next, a new Person object is created and the sayHello() function is called which is not defined in the object itself, but as JavaScript looks up the prototype chain it will find it.
By using prototypal inheritance, objects that share common properties and methods can be created, which can help to write more efficient and modular code.
Asynchronous Programming
Asynchronous programming is a programming pattern that allows multiple tasks to run concurrently without blocking each other or the main thread. In JavaScript, this is often achieved using callbacks, promises, and async/await.
Callbacks
Callbacks are functions that are passed as arguments to other functions and are executed when a certain event occurs. For example, the setTimeout function takes a function as its first argument and executes it after a specified number of milliseconds.
// Use setTimeout to execute a callback after 1 second
setTimeout(function() {
console.log("Hello, world!");
}, 1000);
Promises
In contrast to callbacks, Promises are objects representing a value that may not be available yet, such as the result of an asynchronous operation. Additionally, they have a then method that takes a callback function as an argument and calls it after the Promise is resolved with a value:
// Use a promise to get the result of an asynchronous operation
const fetchData = () => {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve("Data received");
}, 1000);
});
};
// Call the fetchData function and handle the promise result
fetchData().then(result => {
console.log(result);
});
In this example fetchData() is defined as a function returning a Promise object containing a setTimeout call to simulate an asynchronous operation that resolves a String ("Data received").
By calling the fetchData() function the Promise will be returned and can be processed using the then() method. The then method, which will be executed after the asynchronous call is finished, takes a callback function to log the result to the console.
Async/Await
Async/await is a more recent addition to JavaScript that provides a simple, modern syntax for working with asynchronous functions that return Promises. It allows you to write asynchronous code that looks more like synchronous code by using the async and await keywords.
Look at the following example:
const fetchData = () => {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve("Data received");
}, 1000);
});
};
const getData = async () => {
try {
const result = await fetchData();
console.log(result);
} catch (error) {
console.error(error);
}
};
getData();
In this code snippet, a fetchData() function is defined that returns a Promise and resolves after one second returning the String "Data received". Then another function getData() is defined that uses the async keyword to mark it as an async function. This function will process the previously defined fetchData() function by using the await keyword and assigning its return value to the variable result. result is then logged to the console.
By using the async/await syntax in JavaScript, we can write asynchronous code synchronously making it easier to read and understand, since it avoids the nested callbacks or chains of .then() resulting in a so-called "callback or Promise hell".
Event Loop
The event loop is a fundamental core mechanism enabling asynchronous programming. In JavaScript, code is executed in a single-threaded environment, resulting in only one block of code can be executed at a time. This leads to problems in JavaScript because operations can take a long time to complete, such as network requests or file I/O, which can cause the program to block and become unresponsive.
To avoid blocking code execution due to long operations, JavaScript provides a mechanism called "event loop" which continuously monitors the call stack and the message queue data structures. While the call stack knows about functions that are currently being executed, the message queue holds a list of messages (events) and their associated callback functions.
If the call stack is empty, the event loop checks the message queue for any pending messages, retrieves the associated callback function, and pushes it onto the call stack, where it will be executed.
This event loop mechanism allows JavaScript to handle asynchronous events without blocking the program.
For example, imagine a program that wants to perform a network request. The request will be added to the message queue, and the program can continue to execute other code while waiting for the network response. If a response is received, the callback function of the network request is added to the message queue, and the event loop will execute it when the call stack is empty.
Consider the following JavaScript code snippet:
console.log('start');
setTimeout(() => {
console.log('setTimeout');
}, 0);
Promise.resolve().then(() => {
console.log('Promise');
});
console.log('end');
This example contains four different functions to log a String:
- console.log('start'): Simple log statement
- setTimeout(() => { ... }, 0): A log with setTimeout after 0 seconds
- Promise.resolve().then(() => { ... }): A log within a Promise resolving instantly
- console.log('end'): Simple log statement.
The output of this program will be:
start
end
Promise
setTimeout
This might seem counterintuitive since the timer inside setTimeout is set to 0ms, so you might expect it to be executed immediately or immediately after the synchronous calls that log start and end. The reason that Promise is logged before setTimeout is due to the way that JavaScript's event loop works and that the message queue can be further divided into macrotasks (setTimeout, setInterval) and microtasks (Promises, async/await).
The event loop processes this program in the following order:
- The console.log('start') statement is executed and the string "start" is logged to the console.
- The setTimeout function is called with a callback that will log the string "setTimeout" to the console. Although the timer is set to 0ms, it will not execute immediately. Instead, it is added to the event queue.
- The Promise.resolve().then() statement is executed, which creates a new Promise that is immediately resolved. Next, the then method is called on this Promise, with a callback that logs the string "Promise" into the console. This callback is added to the microtask queue.
- The console.log('end') statement is executed and the string "end" is logged into the console.
- As the call stack is empty now, JavaScript checks the microtask queue (part of the event loop) for any tasks that need to be executed and finds the callback from the then method of the Promise. It will be executed resulting in logging the string "Promise" to the console.
- After the microtask queue is emptied, JavaScript checks the event queue for any tasks that need to be executed and finds the callback from the setTimeout function and executes it resulting in logging the string "setTimeout" to the console.
The event loop allows the program to handle asynchronous events in a non-blocking way so that other code can continue to be executed while waiting for the asynchronous operations to complete. Also, keep in mind that there is slightly counterintuitive processing while working with setTimeout/Promises.
Hoisting
The Hoisting mechanism in JavaScript moves variable and function declarations to the top of their respective scopes during compilation, regardless of the place where are they defined in the source code. This enables you to use a variable or function before declaring it.
However, it is important to know that only the variable or function declaration is hoisted and not the assignment or initialization. This will lead to having the value of undefined if accessing the variable before it has been assigned.
Consider the following code snippet showing variable hoisting:
console.log(x); // Output: undefined
var x = 10;
In this example, the variable x will be logged before it is declared. The code runs without any error and will log undefined to the console because only the variable declaration is hoisted to the top of the scope, but the assignment is not.
The next code snippet will show function hoisting:
myFunction(); // Output: "Hello World"
function myFunction() {
console.log("Hello World");
}
In this example, a function called myFunction is invoked before it is declared. As the declaration is hoisted to the top of their scope the code works as expected and logs "Hello World" to the console.
The last code snippet will show function expression hoisting:
myFunction(); // Output: TypeError: myFunction is not a function
var myFunction = function() {
console.log("Hello World");
};
In this example, a function is called before it is defined. As the function expression is declared and assigned to the variable myFunction it is not hoisted in the same way as function declarations. This results in getting a TypeError when we try to call myFunction.
While JavaScript hoisting can be a useful mechanism in many cases, it can also lead to confusion and unexpected behavior if not used properly. It is paramount to understand how hoisting works in JavaScript to avoid common pitfalls. Also, always try to write code that is easy to read and understand.
To avoid issues with hoisting, you should generally declare and assign variables and functions at the beginning of their respective scopes, rather than relying on hoisting to move them to the top.
Type coercion
In JavaScript, type coercion is the automatic conversion of values from one data type to another data type. Normally, this happens automatically when using a specific type in a context where a different data type is expected.
As JavaScript is well-known for unexpected behavior with data types, it is paramount to understand how type coercion works. If not familiar with type coercion, it could result in unexpected behavior of the JavaScript code.
In this code snippet, some famous type coercions of JavaScript are shown:
// Example 1
console.log(5 + "5"); // Output: "55"
// Example 2
console.log("5" * 2); // Output: 10
// Example 3
console.log(false == 0); // Output: true
// Example 4
console.log(null == undefined); // Output: true
Example 1: Concatenate a string and a number using +. In JavaScript, using + with a String will automatically transform the other value into a String.
Example 2: Multiply a String and a number using *. By using * the String will be coerced into a number.
Example 3: Compare a boolean value with a number using ==. JavaScript coerces the boolean into a number (false -> 0) and then compare them resulting in the output true, because 0 == 0.
Example 4: Compare null and undefined using ==. Because JavaScript automatically coerces null to undefined it will result in comparing undefined == undefined which will be true.
As type coercion can be useful in some rare cases, it can lead to confusion and huge bugs, especially if you are not aware of the automatic conversions. To avoid these issues, you can use explicit type conversions (e.g. parseInt(), parseFloat(), etc.) or use strict equality (===).
Fun Fact: There is an esoteric and educational programming style base on weird type coercion in JavaScript which only uses six different characters to write and execute JavaScript code. Using the approach explained on their website the following code snippet is executable in any JavaScript console and will be the same as executing alert(1):
[][(![]+[])[+[]]+(![]+[])[!+[]+!+[]]+(![]+[])[+!+[]]+(!![]+[])[+[]]][([][(![]+[])[+[]]+(![]+[])[!+[]+!+[]]+(![]+[])[+!+[]]+(!![]+[])[+[]]]+[])[!+[]+!+[]+!+[]]+(!![]+[][(![]+[])[+[]]+(![]+[])[!+[]+!+[]]+(![]+[])[+!+[]]+(!![]+[])[+[]]])[+!+[]+[+[]]]+([][[]]+[])[+!+[]]+(![]+[])[!+[]+!+[]+!+[]]+(!![]+[])[+[]]+(!![]+[])[+!+[]]+([][[]]+[])[+[]]+([][(![]+[])[+[]]+(![]+[])[!+[]+!+[]]+(![]+[])[+!+[]]+(!![]+[])[+[]]]+[])[!+[]+!+[]+!+[]]+(!![]+[])[+[]]+(!![]+[][(![]+[])[+[]]+(![]+[])[!+[]+!+[]]+(![]+[])[+!+[]]+(!![]+[])[+[]]])[+!+[]+[+[]]]+(!![]+[])[+!+[]]]((![]+[])[+!+[]]+(![]+[])[!+[]+!+[]]+(!![]+[])[!+[]+!+[]+!+[]]+(!![]+[])[+!+[]]+(!![]+[])[+[]]+([][(![]+[])[+[]]+(![]+[])[!+[]+!+[]]+(![]+[])[+!+[]]+(!![]+[])[+[]]]+[])[+!+[]+[!+[]+!+[]+!+[]]]+[+!+[]]+([+[]]+![]+[][(![]+[])[+[]]+(![]+[])[!+[]+!+[]]+(![]+[])[+!+[]]+(!![]+[])[+[]]])[!+[]+!+[]+[+[]]])()
Destructuring
JavaScript has a powerful feature called Destructuring that allows extracting and assigning values from objects and arrays into variables in a very concise and readable way. It can also help you avoid common pitfalls like using undefined values.
This feature is especially useful when working with complex data structures because it grants easy access and use of individual values without having to write a lot of boilerplate code.
Destructuring Arrays
When destructuring arrays, square brackets are used to specify the variables to be filled with the array values. The order of the variables corresponds to the order of the values in the array.
Here's an example:
const numbers = [1, 2, 3, 4, 5];
const [first, second, ...rest] = numbers;
console.log(first); // Output: 1
console.log(second); // Output: 2
console.log(rest); // Output: [3, 4, 5]
This code snippet shows how destructuring is used to extract the first, the second, and the rest values from the numbers array. In JavaScript destructuring the spread operator (...) syntax is used to extract all not mentioned values into the rest variable.
Destructuring Objects
When destructuring objects, the curly braces are used to specify the properties you want to extract and the variables you want to assign them to. The variable names have to match the property names of the object.
Here's an example:
const person = {
name: 'Paul Knulst',
role: 'Tech Lead',
address: {
street: 'Blogstreet',
city: 'Anytown',
country: 'Germany'
}
};
const {name, role, address: {city, ...address}} = person;
console.log(name); // Output: Paul Knulst
console.log(role); // Output: Tech Lead
console.log(city); // Output: Anytown
console.log(address);
This example shows creating an object representing a person (it's me) and destructuring the name, the role, the address.city, and the asdress.street/address.country into the variables name, role, city, and the address
Destructuring With Default Values
In addition to destructuring values from objects and arrays, it is possible to specify default values for variables that will be used if the property is undefined. This should be used if working with optional or nullable values.
Here's an example:
const person = {
name: 'Paul Knulst',
role: 'Tech Lead'
};
const {name, role, address = 'Unknown'} = person;
console.log(name); // Output: Paul Knulst
console.log(role); // Output: Tech Lead
console.log(address); // Output: Unknown
This example also defines an object representing a person and then uses destructuring to extract name and role properties into the variables name and role. Additionally, a default value of Unknown is used for the address variable because this person object does not have an address property.
Closing Notes
JavaScript is a very versatile and powerful programming language that can be used to build a wide range of applications and systems. The advanced concepts I covered in this guide are essential for building scalable, efficient, and maintainable JavaScript programs.
By understanding closures, this, prototypal inheritance, asynchronous programming, hoisting, the event loop, type coercion, and destructuring, you should be well-equipped to tackle complex JavaScript projects and build robust, high-quality software.
Article credit: Paul Knulst
Image credit: Daniel Movsesyan