JavaScript Basics: Functions and Scope

JavaScript Basics: Functions and Scope

JavaScript Basics: Functions and Scope

Functions and scope are, in my opinion, the two concepts that determine whether JavaScript “clicks” for a beginner or keeps feeling confusing. I remember being able to write functions long before I actually understood scope — and that gap caused me all kinds of bugs around variables that seemed to “disappear” or values that mysteriously changed. In this article, I want to build a genuinely solid foundation in both, from the basics all the way to the internal mechanics of closures and the scope chain.

What Is a Function?

A function is a reusable block of code designed to perform a specific task. I define it once and can call (invoke) it as many times as I need, optionally passing in different inputs (parameters) and getting back an output (return value).

Function Declarations

function greet(name) {
  return `Hello, ${name}!`;
}

console.log(greet("Alice")); // Hello, Alice!

Function Expressions

const greetExpr = function (name) {
  return `Hello, ${name}!`;
};

console.log(greetExpr("Bob")); // Hello, Bob!

Arrow Functions

const greetArrow = (name) => `Hello, ${name}!`;
console.log(greetArrow("Carol")); // Hello, Carol!

Function Declarations Are Hoisted; Expressions Are Not

console.log(hoisted()); // "I work!" — works because of hoisting

function hoisted() {
  return "I work!";
}

console.log(notHoisted()); // TypeError: notHoisted is not a function

var notHoisted = function () {
  return "I don't work yet";
};

I always define functions before I use them regardless of hoisting rules — relying on hoisting makes code harder to follow, even when it technically works.

Parameters, Arguments, and Defaults

function multiply(a, b = 1) {
  return a * b;
}

console.log(multiply(5));    // 5 — b defaults to 1
console.log(multiply(5, 3)); // 15

Rest Parameters

function sumAll(...numbers) {
  return numbers.reduce((total, n) => total + n, 0);
}

console.log(sumAll(1, 2, 3, 4)); // 10

Passing Objects for Named Arguments

function createUser({ name, age = 18, role = "member" } = {}) {
  return `${name}, ${age}, ${role}`;
}

console.log(createUser({ name: "Alice", age: 25 })); // Alice, 25, member

I use this pattern for any function that takes more than two or three parameters — it makes call sites self-documenting, since I see name:, age: explicitly rather than guessing what a bare 25 means.

Return Values

function add(a, b) {
  return a + b;
  console.log("This never runs"); // unreachable code after return
}

A function without an explicit return statement returns undefined by default.

function noReturn() {
  console.log("Doing something");
}
console.log(noReturn()); // "Doing something", then undefined

What Is Scope?

Scope determines where a variable is accessible in my code. JavaScript has a few different kinds of scope, and understanding each one precisely eliminated an entire category of bugs for me.

Global Scope

const appName = "MyApp"; // accessible everywhere in this file/module

function showName() {
  console.log(appName); // accessible here too
}

I try to minimize global variables — they can be accidentally overwritten by any part of the codebase, making bugs hard to trace.

Function Scope

Variables declared with var are scoped to the nearest enclosing function, not to blocks like if or for.

function example() {
  if (true) {
    var x = 10;
  }
  console.log(x); // 10 — accessible outside the if block, because var is function-scoped
}

Block Scope

Variables declared with let and const are scoped to the nearest enclosing block ({}), which includes if statements, loops, and any standalone {}.

function example2() {
  if (true) {
    let y = 20;
    const z = 30;
  }
  console.log(y); // ReferenceError: y is not defined
}

This is one of the main reasons I use let/const almost exclusively now instead of var — block scoping matches how most developers intuitively expect variables to behave.

The Scope Chain

When JavaScript looks up a variable, it checks the current scope first, then walks outward through each enclosing scope until it finds the variable or reaches the global scope (at which point, if still not found, it throws a ReferenceError).

const outer = "I'm outer";

function level1() {
  const middle = "I'm middle";

  function level2() {
    const inner = "I'm inner";
    console.log(inner);  // found in level2's own scope
    console.log(middle); // found in level1's scope (enclosing)
    console.log(outer);  // found in global scope (further enclosing)
  }

  level2();
}

level1();

This nested lookup structure is called the scope chain, and it’s determined entirely by where functions are written in the code — not by how or where they’re called. This is known as lexical scoping.

Closures

This is the concept that took me the longest to genuinely understand, and it’s directly built on the scope chain I just described.

A closure is formed when a function “remembers” the variables from its enclosing scope, even after that outer function has finished executing.

function makeCounter() {
  let count = 0;

  return function () {
    count++;
    return count;
  };
}

const counter = makeCounter();
console.log(counter()); // 1
console.log(counter()); // 2
console.log(counter()); // 3

Even though makeCounter() has already returned, the inner function still has access to count — it “closes over” that variable. Each call to makeCounter() creates a completely independent closure with its own separate count.

const counterA = makeCounter();
const counterB = makeCounter();

console.log(counterA()); // 1
console.log(counterA()); // 2
console.log(counterB()); // 1 — independent from counterA

Practical Use of Closures: Private State

function createBankAccount(initialBalance) {
  let balance = initialBalance;

  return {
    deposit(amount) {
      balance += amount;
      return balance;
    },
    withdraw(amount) {
      if (amount > balance) throw new Error("Insufficient funds");
      balance -= amount;
      return balance;
    },
    getBalance() {
      return balance;
    },
  };
}

const account = createBankAccount(100);
console.log(account.deposit(50));  // 150
console.log(account.withdraw(30)); // 120
console.log(account.balance);      // undefined — genuinely private, no direct access

Before #private class fields existed, closures were the way to achieve real data privacy in JavaScript, and I still use this pattern frequently outside of class-based code.

The Classic Closure Loop Gotcha

for (var i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 100);
}
// Logs: 3, 3, 3 — NOT 0, 1, 2!

Because var is function-scoped (not block-scoped), all three closures share the same i variable, and by the time the timeouts run, the loop has already finished and i is 3.

for (let i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 100);
}
// Logs: 0, 1, 2 — correct!

Using let creates a new binding of i for each iteration, so each closure captures its own separate copy. This single example is, in my experience, the fastest way to genuinely understand the practical difference between var and let.

The this Keyword and Scope

this is determined by how a function is called, not where it’s defined (except for arrow functions, which inherit this lexically from their surrounding scope, as covered in more detail in the arrow functions article).

const obj = {
  name: "Alice",
  regularMethod() {
    console.log(this.name); // "Alice" — this refers to obj, since obj.regularMethod() was the call
  },
  arrowMethod: () => {
    console.log(this.name); // undefined — this is inherited from the outer (module/global) scope
  },
};

obj.regularMethod();
obj.arrowMethod();

Internal Working: The Execution Context and Lexical Environment

Every time a function is called, JavaScript creates a new execution context, which includes a Lexical Environment — an internal data structure holding all the variables declared in that function, plus a reference to the parent lexical environment (the outer scope where the function was defined). This parent reference is exactly what forms the scope chain, and it’s also precisely what makes closures possible: as long as any reference to the inner function exists, the JavaScript engine cannot garbage-collect the outer lexical environment it depends on, even after the outer function has returned.

This is why closures, if overused carelessly (for example, capturing large objects in long-lived closures like event listeners that are never removed), can lead to memory that isn’t released as soon as I might expect — the referenced outer variables stay alive in memory for as long as the closure itself is reachable.

Practical, Real-World Applications

Memoization Using Closures

function memoize(fn) {
  const cache = new Map();
  return function (arg) {
    if (cache.has(arg)) return cache.get(arg);
    const result = fn(arg);
    cache.set(arg, result);
    return result;
  };
}

const slowSquare = (n) => { for (let i = 0; i < 1e6; i++); return n * n; };
const fastSquare = memoize(slowSquare);

console.log(fastSquare(5)); // computed
console.log(fastSquare(5)); // returned instantly from cache

Module Pattern (Pre-ES-Modules Encapsulation)

const CounterModule = (function () {
  let count = 0;
  return {
    increment: () => ++count,
    reset: () => (count = 0),
  };
})();

console.log(CounterModule.increment()); // 1
console.log(CounterModule.increment()); // 2

Best Practices

Common Mistakes to Avoid

  1. Using var inside loops with asynchronous callbacks, expecting each iteration to capture its own value.
  2. Relying on function hoisting instead of defining functions before use, which hurts readability.
  3. Creating unnecessary closures over large objects in long-lived contexts, causing avoidable memory retention.
  4. Confusing this behavior in arrow functions versus regular functions within object methods.

Debugging Tips

FAQs

Q: What’s the difference between a parameter and an argument? A: A parameter is the named variable listed in the function definition; an argument is the actual value passed in when the function is called.

Q: Can a function be both a value and callable? A: Yes — functions are first-class objects in JavaScript, meaning they can be assigned to variables, passed as arguments, and returned from other functions, all while remaining callable.

Q: Do closures cause memory leaks? A: Not inherently, but they can extend the lifetime of variables in memory for as long as the closure itself is reachable — which becomes a genuine concern if closures are attached to long-lived objects like uncanceled event listeners or global caches.

Q: Is block scope the same as function scope? A: No — block scope (let/const) is limited to the nearest {}, including if and loop bodies, while function scope (var) extends to the entire enclosing function regardless of nested blocks.

Summary and Key Takeaways

Once I truly understood scope and closures at this level, an entire category of “why is this variable undefined” and “why does my loop print the wrong numbers” bugs simply stopped happening — because I finally understood exactly what the engine was doing under the syntax.

References

Exit mobile version