Skip to content

04 · Functions & Scope

Function declarations

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

function greet(name = "friend") { // default parameter
  return `Hello, ${name}!`;
}

console.log(add(2, 3));   // 5
console.log(greet());      // Hello, friend!
console.log(greet("Ada")); // Hello, Ada!

Function expressions and arrow functions

// Function expression: an unnamed function assigned to a variable
const multiply = function (a, b) {
  return a * b;
};

// Arrow function: shorter syntax, does NOT have its own `this`
const subtract = (a, b) => a - b;

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

// No parameters: empty parens required
const sayHi = () => "hi";

console.log(multiply(3, 4)); // 12
console.log(subtract(10, 4)); // 6
console.log(square(5));       // 25

Rest parameters and the arguments object

function total(...numbers) { // rest parameter — gathers args into a real array
  return numbers.reduce((sum, n) => sum + n, 0);
}

function makeProfile(name, ...rest) {
  return { name, extra: rest };
}

console.log(total(1, 2, 3, 4));                 // 10
console.log(makeProfile("Ada", "London", 30));
// { name: 'Ada', extra: [ 'London', 30 ] }

Hoisting

console.log(hoisted()); // works — function declarations are fully hoisted
function hoisted() {
  return "I run before my own line in the file";
}

console.log(typeof notYetDefined); // "undefined" — var is hoisted, but not its value
var notYetDefined = 5;

// console.log(letVar); // ReferenceError — let/const are hoisted but not initialized
let letVar = 10;

Function declarations are hoisted with their full body; var is hoisted but only the declaration (not the assignment); let/const are hoisted into a "temporal dead zone" where referencing them before their declaration throws.

Scope: block vs. function

function demo() {
  if (true) {
    let blockScoped = "only visible inside this block";
    var functionScoped = "visible throughout demo()";
  }
  // console.log(blockScoped);    // ReferenceError
  console.log(functionScoped);    // works — var ignores block boundaries
}

demo();
let x = "global";

function outer() {
  let x = "enclosing";

  function inner() {
    let x = "local";
    console.log(x); // local
  }

  inner();
  console.log(x); // enclosing
}

outer();
console.log(x); // global

Each nested function can read variables from its enclosing scopes (this is the basis for closures, covered in depth in Level 2 · Module 1).

Functions are values

function squareFn(x) {
  return x * x;
}

const operations = { square: squareFn };
console.log(operations.square(5)); // 25

// Higher-order function: takes a function as an argument
function applyTwice(fn, value) {
  return fn(fn(value));
}

console.log(applyTwice(squareFn, 3)); // 81

How It Actually Works

Every time a function is called, V8 pushes a new stack frame holding its local variables, arguments, and a reference to where execution should resume when it returns. But scope resolution — how x inside a nested function finds the right x — doesn't walk the call stack at all. It walks a separate structure built at parse time called the scope chain: each function literal captures a pointer to the lexical environment it was defined in, not the one it's called from. That's why a function defined at the top level and called deep inside another function still only sees top-level variables, never the caller's locals — scope is about where code is written, not where it's invoked (this is "lexical scoping").

Function hoisting differs sharply by declaration form. A function foo() {} declaration is fully hoisted — both the name and the function body are available before the line runs, because V8's parser does a pre-pass that registers named function declarations in the enclosing scope before executing any statements. A function expression (const foo = function() {}) only hoists the const binding (in the TDZ, unusable), not the assignment — so calling it before the line throws. arguments is its own quirk: for non-arrow functions, V8 lazily materializes an arguments object backed by the actual argument values on the stack; arrow functions have no arguments binding of their own at all, so referencing arguments inside one walks the scope chain up to the nearest enclosing non-arrow function.

🔀 See this in another language

Exercise

Write a function summarize(...values) that returns an object with the min, max, and average of values, rounding the average to 2 decimal places with .toFixed(2).