Differences between arrow Function, Function declaration and Function expression
Function declarations, function expressions, and arrow functions are three ways to define a function in JavaScript, each behaving differently around hoisting, this binding, and constructor support.
Theory
TL;DR
- Function declarations hoist fully - call them anywhere in their scope, even before they appear in the file
- Function expressions and arrow functions stay in the TDZ until assignment - accessing them early throws a ReferenceError
- Arrow functions have no own
this- they capture it lexically from the surrounding scope at definition time - Arrow functions can't be used with
new- they have no[[Construct]]internal method - Rule of thumb: declarations for top-level utilities, arrows for callbacks and handlers, expressions for IIFEs
Quick example
// Declaration: hoisted, callable before definition
sayHi(); // 'Hi!' - no error
function sayHi() {
console.log('Hi!');
}
// Expression: TDZ with const - calling early throws
// sayHiExpr(); // ReferenceError
const sayHiExpr = function() {
console.log('Hi from expression!');
};
// Arrow: lexical this - takes it from surrounding scope
const obj = {
name: 'World',
greet: () => console.log('Hi ' + this.name) // undefined, not 'World'
};
obj.greet(); // 'Hi undefined'Only the declaration works before its line. The arrow's this.name is undefined because the arrow captured this from the outer module scope, not from obj.
Key difference
The split that matters is hoisting. Function declarations are parsed before any code runs - the entire body lands in the execution context's VariableEnvironment upfront, making the function callable from line one. Expressions and arrows only hoist the variable binding: undefined with var, or a TDZ block with const/let. Beyond that, arrows skip creating this and arguments entirely - both are inherited from the enclosing lexical scope via the scope chain.
When to use
- Top-level utility function needed anywhere in the file - function declaration
- Callback, array method, or event handler - arrow function (short syntax, correct
this) - IIFE or higher-order return where you want a named function in stack traces - function expression
- Constructor called with
new- function declaration or expression, never arrow - Event listener in a class component - arrow (binds
thisto the instance automatically)
Comparison table
| Characteristic | Function Declaration | Function Expression | Arrow Function |
|---|---|---|---|
| Syntax | function name() {} | const name = function() {} | const name = () => {} |
| Hoisting | Full: entire body | Variable only (TDZ with const/let) | Variable only (TDZ with const/let) |
this binding | Dynamic (call-site) | Dynamic (call-site) | Lexical (enclosing scope) |
arguments object | Yes | Yes | No - use rest params (...args) |
Constructor (new) | Yes | Yes | No - throws TypeError |
super in classes | Yes | Yes | No |
| When to use | Hoisted utilities, module exports | IIFEs, higher-order returns | Callbacks, map/filter, React handlers |
How V8 handles this
V8 parses declarations in the pre-execution phase, allocating the full function object in the VariableEnvironment before a single line runs. Expressions and arrows parse as literals during execution and bind to the declared variable only when that line is reached. Arrows skip creating this and arguments bindings entirely - when you reference this inside an arrow, the engine walks up the scope chain to find it in the enclosing LexicalEnvironment.
Common mistakes
Calling an arrow before assignment:
greet(); // ReferenceError: Cannot access 'greet' before initialization
const greet = () => console.log('hi');const keeps greet in the TDZ. Move the call below the assignment, or use a function declaration.
Expecting dynamic this from an arrow method:
const obj = {
name: 'Alice',
say: () => console.log(this.name) // undefined, not 'Alice'
};
obj.say();The arrow grabbed this from the module scope, not from obj. Use a regular method shorthand: say() { console.log(this.name); }.
Using arguments inside an arrow:
const sum = () => console.log(arguments); // ReferenceError or outer arguments
sum(1, 2, 3);Arrows have no arguments binding. Use rest parameters: const sum = (...args) => args.reduce((a, b) => a + b, 0).
Arrow as constructor:
const User = () => ({ name: 'Bob' });
new User(); // TypeError: User is not a constructorNo [[Construct]] means no new. Use function User() {} or class User {}. This one shows up in code reviews more often than you'd expect - usually when someone refactors a class method into an arrow and forgets to update the callers.
super in an arrow class field:
class Child extends Parent {
// arrow = () => super.method(); // SyntaxError
method() { super.method(); } // correct
}Real-world usage
- React - arrow class fields (
handleSubmit = () => {}) to bindthisto the instance without.bind(this)in the constructor - Express.js - arrows in route callbacks (
app.use((req, res, next) => {})) for predictablethis - Redux - arrow selectors (
const getUsers = state => state.users) - Node.js streams - function declarations for reusable parsers called from multiple places
- Lodash - named function expressions in
_.curry(function add(a, b) {})so recursive calls work and stack traces stay readable
Follow-up questions
Q: What happens when a function expression is declared with var instead of const?
A: The variable hoists as undefined. Calling it before the assignment line gives TypeError: sayHi is not a function - not a ReferenceError - because the variable exists but holds undefined at that point.
Q: Why does arrow have no prototype?
A: The spec doesn't allocate prototype on arrows because they can't use [[Construct]]. No reason to create a property that would never be used.
Q: Does this change in a function declaration under strict mode?
A: Yes. In strict mode, this inside a function called without an explicit receiver is undefined, not the global object. Arrows are unaffected since they don't create their own this.
Q: Can you give an arrow function a name for better stack traces?
A: When you assign an arrow to a named const, the engine infers that variable name as the function name and shows it in stack traces. But you can't write a named arrow directly the way you write function log() {}.
Q: (Senior) In V8, how does TDZ behave differently for an immediately-invoked arrow with const vs a var function expression?
A: const f = (() => {})() evaluates fine on that line - TDZ only blocks access to f before the line, not the expression itself. With var f = (function(){})(), the var hoists as undefined. Referencing f before the line returns undefined, and calling f() throws TypeError: f is not a function - a different error from TDZ's ReferenceError, which matters when debugging.
Examples
Hoisting in practice
// Declaration is available immediately
console.log(add(2, 3)); // 5
function add(a, b) {
return a + b;
}
// Expression with const: TDZ until assignment line
// console.log(multiply(2, 3)); // ReferenceError
const multiply = function(a, b) {
return a * b;
};
console.log(multiply(2, 3)); // 6add works on line 2 because the declaration was hoisted with its full body. The commented multiply call would throw - const keeps the binding in the TDZ until the assignment.
Arrow function this in a React class component
class Button extends React.Component {
constructor(props) {
super(props);
this.state = { count: 0 };
// Expression: needs .bind(this) to get the right context
this.handleClickExpr = function() {
this.setState({ count: this.state.count + 1 });
}.bind(this);
}
// Arrow: captures this from class body, no bind needed
handleClickArrow = () => {
this.setState({ count: this.state.count + 1 });
};
render() {
return <button onClick={this.handleClickArrow}>{this.state.count}</button>;
}
}The expression version needs .bind(this) or it loses context when the browser calls it. The arrow captures this from the class definition scope automatically. This is the pattern pre-hooks React relied on for class event handlers.
Arrow fails as constructor in a factory
function Decl() { this.type = 'declaration'; }
const Expr = function() { this.type = 'expression'; };
const Arrow = () => {};
new Decl(); // { type: 'declaration' }
new Expr(); // { type: 'expression' }
new Arrow(); // TypeError: Arrow is not a constructor
// Real-world pattern that breaks at runtime, not compile time
function createInstance(Ctor) {
return new Ctor();
}
createInstance(Decl); // works
createInstance(Arrow); // TypeError in productionnew Arrow() throws because arrows have no [[Construct]] method. The dangerous part: there's no compile-time error. If you pass an arrow to a factory expecting a constructor, the crash surfaces at runtime.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.