How it works
Every form goes through OrdinaryFunctionCreate, which records the current Environment Record in [[Environment]] (see the closures chapter), the parameter list, the body, and a [[ThisMode]]: lexical for arrows, non-lexical (strict or sloppy) for everything else. The differences come from what happens next.
InstantiateOrdinaryFunctionObject (declarations) runs while the scope is being set up: for a function body, step "for each funcDecl of funcsToInitialize" in FunctionDeclarationInstantiation; for modules and scripts, the equivalent declaration-instantiation step. The binding is initialized with the finished function before the first statement executes. If a name is declared twice, the last declaration wins.
InstantiateOrdinaryFunctionExpression runs when evaluation reaches the expression. It calls OrdinaryFunctionCreate, SetFunctionName, and then MakeConstructor, so a function expression is a constructor with a fresh prototype object.
InstantiateArrowFunctionExpression calls OrdinaryFunctionCreate with lexical-this and SetFunctionName, and stops. No MakeConstructor, so no [[Construct]] and no prototype. When the arrow runs, this, arguments, super, and new.target aren't found in its own Function Environment Record and resolve outward.
- Method definitions (
DefineMethod) create a non-lexical function and record a [[HomeObject]] so super works. They also skip MakeConstructor.
Edge cases
Constructor status is not the same as having a prototype:
const kinds = {
method() {},
fn: function () {},
arrow: () => {},
async af() {},
*gen() {},
};
for (const [name, f] of Object.entries(kinds)) {
let canNew = true;
try { new f(); } catch { canNew = false; }
console.log(name, canNew, 'prototype' in f);
}
// → method false false
// → fn true true
// → arrow false false
// → af false false
// → gen false true
A generator has a prototype (the prototype of its generator objects) but no [[Construct]].
Name binding scope differs too. A declaration puts its name in the enclosing scope. A named function expression puts its name in a private scope visible only inside the function:
const countdown = function tick(n) {
return n === 0 ? 'done' : tick(n - 1);
};
console.log(countdown(2), typeof tick); // → done undefined
Function declarations inside blocks are scoped to the block in strict code and modules. Legacy: in sloppy mode, Annex B also creates a function-level var that is assigned when the declaration is evaluated, so the name leaks out of the block; its exact behaviour varies in corners, and strict code avoids all of it.
Duplicate parameter names are legal only in sloppy functions with a simple parameter list, and function dup(a, a) is a SyntaxError in strict code, in arrows, and in methods.
Real-world scenario
Callbacks are where the choice shows up. A prototype method passed bare loses its receiver (see the this chapter), while an arrow written inline keeps it:
class Poller {
interval = 50;
start() {
// Arrow: `this` is the Poller, taken from start()'s environment.
setTimeout(() => console.log(this.interval), 0);
// A plain `function () {}` here would get its own `this` at call time.
}
}
new Poller().start(); // → 50
The reverse: event-handler or method code that wants the caller-supplied this (element.addEventListener('click', function () { this… }), prototype methods, DOM libraries) must not be an arrow.
Trade-offs
Arrows are shorter and remove a class of this bugs, but they can't be constructed, can't see their own call's arguments, and show up as anonymous in stack traces unless assigned to a name. Declarations are hoisted and named, which suits top-level utilities and mutual recursion; the cost is that code reads out of order if you lean on hoisting. Method shorthand is the clearest for object and class members: it gets super and a name, and the missing [[Construct]] stops accidental new.