Skip to main content

Functions

Ways to define a function

Forms differ in three things: hoisting, whether they have their own this/arguments, and whether they're new-able.

FormHoisted?Has own this/arguments?Notes
Declaration function f() {}yes (name + body)yescallable before its line
Expression const f = function () {}no (TDZ)yesnamed form can self-recurse
Arrow const f = () => {}no (TDZ)no — inherits bothno prototype, not new-able
new Function('a','b','return a+b')n/ayesparses a string, runs in global scope only

Declaration — hoisted (name + body), so callable before its line.

greet("Ada"); // ✓ hoisted
function greet(name) {
return `Hi ${name}`;
}

Expression — function as a value; TDZ, so not callable before its line. Named form keeps its name private to the body (useful for recursion).

const factorial = function fac(n) {
return n < 2 ? 1 : n * fac(n - 1);
};

Arrow — no own this, arguments, super, new.target; inherits them lexically. No prototype, not new-able.

const square = (n) => n * n; // implicit return
const point = (x, y) => ({ x, y }); // () to return an object literal

Function constructor — built from strings; only sees global scope, never the lexical scope it was created in. eval-adjacent.

const add = new Function("a", "b", "return a + b");
add(2, 3); // 5

Related forms — methods ({ f() {} }), generators (function*), async (async function / async () =>) — follow the same hoisting/this rules as their plain counterparts.

Advanced Patterns & Edge Cases

Declarations hoist, expressions don't

A declaration is fully hoisted (callable above its definition). An expression/arrow assigned to let/const sits in the temporal dead zone until its line runs. See hoisting.

square(5); // ✓ 25 — declaration hoisted

function square(n) {
return n * n;
}

cube(5); // ✗ ReferenceError — const in TDZ
const cube = (n) => n ** 3;

Pass-by-value — but the "value" of an object is its reference

Primitives copy. Objects copy the reference, so mutating properties leaks out, but reassigning the parameter does not.

function mutate(obj) {
obj.make = "Toyota"; // ✓ affects caller — same object
obj = { make: "Tesla" }; // ✗ rebinds local only — caller unaffected
}

const car = { make: "Honda" };
mutate(car);
console.log(car.make); // "Toyota"

Named function expressions: the name is private to the body

A named function expression exposes its name only inside its own body (handy for recursion without depending on the outer binding). The name is not visible in the enclosing scope.

const factorial = function fac(n) {
return n < 2 ? 1 : n * fac(n - 1); // `fac` only usable in here
};

console.log(factorial(5)); // 120
console.log(typeof fac); // "undefined" — not leaked outside

Arrow functions have no own this or arguments

They lexically inherit both from the enclosing scope — the reason they fix the classic setInterval/callback this bug, and the reason they can't be used as methods that need their own this. Use rest params instead of arguments. See this-keyword.

function Timer() {
this.age = 0;
setInterval(() => this.age++, 1000); // ✓ `this` = the Timer instance
}

const obj = {
vals: [1, 2, 3],
bad: () => this.vals, // ✗ `this` is NOT obj (it's the outer scope)
};

const sum = (...nums) => nums.reduce((a, b) => a + b, 0); // ✓ rest, not arguments

Arrows also have no prototype and can't be called with new (TypeError).

arguments is array-like, not an array

It has length and indices but none of the array methods, and it does not include rest/default params cleanly. Prefer rest parameters.

function legacy() {
// arguments.map(...) ✗ TypeError
return Array.from(arguments).map((x) => x * 2); // convert first
}

function modern(...args) {
return args.map((x) => x * 2); // ✓ a real array
}

Default parameters are evaluated at call time, left to right

Defaults are lazy (re-evaluated on every call) and can reference earlier parameters. undefined triggers the default; null does not.

function make(id, label = `item-${id}`, ts = Date.now()) {
return { id, label, ts };
}

make(7); // label "item-7", ts = now
make(7, undefined); // default kicks in
make(7, null); // label is null — null does NOT trigger default

Closures capture variables, not values

A closure holds a live reference to the variable. This is what makes private state and factory functions work — and also the classic var-in-a-loop bug (use let for a per-iteration binding).

function counter() {
let count = 0; // private — only reachable via the returned fns
return {
inc: () => ++count,
get: () => count,
};
}

const c = counter();
c.inc();
c.inc();
console.log(c.get()); // 2

See scope-closures for the scope-chain and loop-capture details.

IIFE — private scope, executed once

Runs immediately and discards its scope, exposing only what it returns. Largely superseded by modules and block scoping, but still seen for one-shot init and encapsulating privates.

const api = (function () {
const secret = "0]Eal(eh&2"; // not reachable from outside
return { reveal: () => secret };
})();

console.log(api.reveal()); // "0]Eal(eh&2"

new Function ignores lexical scope

Unlike a closure, the body of new Function(...) only sees global variables, never the scope where it was created. It's eval-adjacent — avoid unless you genuinely need runtime code generation.

const x = 10;
const f = new Function("return typeof x === 'undefined'");
console.log(f()); // true — local x is invisible to it