Advanced JavaScript Concepts To Write High-Quality Code That Scales
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 fromDate.prototype
.Array
objects inherit fromArray.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
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);
To learn more about how to deal with callbacks in JavaScript read this tutorial explaining best practices.
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 statementsetTimeout(() => { ... }, 0)
: A log with setTimeout after 0 secondsPromise.resolve().then(() => { ... })
: A log within a Promise resolving instantlyconsole.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, thethen
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.
However, mastering these concepts takes a lot of time and practice, so please don't be discouraged if you don't fully understand them immediately. Keep experimenting with code, reading documentation, and learning from other JavaScript developers, to further increase your skill.
With these advanced JavaScript concepts in your toolkit, the possibilities for what you can create with JavaScript are truly endless.
Finally, what do you think about all these JavaScript concepts? Are you eager to dive deeper into them? Did you find them valuable? Also, do you have any questions regarding any of the concepts mentioned here? I would love to hear your thoughts and answer your questions. Please share everything in the comments.
Feel free to connect with me on Medium, LinkedIn, Twitter, and GitHub.
Thank you for reading, and happy coding!