Functions: Declarations, Expressions, and Arrows
This chapter assumes you can already read JavaScript fluently: you know how objects and property lookup work, and you have at least seen this and closures. If so, the interesting question is not "what is a function?" but "which of the three syntactic forms should I use, and what does each one actually do at runtime?"
The three forms and what differs
JavaScript gives you three ways to create a function. They all produce a callable object, but they differ on four axes: hoisting, this binding, the internal [[Constructor]] slot, and identity under a name property.
// 1. Function declaration
function declared() { return 'declared'; }
// 2. Function expression (named and anonymous)
const expr = function named() { return 'expr'; };
// 3. Arrow function
const arrow = () => 'arrow';
console.log(declared.name, expr.name, arrow.name); // declared named arrowA function declaration is fully hoisted: the binding exists and the body is available before the declaration line executes. A const-assigned expression is not — the binding hoists (in temporal-dead-zone fashion for let/const) but the value does not. This is the practical reason declarations dominate at module top level: it makes the file order-independent.
console.log(early()); // works: declaration fully initialized
function early() { return 'ok'; }The four axes compared
| Property | Declaration | Expression | Arrow |
|---|---|---|---|
| Hoisting | Fully hoisted | Binding only | Binding only |
| Own `this` | Yes (call-site) | Yes (call-site) | No (lexical) |
| Constructible with `new` | Yes | Yes | No |
| `arguments` object | Yes | Yes | No |
Arrows are not "shorter functions." They are a different kind of callable. Internally, an arrow has no [[Environment]]-independent this slot: its this is resolved as a normal variable lookup into its enclosing scope, which is exactly why .call() and .bind() cannot rebind it — there is nothing to bind.
const obj = {
name: 'obj',
regular() { return this.name; },
arrow: () => this, // module/global scope's this
};
console.log(obj.regular()); // 'obj'
console.log(obj.arrow()); // not obj — this comes from the defining scopeArrows: when lexical `this` is the point
The arrow function exists to close over the surrounding this. The classic pattern is registering a callback on an object or class instance:
class Timer {
constructor() { this.ticks = 0; }
start() {
// Arrow inherits `this` from start(), which was called on the instance
setInterval(() => { this.ticks += 1; }, 1000);
}
}The gotcha is symmetric: an arrow's this is fixed at definition. If you want per-call binding — event handlers, prototype methods, polymorphic functions — an arrow is wrong, not merely shorter. A method defined as an arrow class field (e.g. handler = () => {...}) permanently pins this to the instance, which is sometimes exactly what you want for callbacks but breaks subclass overrides that expect to be called with this of the subclass.
The rest of the arrow contract
Arrows also lack their own arguments binding, super, and new.target. They cannot be used as generator functions (=>* is a syntax error). Using new on one throws a TypeError immediately, not at call time via some fallback — the engine knows at parse time that [[Construct]] is absent.
One subtlety: named function expressions give you a self-reference that does not pollute the enclosing scope, and it survives even if the outer binding is reassigned:
const f = function self(n) {
return n <= 1 ? 1 : n * self(n - 1);
};
const g = f;
f = null;
console.log(g(5)); // 120 — `self` still refers to the functionAn arrow's implicit return is fine for expressions but has a well-known trap: returning an object literal requires parentheses, because { is parsed as a block body:
const bad = (key) => { key: 42 }; // block with a labeled statement; returns undefined
const good = (key) => ({ [key]: 42 }); // object literal
console.log(good('a')); // { a: 42 }IIFEs and hoisting decisions
The immediately-invoked function expression — (function () { ... })() — was the pre-module mechanism for private scope. Today it survives mostly as the top-level wrapper for async work in scripts that cannot use top-level await, and inside async arrow variants: (async () => { ... })(). Note that an async IIFE's rejection becomes an unhandled promise rejection; attach .catch() if failure is possible.
When choosing between declaration and expression at the top level, the trade-off is: declarations hoist (order-independent, slightly easier reading flow) but can be reassigned implicitly and their hoisting can mask bugs where you call something before its dependencies ran. Expressions under const fail loudly and early. Style guides diverge here; the decision criterion is whether you prefer fail-fast ordering or maximum flexibility.
The dispatcher model
All forms route through the same call machinery; the differences are which slots the engine populates:
Show diagram source
flowchart TD
A[Function call] --> B{Form?}
B -->|Declaration / Expression| C[Bind this from call site]
B -->|Arrow| D[Inherit this from enclosing scope]
C --> E[Bind arguments, new.target]
D --> F[No arguments binding<br/>No construct slot]
E --> G[Execute body]
F --> G
B -->|new on arrow| H[TypeError]Exercise
Why does (0, obj.method)() call the method with this === undefined even though obj.method is a regular function?
Show answer
The comma operator makes the expression evaluate to the function value itself, detached from obj; the subsequent call has no receiver, so this defaults to undefined (in strict mode) — the same mechanism behind the classic const m = obj.method; m() bug. Arrows sidestep it by never using call-site this.
Exercise
You need a factory that returns a constructor-like callable usable with new. Which form must you use, and why can't an arrow work?
Show answer
A function declaration or expression. Arrows lack the internal [[Construct]] method (no new.target, no this binding machinery for construction), so new arrowFn() throws a TypeError at the call site.
Spotted a mistake, or code that does not work?