Skip to content

Functions, Scope, and Closures

JavaScript functions are not only named units of behavior. They are ordinary values that can be stored, passed, returned, and created dynamically. That fact shapes the entire ecosystem: event handlers, promise chains, middleware, component definitions, array transformations, test fixtures, and configuration APIs all rely on functions moving through a program as data.

Three common ways to create a function

A function declaration gives a name to a function in the surrounding scope:

js
function formatAmount(amount) {
  return `$${amount.toFixed(2)}`;
}

A function expression creates a function as part of an expression:

js
const formatAmount = function (amount) {
  return `$${amount.toFixed(2)}`;
};

An arrow function is a compact function expression with different this behavior:

js
const formatAmount = amount => `$${amount.toFixed(2)}`;

These forms overlap, but they are not interchangeable in every context.

Declarations are available throughout their containing scope, even before the textual declaration:

js
console.log(formatAmount(12));

function formatAmount(amount) {
  return `$${amount.toFixed(2)}`;
}

A const binding for a function expression is not initialized until execution reaches the declaration:

js
console.log(formatAmount(12)); // ReferenceError

const formatAmount = amount => `$${amount.toFixed(2)}`;

Use declarations for ordinary named module-level or local functions when that reads naturally. Use arrow functions frequently for callbacks and small closures. Do not choose solely by character count.

Calls are permissive at runtime

JavaScript does not enforce a declared parameter count:

js
function describe(name, role) {
  return `${name}: ${role}`;
}

describe("Avery", "admin", "ignored"); // "Avery: admin"
describe("Avery"); // "Avery: undefined"

Extra arguments are still passed; named parameters simply do not bind them. Missing parameters receive undefined.

Default parameters activate for missing or explicit undefined values:

js
function greet(name = "Guest") {
  return `Hello, ${name}`;
}

greet(); // "Hello, Guest"
greet(undefined); // "Hello, Guest"
greet(null); // "Hello, null"

Rest parameters collect remaining arguments into a real array:

js
function sum(...values) {
  return values.reduce((total, value) => total + value, 0);
}

sum(2, 3, 5); // 10

Ordinary functions also expose an older array-like arguments object. Rest parameters are clearer when you control the function. Arrow functions do not create their own arguments; a reference resolves to an enclosing ordinary function's binding, if one exists.

Functions are values

Assigning a function does not call it:

js
const formatter = formatAmount;
const text = formatter(12.5);

Pass a function to another function:

js
function printReport(report, format) {
  console.log(format(report));
}

printReport(report, formatAsText);
printReport(report, formatAsJson);

The receiving function does not need to know which implementation it received. This is dependency injection in a small, idiomatic JavaScript form.

Array methods use the same idea:

js
const amounts = transactions.map(transaction => transaction.amount);

map calls the callback with the current value, index, and array. The callback may declare only the parameters it needs.

Be careful when passing an existing function whose signature accidentally interprets those additional arguments:

js
["10", "10", "10"].map(Number.parseInt);
// [10, NaN, 2]

map passes the index as the second argument, and parseInt interprets its second argument as the radix. Adapt the signature explicitly:

js
["10", "10", "10"].map(text => Number.parseInt(text, 10));
// [10, 10, 10]

Higher-order functions configure behavior

A higher-order function accepts or returns a function. This can produce focused abstractions without creating a class hierarchy.

js
function createRangeValidator(minimum, maximum) {
  return value =>
    typeof value === "number" && value >= minimum && value <= maximum;
}

const isPercentage = createRangeValidator(0, 100);
const isRetryCount = createRangeValidator(0, 5);

isPercentage(80); // true
isRetryCount(80); // false

Each returned function remembers the minimum and maximum values from the call that created it. That memory is a closure.

Higher-order utilities are most useful when they name a recurring behavior. A generic abstraction with many callbacks can become harder to understand than direct code. Prefer a concrete helper until repetition reveals a stable relationship.

Lexical scope follows source structure

JavaScript uses lexical scope: a function can refer to names from the source scopes surrounding its definition.

js
const currency = "USD";

function formatAmount(amount) {
  const formatter = new Intl.NumberFormat("en-US", {
    style: "currency",
    currency,
  });

  return formatter.format(amount);
}

formatAmount can see currency because the function was defined in that module scope. Code that calls formatAmount does not determine which currency binding it uses.

Blocks create scope for let and const:

js
if (enabled) {
  const message = "Feature enabled";
  console.log(message);
}

// message is not defined here

Functions create another scope:

js
function load() {
  const token = readToken();

  function request() {
    return fetchData(token);
  }

  return request();
}

The inner function can see its own bindings, then the outer function's, then module and global scopes. An inner declaration with the same name shadows an outer one.

Closures retain access to bindings

A closure is a function together with access to its lexical environment. It remains useful after the creating call returns:

js
function createCounter() {
  let count = 0;

  return function next() {
    count += 1;
    return count;
  };
}

const firstCounter = createCounter();
const secondCounter = createCounter();

firstCounter(); // 1
firstCounter(); // 2
secondCounter(); // 1

Each call to createCounter creates a new count binding. The returned function retains access to that particular binding.

Closures capture bindings, not frozen snapshots:

js
function createStatus() {
  let status = "idle";

  return {
    getStatus: () => status,
    setStatus: next => {
      status = next;
    },
  };
}

const state = createStatus();
state.getStatus(); // "idle"
state.setStatus("running");
state.getStatus(); // "running"

Both methods close over the same live binding.

This creates privacy by reachability. Callers cannot access status directly, but exported operations can read and update it.

Module scope is already an encapsulation boundary

An ECMAScript module has its own top-level scope. Unexported names are private to that module:

js
// identifiers.js
let nextId = 1;

export function createId() {
  const id = nextId;
  nextId += 1;
  return `record-${id}`;
}

Importers can call createId but cannot name nextId. The exported function closes over module state.

Module state is shared by importers of the same module instance, which can be exactly what a registry needs or an undesirable hidden global. Encapsulation does not automatically make shared mutable state easy to test. A factory often provides independent instances:

js
export function createIdGenerator(prefix = "record") {
  let nextId = 1;

  return function createId() {
    const id = nextId;
    nextId += 1;
    return `${prefix}-${id}`;
  };
}

Now tests and consumers can create isolated generators.

Closures power event subscriptions

An unsubscribe function is a common closure pattern:

js
function createEmitter() {
  const listeners = new Set();

  return {
    subscribe(listener) {
      listeners.add(listener);

      return function unsubscribe() {
        listeners.delete(listener);
      };
    },

    emit(value) {
      for (const listener of listeners) {
        listener(value);
      }
    },
  };
}

The returned unsubscribe function remembers both the private listeners set and the particular listener argument. No token or listener ID is required.

This basic design raises real policy questions:

  • What happens if a listener removes itself during emission?
  • Can the same function subscribe twice?
  • Does one listener's exception stop later listeners?
  • Are new listeners added during emission called immediately?

The closure is simple; a production-ready contract still requires deliberate semantics.

var creates the classic loop-closure surprise

var is function-scoped, so one binding is shared across loop iterations:

js
const callbacks = [];

for (var index = 0; index < 3; index += 1) {
  callbacks.push(() => index);
}

callbacks.map(callback => callback()); // [3, 3, 3]

Each closure reads the same index after the loop finishes.

let creates a per-iteration binding for this loop form:

js
const callbacks = [];

for (let index = 0; index < 3; index += 1) {
  callbacks.push(() => index);
}

callbacks.map(callback => callback()); // [0, 1, 2]

This is one reason modern code avoids var. The broader lesson is still that closures observe bindings, so later mutation can affect their results.

Hoisting is declaration setup, not source-code movement

People often say declarations are “hoisted.” No source text moves. Before execution, the runtime creates bindings according to the declaration kind.

Function declarations are initialized with their functions during scope setup, so early calls work.

var bindings are initialized to undefined:

js
console.log(status); // undefined
var status = "ready";

let, const, and class bindings exist from the start of the scope but cannot be accessed before their declaration is evaluated. This interval is the temporal dead zone:

js
console.log(status); // ReferenceError
const status = "ready";

Write declarations before ordinary use even where the language permits otherwise. Function declarations are commonly placed according to the file's reading flow; variables should not rely on var initialization behavior.

Arrow functions have concise bodies and lexical this

An expression body returns implicitly:

js
const double = value => value * 2;

A block body needs an explicit return:

js
const double = value => {
  return value * 2;
};

Returning an object literal from an expression body requires parentheses so braces are not parsed as a function body:

js
const createRecord = id => ({ id, active: true });

Arrow functions do not have their own this, arguments, or constructor behavior. That makes them excellent callbacks when the callback should use the surrounding context. It makes them inappropriate when invocation should supply a receiver or when the function should be called with new.

The next article develops this in detail. For now, do not rewrite every function declaration as an arrow merely because arrows are newer.

Asynchronous callbacks can retain stale assumptions

Closures remain live across time:

js
function scheduleStatusLog(state) {
  setTimeout(() => {
    console.log(state.status);
  }, 1000);
}

The callback holds a reference to state. If another part of the program mutates state.status before the timer fires, the callback sees the new value.

If the intended behavior is to record the status at scheduling time, capture that value in a separate binding:

js
function scheduleStatusLog(state) {
  const scheduledStatus = state.status;

  setTimeout(() => {
    console.log(scheduledStatus);
  }, 1000);
}

Neither behavior is universally correct. The closure model lets you choose deliberately.

Closures have a lifetime cost

A value remains reachable while a live closure can access it. This is usually desirable and automatically managed. It can retain large objects longer than intended:

js
function createLookup(largeDataset) {
  return id => largeDataset.find(record => record.id === id);
}

As long as the returned lookup function is reachable, so is largeDataset. Event listeners can similarly keep component or DOM state alive if they are never removed. The right response is not to avoid closures—it is to make subscription lifetimes and cleanup explicit.

Functions and closures often eliminate the need for a class, but classes remain useful when identity, many related operations, construction rules, or framework conventions make an object-oriented API clearer. To choose well, you need JavaScript's actual object model and the invocation rules behind this. That is the subject of the next article.

Further reference