JavaScript Essentials, Mastering Core Concepts

This post delves into fundamental and advanced JavaScript topics that every developer should have in their toolkit.

Bryan Aguilar
15 min readAug 29, 2024

In the ever-evolving landscape of web development, JavaScript stands as a cornerstone language, powering interactive and dynamic experiences across the internet. Whether you’re a seasoned developer or just starting your journey, mastering key JavaScript concepts is crucial for building robust, efficient, and maintainable applications.

Whether you’re preparing for a job interview or looking to level up your skills, understanding these concepts will give you a solid foundation in JavaScript and set you apart in the competitive world of web development.

Hoisting

Hoisting is a behavior in JavaScript where variable and function declarations are moved to the top of their respective scopes during the compilation phase, before the code is executed. This means that regardless of where variables and functions are declared in the code, they are treated as if they are declared at the beginning of their scope. However, it’s crucial to understand that only the declarations are hoisted, not the initializations.

console.log(x); // Output: undefined
var x = 5;

// The above code is interpreted as:
var x;
console.log(x);
x = 5;

In this example, the variable x is hoisted to the top of its scope, but its initialization remains in place. That's why we get undefined when we try to log x before its initialization.

Other Example

Case 1

f1();
f2();

function f1() {}
const f2 = () => {};

In this case:

  • f1() will run without any issues. This is because function declarations (using the function keyword) are fully "hoisted" to the top of the scope. This means that the f1 function is available to be called from the start of the script.
  • f2() will throw an error, specifically a “ReferenceError: Cannot access ‘f2’ before initialization.” This happens because, although the declaration of f2 is hoisted, its initialization is not. Variables declared with const (and let) are hoisted to the top of their block but enter a "temporal dead zone" until they are initialized in the code.

Case 2

function f1() {}
const f2 = () => {};

f1();
f2();

In this case:

  • f1() will run without any issues, for the same reasons as in Case 1.
  • f2() will also run without any issues. By this point in the code, f2 has already been declared and initialized, so it's ready to be called.

The key difference

Function declarations (using function) are fully hoisted and can be called from anywhere within the scope in which they are defined.

Function expressions assigned to variables (such as the arrow function assigned to f2) follow the hoisting rules for variables. With const and let, they are hoisted but not initialized until the execution flow reaches their declaration.

Function declarations vs. function expressions

  • Function declarations are fully hoisted
  • Function expressions are not hoisted
foo(); // This works
bar(); // This throws an error

function foo() {
console.log('foo');
}

var bar = function () {
console.log('bar');
};

let and const

Variables declared with let and const are hoisted, but not initialized. They are in a temporal dead zone from the start of the block until the declaration is reached.

Class declarations

Likelet and const, classes are hoisted, but not initialized.

Best practices

To avoid confusion, it’s generally recommended to declare variables at the top of their scope and functions before they are used.

Understanding hoisting is crucial for debugging and writing clean, predictable JavaScript code. It helps explain certain behaviors that might otherwise seem counterintuitive to developers new to the language.

Closures

Closures are an essential concept in JavaScript that allow functions to retain access to variables from their parent scope, even after the parent function has finished executing. This is achieved by creating a closure, which is a function that has access to its lexical scope even when it is executed outside that scope.

function outer() {
let x = 10;

function inner() {
console.log(x);
}

return inner;
}

const closure = outer();
closure(); // Output: 10

Prototypes

Prototypes are an essential concept in JavaScript that allow objects to inherit properties and methods from other objects. This is achieved by creating a prototype object and assigning it to the constructor function of the object. The prototype object contains the methods and properties that the object will inherit from the constructor function.

function Person(name, age) {
this.name = name;
this.age = age;
}

Person.prototype.greet = function () {
console.log(`Hello, my name is ${this.name} and I am ${this.age} years old.`);
};

const person = new Person('John', 30);
person.greet(); // Output: Hello, my name is John and I am 30 years old.

Scope

Scope refers to the visibility and accessibility of variables, functions, and objects in a particular part of your code. JavaScript has two types of scope: global scope and local scope. Global scope refers to variables, functions, and objects that are accessible throughout your entire codebase. Local scope refers to variables, functions, and objects that are only accessible within a specific block of code.

// Example 1: Global scope
let x = 10;
console.log(x); // Output: 10

function greet() {
let x = 20;
console.log(x); // Output: 20
}
greet();
console.log(x); // Output: 10

The this Keyword in JavaScript

The this keyword in JavaScript refers to the object that is executing the current function. Its value is determined by how a function is called, which can make it a powerful but sometimes confusing feature of the language.

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

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

// In this example, 'this' inside the greet function refers to the 'person' object, so 'this.name' accesses the 'name' property of 'person'.

Global context

In the global execution context (outside any function), this refers to the global object (window in browsers, global in Node.js).

Function context

The value of this inside a function depends on how the function is called:

  • As a method of an object: this refers to the object.
  • As a standalone function: this refers to the global object (in non-strict mode) or undefined (in strict mode).

Arrow functions

Arrow functions do not bind their own this. Instead, they inherit this from the enclosing scope.

const obj = {
name: 'Alice',
sayHello: () => {
console.log(`Hello, ${this.name}`);
}
};
obj.sayHello(); // Output: "Hello, undefined"

Explicit binding

You can explicitly set the value of this using methods like call(), apply(), or bind().

function greet() {
console.log(`Hello, ${this.name}`);
}
const person = { name: 'Bob' };
greet.call(person); // Output: "Hello, Bob"

Constructor functions

When a function is used as a constructor (with the ‘new’ keyword), ‘this’ refers to the newly created instance.

Understanding thisis crucial for working with object-oriented patterns in JavaScript and for understanding how many JavaScript libraries and frameworks operate.

Arrow Functions in JavaScript

Arrow functions, introduced in ES6 (ECMAScript 2015), provide a more concise syntax for writing function expressions. They offer not just syntactic sugar, but also some functional differences compared to regular functions.

// Regular function
function add(a, b) {
return a + b;
}

// Arrow function
const addArrow = (a, b) => a + b;

console.log(add(2, 3)); // Output: 5
console.log(addArrow(2, 3)); // Output: 5

Syntax

Arrow functions have a shorter syntax, especially for simple functions.

// Single parameter
const square = (x) => x * x;

// No parameters
const sayHello = () => console.log('Hello');

// Multiple statements
const greet = (name) => {
const greeting = `Hello, ${name}!`;
console.log(greeting);
};

this binding

Arrow functions do not bind their own ‘this’. Instead, they inherit ‘this’ from the enclosing scope (lexical scoping).

const obj = {
name: 'John',
regularFunction: function () {
console.log(this.name); // "John"
},
arrowFunction: () => {
console.log(this.name); // undefined
}
};

No arguments object

Arrow functions don’t have their own arguments object. You can use rest parameters instead.

const sum = (...args) => args.reduce((a, b) => a + b, 0);

Cannot be used as constructors

Arrow functions cannot be used with the new keyword.

No duplicate named parameters

In strict mode, regular functions allow duplicate named parameters, but arrow functions don’t.

Implicit return

For single-expression bodies, the return keyword can be omitted.

const multiply = (a, b) => a * b;

No new.target keyword

Arrow functions do not have their own new.target keyword.

Cannot be used for methods

Due to their this binding behavior, arrow functions are generally not suitable for object methods.

When to use

  • For short, simple functions
  • When you want to preserve the lexical this
  • In callbacks where you don’t need to rebind this

When not to use

  • For object methods that need to access this
  • As constructors
  • When you need to use arguments object or new.target

Destructuring in JavaScript

Destructuring is a convenient way of extracting multiple values from data stored in objects and arrays. It allows you to unpack values from arrays, or properties from objects, into distinct variables.

// Object destructuring
const person = { name: 'John', age: 30, job: 'developer' };
const { name, age } = person;

console.log(name); // Output: 'John'
console.log(age); // Output: 30

// Array destructuring
const colors = ['red', 'green', 'blue'];
const [firstColor, secondColor] = colors;

console.log(firstColor); // Output: 'red'
console.log(secondColor); // Output: 'green'

Default values

You can assign default values when the extracted value is undefined

const { name, age, country = 'Unknown' } = person;

Renaming variables

You can assign a property to a variable with a different name.

const { name: fullName } = person;
console.log(fullName); // Output: 'John'

Rest operator in destructuring

You can use the rest operator (...) to collect remaining properties.

const { name, ...rest } = person;
console.log(rest); // Output: { age: 30, job: 'developer' }

Nested destructuring

You can use nested destructuring to extract values from nested objects.

const user = {
id: 42,
details: {
name: 'John',
age: 30
}
};
const {
details: { name, age }
} = user;

Function parameter destructuring

You can use destructuring in function parameters.

function printPerson({ name, age }) {
console.log(`${name} is ${age} years old`);
}
printPerson(person);

Swapping variables

You can easily swap variables using array destructuring.

let a = 1;
let b = 2;
[a, b] = [b, a];
console.log(a, b); // Output: 2 1

Destructuring is a powerful feature that can make your code more readable and concise, especially when working with complex data structures or API responses. It’s widely used in modern JavaScript development, particularly in frameworks like React for props and state management.

ES6 Modules (import/export)

ES6 Modules provide a way to organize and structure JavaScript code by allowing you to split your code into separate files and export/import functionality between them. This system helps in creating more maintainable and scalable applications.

// math.js
export const add = (a, b) => a + b;
export const subtract = (a, b) => a - b;
// main.js
import { add, subtract } from './math.js';
console.log(add(5, 3)); // Output: 8
console.log(subtract(10, 4)); // Output: 6

Named Exports

You can export multiple values from a module using named exports.

// utils.js
export const helper1 = () => {
/* ... */
};
export const helper2 = () => {
/* ... */
};

Default Exports

A module can have one default export.

// person.js
export default class Person {
/* ... */
}

// main.js
import Person from './person.js';

Renaming Imports and Exports

You can rename exports and imports using the as keyword.

// math.js
export { add as sum, subtract as minus };

// main.js
import { sum as addition, minus as subtraction } from './math.js';

Importing All

You can import all exports from a module as an object.

import * as mathUtils from './math.js';
console.log(mathUtils.add(2, 3));

Dynamic Imports

ES2020 introduced dynamic imports for loading modules conditionally.

if (condition) {
import('./module.js').then((module) => {
// Use module
});
}

Module Scope

Variables and functions in a module are scoped to that module unless explicitly exported.

Static Structure

The import and export statements are static, meaning they are analyzed at compile time.

No Global Scope Pollution

Modules don’t add their top-level variables to the global scope.

Browser Support

To use ES6 modules in browsers, you need to use the type="module" attribute in the script tag.

<script type="module" src="main.js"></script>

Node.js Support

Node.js supports ES6 modules, but you may need to use the .mjs extension or set “type”: “module” in package.json.

ES6 Modules are a fundamental part of modern JavaScript development, enabling better code organization, encapsulation, and reusability.

Async/Await

Async/Await is a syntactic feature introduced in ES2017 (ES8) that provides a more comfortable and readable way to work with asynchronous code. It’s built on top of Promises and allows you to write asynchronous code that looks and behaves more like synchronous code.

async function fetchUserData() {
try {
const response = await fetch('https://api.example.com/user');
const userData = await response.json();
console.log(userData);
} catch (error) {
console.error('Error fetching user data:', error);
}
}

fetchUserData();

Async Functions

Functions declared with the async keyword always return a Promise.

async function greet() {
return 'Hello';
}
greet().then(console.log); // Output: Hello

Await Operator

The await keyword can only be used inside async functions. It pauses the execution of the function until the Promise is resolved.

Error Handling

You can use try/catch blocks for error handling, which is more intuitive than Promise’s .catch() method.

Parallel Execution

To run multiple promises in parallel, you can use Promise.all() with await.

async function fetchMultipleUsers() {
const [user1, user2] = await Promise.all([
fetch('https://api.example.com/user1'),
fetch('https://api.example.com/user2')
]);
// Process user1 and user2
}

Async with Arrow Functions

Arrow functions can also be async.

const fetchData = async () => {
const result = await someAsyncOperation();
return result;
};

Top-level await

In modern JavaScript environments, you can use await at the top level of a module.

// In a module
const data = await fetch('https://api.example.com/data');
export { data };

Interaction with Promises

Async/Await is fully compatible with Promises. You can mix and match them as needed.

Performance Considerations

While Async/Await makes code more readable, it’s important to be aware of potential performance implications, especially when using await sequentially for operations that could be run in parallel.

Browser Support

Async/Await is widely supported in modern browsers, but for older browsers, you might need to use a transpiler like Babel.

Async/Await significantly simplifies working with asynchronous operations in JavaScript, making code more readable and maintainable. It’s particularly useful when dealing with multiple asynchronous operations that depend on each other, and it has become a standard way of handling asynchronous code in modern JavaScript development.

Event Loop and Asynchrony in JavaScript

The event loop is a fundamental concept in JavaScript that enables asynchronous programming. It’s a mechanism that allows JavaScript to perform non-blocking operations despite being single-threaded. Asynchrony refers to the ability to execute code out of sequence, allowing long-running operations to be performed without blocking the main thread.

Here’s a simple example to illustrate how the event loop and asynchrony work:

console.log('Start');

setTimeout(() => {
console.log('Timeout callback');
}, 0);

Promise.resolve().then(() => {
console.log('Promise callback');
});

console.log('End');

The output of this code will be:

Start
End
Promise callback
Timeout callback

This example demonstrates how asynchronous operations (setTimeout and Promise) are handled by the event loop.

Call Stack

JavaScript uses a call stack to keep track of function callSynchronous code is executed immediately and added to the call stack.

Web APIs

Browser APIs (like setTimeout, fetch, etc.) handle time-consuming operations outside the main JavaScript thread.

Callback Queue

When asynchronous operations complete, their callbacks are placed in the callback queue.

Microtask Queue

Promises use a separate microtask queue, which has higher priority than the regular callback queue.

Event Loop

The event loop constantly checks if the call stack is emptIf it is, it first processes all microtasks, then takes the next task from the callback queue and pushes it onto the call stack.

Non-blocking

Asynchronous operations allow the main thread to continue executing code while waiting for I/O operations, network requests, or timers to complete.

Promises and async/await

These are modern JavaScript features that make working with asynchronous code more manageable and readable.

Understanding the event loop and asynchrony is crucial for writing efficient JavaScript code, especially for applications that deal with I/O operations or need to maintain a responsive user interface.

Prototypes and Prototypal Inheritance

Prototypes are a fundamental concept in JavaScript that forms the basis for object-oriented programming in the language. Every object in JavaScript has an internal property called [[Prototype]], which can be accessed using Object.getPrototypeOf() or the deprecated proto property. This prototype is another object that the current object inherits properties and methods from.

Prototypal inheritance is the mechanism by which objects can inherit properties and methods from other objects. When a property or method is accessed on an object, JavaScript first looks for it on the object itself. If it’s not found, it looks up the prototype chain until it finds the property or reaches the end of the chain (usually Object.prototype).

// Create a base object
const Animal = {
makeSound() {
console.log('Some generic animal sound');
}
};

// Create a new object that inherits from Animal
const Dog = Object.create(Animal);

// Add a method specific to Dog
Dog.bark = function () {
console.log('Woof! Woof!');
};

// Create an instance of Dog
const myDog = Object.create(Dog);

// Using inherited and specific methods
myDog.makeSound(); // Output: "Some generic animal sound"
myDog.bark(); // Output: "Woof! Woof!"

Prototype Chain

Objects can form a chain of prototypes, allowing for multiple levels of inheritance.

Performance

Prototypal inheritance can be more memory-efficient than classical inheritance, as objects can directly share properties and methods.

Dynamic Nature

Prototypes can be modified at runtime, allowing for dynamic changes to object behavior.

Constructor Functions

Often used with prototypes to create object instances with shared methods.

Class Syntax

ES6 introduced the class keyword, which provides a more familiar syntax for creating objects and implementing inheritance, but it's essentially syntactic sugar over prototypal inheritance.

Object.create() vs New Keyword

Object.create() allows for direct prototype assignment, while the new keyword is used with constructor functions.

Prototype Pollution

Care must be taken to avoid unintentionally modifying object prototypes, which can lead to security vulnerabilities.

Property Shadowing

Properties defined on an object can shadow (override) properties of the same name in its prototype chain.

Understanding prototypes and prototypal inheritance is crucial for effective JavaScript programming, especially when working with object-oriented patterns or creating efficient code structures.

Map, Set, WeakMap, and WeakSet

Map

A Map is a collection of key-value pairs where both the keys and values can be of any type. Unlike objects, Maps allow keys of any type and maintain insertion order.

const userMap = new Map();
userMap.set('name', 'John');
userMap.set('age', 30);
userMap.set({ id: 1 }, 'custom object as key');

console.log(userMap.get('name')); // Output: John
console.log(userMap.size); // Output: 3
  • Maps preserve insertion order when iterating.
  • They provide methods like set(), get(), has(), delete(), and clear().
  • Maps are iterable and can be used with for...of loops.

Set

A Set is a collection of unique values of any type. It doesn’t allow duplicates.

const uniqueNumbers = new Set([1, 2, 3, 3, 4, 5, 5]);
console.log(uniqueNumbers); // Output: Set(5) { 1, 2, 3, 4, 5 }

uniqueNumbers.add(6);
console.log(uniqueNumbers.has(4)); // Output: true
  • Sets automatically remove duplicates.
  • They provide methods like add(), has(), delete(), and clear().
  • Sets are iterable and can be used with for...of loops.
  • Sets are useful for tasks like removing duplicates from arrays or checking if a value exists in a collection.

WeakMap

A WeakMap is similar to a Map, but with some key differences:

  • Keys must be objects.
  • References to key objects are held “weakly,” allowing them to be garbage collected if there are no other references.
let obj = { id: 1 };
const weakMap = new WeakMap();
weakMap.set(obj, 'associated data');

console.log(weakMap.get(obj)); // Output: associated data
obj = null; // The entry in weakMap will be removed automatically
  • WeakMaps are not iterable and don’t have a size property.
  • They’re useful for storing private data for objects or adding data without modifying the original object.

WeakSet

A WeakSet is similar to a Set, but:

  • It can only store object references.
  • References to objects in the set are held weakly.
let obj1 = { id: 1 };
let obj2 = { id: 2 };
const weakSet = new WeakSet([obj1, obj2]);

console.log(weakSet.has(obj1)); // Output: true
obj1 = null; // obj1 will be removed from the WeakSet automatically
  • WeakSets are not iterable and don’t have a size property.
  • They’re useful for storing a collection of objects and track which objects have been used or processed.

General considerations

  • WeakMaps and WeakSets help prevent memory leaks in certain scenarios.
  • They’re particularly useful in cases where you need to associate data with objects without preventing those objects from being garbage collected.
  • Regular Maps and Sets are more versatile and feature-rich, but WeakMaps and WeakSets serve specific use cases related to memory management.

These data structures provide powerful tools for managing collections in JavaScript, each with its own strengths and use cases.

Functional Programming

Functional programming (FP) is a programming paradigm that treats computation as the evaluation of mathematical functions and avoids changing state and mutable data. In JavaScript, which is a multi-paradigm language, functional programming techniques can be applied alongside other approaches.

Key concepts of functional programming include:

Pure Functions

Functions that always produce the same output for the same input and have no side effects. This makes code more predictable and easier to test.

Immutability

Instead of modifying data structures, create new ones with the desired changes. This helps prevent bugs caused by unexpected mutations.

Higher-Order Functions

Functions that can take other functions as arguments or return functions. They enable powerful abstractions and code reuse.

Function Composition

Building complex functions by combining simpler ones. This promotes modularity and reusability.

// Pure function
const add = (a, b) => a + b;

// Higher-order function
const map = (fn, arr) => arr.map(fn);

// Immutable data transformation
const numbers = [1, 2, 3, 4, 5];
const doubledNumbers = map((num) => num * 2, numbers);

// Function composition
const compose = (f, g) => (x) => f(g(x));
const addOne = (x) => x + 1;
const double = (x) => x * 2;
const addOneThenDouble = compose(double, addOne);

console.log(doubledNumbers); // [2, 4, 6, 8, 10]
console.log(addOneThenDouble(3)); // 8

Functional programming in JavaScript can lead to more predictable, testable, and maintainable code. However, it’s often most effective when combined pragmatically with other programming paradigms to suit the specific needs of a project.

JavaScript, with its rich set of features and evolving syntax, continues to be a cornerstone of modern web development. The concepts we’ve explored — from hoisting and closures to the event loop and functional programming paradigms — form the backbone of advanced JavaScript programming.

By embracing these advanced JavaScript concepts, you’re well-equipped to tackle complex development challenges and contribute to the ever-growing JavaScript ecosystem. Keep exploring, keep coding, and keep pushing the boundaries of what’s possible with JavaScript!

Don’t forget to visit my website.

Thank you for reading this article!

If you have any questions, don’t hesitate to ask me. My inbox will always be open. Whether you have a question or just want to say hello, I will do my best to answer it!

--

--

Bryan Aguilar

Senior Software Engineer · Systems Engineer · Full Stack Developer · Enthusiastic Data Analyst & Data Scientist | https://www.bryan-aguilar.com