Sitemap

Exploring 8 JavaScript Fundamentals

11 min readJan 2, 2025

--

In this article, you will find;

  • Hoisting.
  • Closure.
  • Promise.
  • Function Currying.
  • Execution Context.
  • Call, Apply, and Bind.
  • Polyfills for Common Array and String Methods.
  • Prototypal Inheritance.

Hoisting

Hoisting is the process where JavaScript internally moves your variable and function declarations to the top of their scope at compile time, before any code executes. However, the effect of hoisting differs depending on whether you’re using var, let, constor function declaration:

var

  • Hoisted with an initial value of undefined.
  • If you reference a var variable before its declaration, JavaScript simply returns undefined, rather than throwing an error.
// var is hoisted: it defaults to undefined if used before declaration
console.log(grape);
// Output: undefined (no ReferenceError; it's hoisted with "undefined")

var grape = "Green Grape";
console.log(grape);
// Output: "Green Grape"

let and const

  • Also hoisted, but they reside in the Temporal Dead Zone (TDZ) from the start of their enclosing scope until the declaration line.
  • Attempting to access them before that line triggers a ReferenceError.
  • const requires an initial value and can’t be reassigned later.
// let is also hoisted, but it stays in the Temporal Dead Zone (TDZ):
try {
console.log(berry);
// ReferenceError: Cannot access 'berry' before initialization
} catch (error) {
console.error("Error accessing berry:", error.message);
}

let berry = "Strawberry";
console.log(berry);
// Output: "Strawberry"
// const behaves like let regarding the TDZ but must be initialized immediately.
try {
console.log(mango);
// ReferenceError: Cannot access 'mango' before initialization
} catch (error) {
console.error("Error accessing mango:", error.message);
}

const mango = "Honey Mango";
console.log(mango);
// Output: "Honey Mango"

// Attempting to reassign a const will cause a TypeError
// mango = "Green Mango"; // TypeError: Assignment to constant variable.

Function Declarations

  • Fully hoisted with their implementation, allowing you to call them before they appear in the code.
// Function declarations are fully hoisted:
blendFruits();
// Output: "Blending the fruits into a smoothie!"

function blendFruits() {
console.log("Blending the fruits into a smoothie!");
}

Closure

A closure is created when a function has access to variables from its outer function’s scope. This access remains even after the outer function has finished execution. Closures work because of JavaScript’s lexical scoping rules: the environment in which a function is defined is stored within the function itself, allowing it to retain references to any variables in that environment.

Why Are Closures Useful?

  1. Data Encapsulation: Closures let you create private data or methods that aren’t accessible from outside the function.
  2. Maintaining State: They help keep state across multiple invocations of a function.
  3. Factory Functions: You can generate specialized functions on the fly, each with its own persistent environment.

How Do Closures Work Internally?

When a function is declared, it keeps a reference to its lexical environment. Even after the outer function returns, that reference is preserved. As a result, whenever you invoke the inner function, it can still access and modify the outer scope’s variables.

Simple Closure

function createFruitVault() {
const secretFruit = "Dragon Fruit";

return function revealSecretFruit() {
console.log("The secret fruit is:", secretFruit);
};
}

const openVault = createFruitVault();
openVault();
// Output: "The secret fruit is: Dragon Fruit"
  • The inner function revealSecretFruit closes over the variable secretFruit.
  • Even though createFruitVault has finished running, revealSecretFruit still has access to secretFruit due to the closure.

Maintaining State with Closures

function fruitCounter() {
let count = 0;

return function addFruit() {
count++;
console.log("Total fruits stored:", count);
};
}

const addOneFruit = fruitCounter();

addOneFruit();
// Output: "Total fruits stored: 1"

addOneFruit();
// Output: "Total fruits stored: 2"
  • Each call to addOneFruit increments the count.
  • The variable count resides in the outer scope of fruitCounter, and only the returned function can manipulate it.

Data Privacy

function secretFruitStore() {
let inventory = ["Apple", "Banana"];

return {
getInventory: function() {
return [...inventory]; // return a copy
},
addFruit: function(fruit) {
inventory.push(fruit);
console.log("Fruit added:", fruit);
}
};
}

const store = secretFruitStore();
store.addFruit("Mango");
// Output: "Fruit added: Mango"

console.log(store.getInventory());
// Output: ["Apple", "Banana", "Mango"]

// Direct access to inventory isn't possible, it's private in the closure!
  • The inventory array is private to the outer function.
  • Only the methods getInventory and addFruit can interact with it.
  • This pattern provides controlled access to data.

Promise

A Promise is a JavaScript object that acts as a placeholder for the eventual completion (or failure) of an asynchronous operation. It can be in one of three states:

  1. Pending: The initial state, neither fulfilled nor rejected.
  2. Fulfilled: The operation completed successfully, and the promise now has a resolved value.
  3. Rejected: The operation failed, and the promise has a reason for the failure.

Promises simplify handling asynchronous operations by providing .then() and .catch() methods:

  • .then() is called if the Promise is fulfilled, giving you access to the resolved value.
  • .catch() is called if the Promise is rejected, allowing you to handle errors gracefully.

Additionally, you can use .finally() to execute code regardless of whether the Promise was fulfilled or rejected.

When to Use Promises

  • Handling network requests, such as fetching data from an API.
  • Performing long-running tasks or operations that need to complete before moving on.
  • Replacing callback-based code with a more streamlined, error-handling approach.

Promises serve as one of the cornerstones of modern asynchronous programming in JavaScript, paving the way for async/await syntax and more readable asynchronous flows.

Basic Promise

const plantMango = new Promise((resolve, reject) => {
setTimeout(() => {
const successfulGrowth = true; // change to false to test rejection
if (successfulGrowth) {
resolve("A fresh Mango has grown!");
} else {
reject("Failed to grow the Mango seed.");
}
}, 1000);
});

plantMango
.then((mangoMessage) => {
console.log("Success:", mangoMessage);
})
.catch((error) => {
console.error("Failure:", error);
});
  • The promise starts in a pending state. After a delay, it either resolves with a success message or rejects with an error.
  • .then() handles the resolve case, and .catch() deals with the rejection.

Chaining Promises

function washBananas() {
return new Promise((resolve) => {
console.log("Washing Bananas...");
setTimeout(() => resolve("Bananas washed"), 500);
});
}

function peelBananas(previousMessage) {
return new Promise((resolve) => {
console.log(previousMessage);
console.log("Peeling Bananas...");
setTimeout(() => resolve("Bananas peeled"), 500);
});
}

function sliceBananas(previousMessage) {
return new Promise((resolve) => {
console.log(previousMessage);
console.log("Slicing Bananas...");
setTimeout(() => resolve("Bananas sliced"), 500);
});
}

// Chain them
washBananas()
.then(result => peelBananas(result))
.then(result => sliceBananas(result))
.then(finalMessage => {
console.log(finalMessage);
// Output: "Bananas sliced"
})
.catch(error => {
console.error("Error in banana process:", error);
});
  • Each function returns a new promise.
  • By returning a promise in .then(), you can chain multiple asynchronous actions in a sequence.
  • If any promise in the chain is rejected, the .catch() handles the error.

Promise.all

function pickStrawberries() {
return new Promise((resolve) => {
console.log("Picking Strawberries...");
setTimeout(() => resolve("Strawberries picked"), 700);
});
}

function pickBlueberries() {
return new Promise((resolve) => {
console.log("Picking Blueberries...");
setTimeout(() => resolve("Blueberries picked"), 300);
});
}

Promise.all([pickStrawberries(), pickBlueberries()])
.then((messages) => {
console.log("All fruits ready:", messages);
// Output: "All fruits ready: [ 'Strawberries picked', 'Blueberries picked' ]"
})
.catch((error) => {
console.error("Error picking fruits:", error);
});
  • Promise.all() waits for multiple promises to be fulfilled, then returns an array of all resolved values.
  • If any promise is rejected, Promise.all() immediately rejects.

Function Currying

Function Currying transforms a function that accepts multiple parameters into a sequence of functions, each taking one or fewer arguments at a time. Instead of calling someFunction(a, b, c), you call someFunction(a)(b)(c). This pattern can help create more specialized functions and make function composition easier.

Why Use Currying?

  1. Reusability: You can partially apply functions with certain arguments and reuse them for specific tasks.
  2. Clarity: Curried functions often read more like a pipeline, making logic flow easier to follow when chaining operations.
  3. Functional Programming: Currying is commonly used in functional programming paradigms for composing more complex functions from simpler ones.

Code Examples

// A regular function taking three parameters
function makeFruitPunch(apple, orange, lemon) {
return `Fruit Punch: ${apple} + ${orange} + ${lemon}`;
}

console.log(makeFruitPunch("Red Apple", "Navel Orange", "Meyer Lemon"));
// Output: "Fruit Punch: Red Apple + Navel Orange + Meyer Lemon"

// A curried version of the same function
function curryFruitPunch(apple) {
return function(orange) {
return function(lemon) {
return `Curried Fruit Punch: ${apple} + ${orange} + ${lemon}`;
};
};
}

console.log(curryFruitPunch("Green Apple")("Blood Orange")("Eureka Lemon"));
// Output: "Curried Fruit Punch: Green Apple + Blood Orange + Eureka Lemon"
  • The regular function makeFruitPunch accepts all parameters at once.
  • The curried function curryFruitPunch takes arguments one by one, returning a new function each time until it reaches the final parameter.

Execution Context

An execution context is the environment in which JavaScript code is evaluated and executed. There are two primary types of execution contexts:

  1. Global Execution Context
  • Created when your JavaScript code first runs.
  • Handles global variables and globally declared functions.
  • In the browser, the global object is window, which serves as the outermost environment.
// Global variable
let globalFruit = "Strawberry";

// Global function
function showGlobalFruit() {
console.log("Global fruit is:", globalFruit);
}

showGlobalFruit();
// Output: "Global fruit is: Strawberry"
  1. Function Execution Context
  • Created whenever a function is invoked.
  • Each function call has its own local scope, its own this binding, and a reference to its outer environment.
function fruitParfait() {
let localFruit = "Blueberry";
console.log("Inside fruitParfait, localFruit is:", localFruit);

function addIngredients() {
let addedFruit = "Banana";
console.log("Adding:", localFruit, "and", addedFruit);
}

addIngredients();
}

fruitParfait();
// Outputs:
// "Inside fruitParfait, localFruit is: Blueberry"
// "Adding: Blueberry and Banana"

Creation Phase vs. Execution Phase

  1. Creation Phase
  • The JavaScript engine scans for variable and function declarations in the current scope.
  • Memory is allocated for these variables and functions (this is where hoisting happens).
  1. Execution Phase
  • Code runs line by line.
  • Variables get assigned their initial values, and functions are actually invoked.

When a function finishes executing, its context is removed from the call stack. The engine then returns to the previously active context.

let bowl = "Main Serving Bowl";

function prepareSalad() {
let bowl = "Local Salad Bowl";
console.log("Preparing salad in:", bowl);
mixFruit();
}

function mixFruit() {
let bowl = "Mixing Bowl";
console.log("Mixing fruit in:", bowl);
}

prepareSalad();
// Outputs:
// "Preparing salad in: Local Salad Bowl"
// "Mixing fruit in: Mixing Bowl"
  • Calling prepareSalad() creates a new context.
  • Inside prepareSalad(), calling mixFruit() pushes another context onto the call stack.
  • Each function has its own local bowl variable, demonstrating how local scopes differ.

Call, Apply, and Bind

call(thisArg, ...args)

  • Invokes a function, explicitly setting this to thisArg.
  • Additional arguments are passed individually.

apply(thisArg, [argsArray])

  • Similar to call, but arguments are passed in as an array rather than individually.

bind(thisArg, ...args)

  • Returns a new function that permanently sets this to thisArg, along with optional preset arguments.
  • You can invoke that new function as many times as needed.

These methods are crucial when you need to control the value of this in your functions—particularly when borrowing methods from one object to use on another or when dealing with event handlers in certain contexts.

function showFavoriteFruit(prefix, suffix) {
console.log(`${prefix} favorite fruit is ${this.fruit}${suffix}`);
}

const person = {
fruit: "Strawberry"
};

// Using call
showFavoriteFruit.call(person, "My", "!");
// Output: "My favorite fruit is Strawberry!"


// Using apply (arguments passed as an array)
showFavoriteFruit.apply(person, ["Your", "!!!"]);
// Output: "Your favorite fruit is Strawberry!!!"


// Using bind (returns a new function)
const boundShowFruit = showFavoriteFruit.bind(person, "Arda's");
boundShowFruit("!!!");
// Output: "Arda's favorite fruit is Strawberry!!!"
  • call directly invokes the function with a custom this and any arguments listed after.
  • apply is useful when your arguments are already in an array.
  • bind doesn’t run the function immediately; it creates a new function with this permanently set, which you can call at a later time.

Polyfills for Common Array and String Methods

A polyfill is a piece of code that implements a functionality on environments (older browsers) where that functionality doesn’t exist natively. In JavaScript, you often see polyfills for newer methods such as Array.prototype.map, Array.prototype.filter, Array.prototype.reduce, and String.prototype.trim. By including these polyfills, you ensure your code can still run in environments that do not support these methods natively.

Why Use Polyfills?

  1. Browser Compatibility: Older browsers might not support modern JavaScript methods.
  2. Consistency: You can rely on certain APIs existing without writing multiple fallback checks.
  3. Backward Support: Allows modern code to run in older environments without breaking.

Array.prototype.map

if (!Array.prototype.map) {
Array.prototype.map = function(callback, thisArg) {
if (this == null) {
throw new TypeError("Map can't be called on null or undefined!");
}
if (typeof callback !== "function") {
throw new TypeError("Callback must be a function!");
}

const fruitBasket = Object(this);
const length = fruitBasket.length >>> 0;
const result = new Array(length);

for (let i = 0; i < length; i++) {
if (i in fruitBasket) {
result[i] = callback.call(thisArg, fruitBasket[i], i, fruitBasket);
}
}

return result;
};
}

// Usage Example:
const fruits = ["Apple", "Banana", "Cherry"];
const fruitLengths = fruits.map((item) => item.length);
console.log(fruitLengths);
// Possible Output: [5, 6, 6]

Array.prototype.filter

if (!Array.prototype.filter) {
Array.prototype.filter = function(callback, thisArg) {
if (this == null) {
throw new TypeError("Filter can't be called on null or undefined!");
}
if (typeof callback !== "function") {
throw new TypeError("Callback must be a function!");
}

const fruitBasket = Object(this);
const length = fruitBasket.length >>> 0;
const filteredFruits = [];

for (let i = 0; i < length; i++) {
if (i in fruitBasket) {
const fruit = fruitBasket[i];
if (callback.call(thisArg, fruit, i, fruitBasket)) {
filteredFruits.push(fruit);
}
}
}

return filteredFruits;
};
}

// Usage Example:
const allFruits = ["Apple", "Banana", "Avocado", "Blueberry"];
const fruitsThatStartWithA = allFruits.filter((item) => item.startsWith("A"));
console.log(fruitsThatStartWithA);
// Possible Output: ["Apple", "Avocado"]

Array.prototype.reduce

if (!Array.prototype.reduce) {
Array.prototype.reduce = function(callback /*, initialValue*/) {
if (this == null) {
throw new TypeError("Reduce can't be called on null or undefined!");
}
if (typeof callback !== "function") {
throw new TypeError("Callback must be a function!");
}

const fruitBasket = Object(this);
const length = fruitBasket.length >>> 0;
let index = 0;
let accumulator;

if (arguments.length >= 2) {
accumulator = arguments[1];
} else {
// Find first defined index
while (index < length && !(index in fruitBasket)) {
index++;
}
if (index >= length) {
throw new TypeError("Reduce of empty array with no initial value");
}
accumulator = fruitBasket[index++];
}

for (; index < length; index++) {
if (index in fruitBasket) {
accumulator = callback(accumulator, fruitBasket[index], index, fruitBasket);
}
}

return accumulator;
};
}

// Usage Example:
const tropicalFruits = ["Mango", "Pineapple", "Papaya"];
const totalLetters = tropicalFruits.reduce((sum, fruit) => sum + fruit.length, 0);
console.log(totalLetters);
// Possible Output: 18

String.prototype.trim

if (!String.prototype.trim) {
String.prototype.trim = function() {
// Remove leading and trailing whitespace
return this.replace(/^\s+|\s+$/g, "");
};
}

// Usage Example:
const messyFruit = " Watermelon ";
const cleanFruit = messyFruit.trim();
console.log(cleanFruit);
// Possible Output: "Watermelon"

Prototypal Inheritance

Prototypal Inheritance is a mechanism in JavaScript where objects can inherit properties and methods from a prototype object. Instead of classes, JavaScript builds relationships via the prototype chain: if an object can’t find a property or method on itself, it looks to its prototype, then the prototype’s prototype, and so on, until it finds what it needs or reaches the end of the chain.

Why Is It Useful?

  1. Reusability: Common properties and methods can be placed on a single prototype object, reducing memory usage and duplication.
  2. Dynamic Extensibility: You can add properties or methods to the prototype after objects have been created, and they gain these new capabilities.
  3. Flexibility: It differs from class-based inheritance, offering a more dynamic way of defining how objects relate to each other.
// Constructor function: Fruit
function Fruit(name, color) {
this.name = name;
this.color = color;
}

// Add a method to Fruit's prototype
Fruit.prototype.describe = function() {
return `This is a ${this.color} ${this.name}.`;
};

// Create instances (children)
const apple = new Fruit("Apple", "Red");
const mango = new Fruit("Mango", "Golden");

// Access the describe method from the prototype
console.log(apple.describe());
// Output: "This is a Red Apple."

console.log(mango.describe());
// Output: "This is a Golden Mango."
  • Fruit is a constructor function used to create new objects.
  • A method describe is added to Fruit.prototype.
  • Instances apple and mango automatically inherit the describe method via the prototype chain.

In short,

Hoisting: JavaScript moves variable and function declarations to the top of their scope.
Closure: An inner function retains access to its outer function’s variables even after the outer function returns.
Promise: Represents an eventual success or failure of an asynchronous operation.
Function Currying: Converts a function taking multiple parameters into nested functions, each handling one parameter.
Execution Context: The environment in which JavaScript code is evaluated and executed.
Call, Apply, and Bind: Methods for explicitly setting the this context of a function and passing arguments.
Polyfills: Fallback implementations of JavaScript features for older environments lacking native support.
Prototypal Inheritance: Objects can inherit properties and methods from another object via a prototype chain.

That was all about JavaScript Fundamentals.

As I come across different and interesting topics about web development, I will keep continuing to come up with different stories.

--

--