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.
| Form | Hoisted? | Has own this/arguments? | Notes |
|---|---|---|---|
Declaration function f() {} | yes (name + body) | yes | callable before its line |
Expression const f = function () {} | no (TDZ) | yes | named form can self-recurse |
Arrow const f = () => {} | no (TDZ) | no — inherits both | no prototype, not new-able |
new Function('a','b','return a+b') | n/a | yes | parses 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