JavaScript Functions: Declaration, Expression, and Arrow

JavaScript treats functions as first-class citizens, meaning you can assign them to variables, pass them as arguments, and return them from other functions. But not all functions behave the same way....

Key Insights

  • Function declarations are hoisted to the top of their scope, while function expressions and arrow functions are not—this fundamentally changes when your functions are available for use
  • Arrow functions bind this lexically from their surrounding context, making them ideal for callbacks but unsuitable for object methods or constructors
  • Choose function declarations for top-level utility functions, expressions for conditional function creation, and arrow functions for short callbacks and when you need predictable this behavior

Understanding JavaScript’s Three Function Types

JavaScript treats functions as first-class citizens, meaning you can assign them to variables, pass them as arguments, and return them from other functions. But not all functions behave the same way. The three main types—declarations, expressions, and arrow functions—differ in syntax, hoisting behavior, and how they handle the this keyword.

Here’s a quick look at all three:

// Function Declaration
function greet(name) {
  return `Hello, ${name}`;
}

// Function Expression
const greetExpression = function(name) {
  return `Hello, ${name}`;
};

// Arrow Function
const greetArrow = (name) => `Hello, ${name}`;

These differences aren’t just syntactic sugar. They affect when your code runs, how it behaves in different contexts, and what bugs you might encounter.

Function Declarations: The Traditional Approach

Function declarations use the function keyword followed by a name, parameters, and a body. They’re the most straightforward way to define functions in JavaScript.

function calculateTotal(price, tax) {
  const total = price + (price * tax);
  return total;
}

// You can call this before the declaration due to hoisting
console.log(multiply(5, 3)); // 15

function multiply(a, b) {
  return a * b;
}

The critical behavior here is hoisting. JavaScript moves function declarations to the top of their scope during compilation. This means you can call multiply() before its actual declaration in the code—the entire function definition is hoisted, not just the name.

Function declarations work best for:

  • Top-level utility functions that other code depends on
  • Methods you want available throughout a module
  • Recursive functions that need to reference themselves by name
  • When you want clear, self-documenting code with named functions
function fibonacci(n) {
  if (n <= 1) return n;
  return fibonacci(n - 1) + fibonacci(n - 2); // Self-reference works naturally
}

Function Expressions: Functions as Values

Function expressions define a function as part of an expression, typically by assigning it to a variable. The function itself can be named or anonymous.

// Anonymous function expression
const divide = function(a, b) {
  if (b === 0) throw new Error('Division by zero');
  return a / b;
};

// Named function expression (useful for debugging and recursion)
const factorial = function fact(n) {
  if (n <= 1) return 1;
  return n * fact(n - 1);
};

Unlike declarations, function expressions are not hoisted. The variable is hoisted, but it’s undefined until the assignment executes.

console.log(typeof myFunc); // undefined
// console.log(myFunc()); // TypeError: myFunc is not a function

const myFunc = function() {
  return 'I exist now';
};

console.log(myFunc()); // "I exist now"

Function expressions shine when you need:

  • Conditional function creation
  • Functions as callback arguments
  • Immediately Invoked Function Expressions (IIFEs)
  • Functions stored in data structures
// Conditional function creation
const getGreeting = language === 'es' 
  ? function(name) { return `Hola, ${name}`; }
  : function(name) { return `Hello, ${name}`; };

// As callbacks
[1, 2, 3].map(function(n) {
  return n * 2;
});

// IIFE pattern
(function() {
  const privateVar = 'secret';
  console.log('This runs immediately');
})();

Arrow Functions: Concise and Lexically Bound

Introduced in ES6, arrow functions provide shorter syntax and, more importantly, lexical this binding. They don’t create their own this context—they inherit it from the surrounding scope.

// Various syntax forms
const square = x => x * x;  // Single param, implicit return
const add = (a, b) => a + b;  // Multiple params, implicit return
const complexCalc = (x, y) => {  // Block body, explicit return
  const temp = x * 2;
  return temp + y;
};

The this binding difference is crucial:

function Timer() {
  this.seconds = 0;
  
  // Regular function: 'this' refers to the global object (or undefined in strict mode)
  setInterval(function() {
    this.seconds++; // BUG: 'this' is not the Timer instance
    console.log(this.seconds); // NaN
  }, 1000);
}

function TimerFixed() {
  this.seconds = 0;
  
  // Arrow function: 'this' is lexically bound to the Timer instance
  setInterval(() => {
    this.seconds++; // Works correctly
    console.log(this.seconds); // 1, 2, 3...
  }, 1000);
}

When NOT to use arrow functions:

// DON'T use for object methods
const person = {
  name: 'Alice',
  greet: () => {
    console.log(`Hello, ${this.name}`); // 'this' is not the person object
  }
};

// DON'T use as constructors
const MyClass = () => {};
// new MyClass(); // TypeError: MyClass is not a constructor

// DON'T use when you need 'arguments' object
const sum = () => {
  console.log(arguments); // ReferenceError: arguments is not defined
};

Arrow functions are perfect for:

  • Array methods and functional programming
  • Short callbacks
  • Event handlers where you need access to outer scope
  • Any situation where you want predictable this behavior
// Array methods
const numbers = [1, 2, 3, 4, 5];
const doubled = numbers.map(n => n * 2);
const evens = numbers.filter(n => n % 2 === 0);
const sum = numbers.reduce((acc, n) => acc + n, 0);

// Event handlers with React-style class
class Button {
  constructor() {
    this.count = 0;
  }
  
  handleClick = () => {
    this.count++; // 'this' always refers to the Button instance
    console.log(this.count);
  }
}

Practical Comparison and Real-World Usage

Here’s when to choose each type:

Scenario Best Choice Reason
Top-level utility functions Declaration Clear, hoisted, self-documenting
Array callbacks Arrow Concise, lexical this
Object methods Declaration/Expression Need dynamic this
Event handlers (React/classes) Arrow Preserve this binding
Conditional logic Expression Not hoisted, assigned conditionally
Constructors Declaration Arrow functions can’t be constructors

Real-world example combining all three:

// Declaration for main module function
function createUserManager() {
  const users = [];
  
  return {
    // Expression for object method
    addUser: function(name, email) {
      users.push({ name, email, id: users.length + 1 });
    },
    
    // Expression for method needing 'this'
    getCount: function() {
      return this.toString().length; // Dynamic 'this'
    },
    
    // Arrow function for callback with outer scope access
    findByEmail: (email) => {
      return users.find(user => user.email === email);
    },
    
    // Arrow for array manipulation
    getAllEmails: () => users.map(user => user.email)
  };
}

Common Pitfalls and Best Practices

Pitfall #1: Arrow functions in object literals

// WRONG
const calculator = {
  value: 0,
  increment: () => {
    this.value++; // 'this' is not calculator
  }
};

// RIGHT
const calculator = {
  value: 0,
  increment() { // Shorthand method syntax
    this.value++;
  }
};

Pitfall #2: Inconsistent function style

Pick a convention and stick to it. Many teams use:

  • Declarations for top-level functions
  • Arrow functions for callbacks
  • Method shorthand for object methods
// Consistent style
function processData(data) {
  return data
    .filter(item => item.active)
    .map(item => item.value)
    .reduce((sum, val) => sum + val, 0);
}

Pitfall #3: Overusing arrow functions

// Unnecessary - no 'this' binding needed, harder to debug
const add = (a, b) => a + b;

// Better - named function appears in stack traces
function add(a, b) {
  return a + b;
}

Making the Right Choice

Use this decision tree:

  1. Need dynamic this binding? → Function declaration or expression
  2. Need to call before definition? → Function declaration
  3. Short callback or array method? → Arrow function
  4. Need lexical this (event handlers, timers)? → Arrow function
  5. Top-level reusable function? → Function declaration
  6. Conditional function creation? → Function expression

Understanding these three function types gives you precise control over your JavaScript code’s behavior. Function declarations provide clarity and hoisting, expressions offer flexibility, and arrow functions deliver concise syntax with predictable this binding. Master when to use each, and you’ll write cleaner, more maintainable JavaScript.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.