So many ways to write a function
JavaScript offers several syntaxes for defining functions, each with different hoisting,
this behavior, and use cases. It also has a flexible parameter system — defaults, rest
parameters, destructuring, and the legacy arguments object. Knowing the distinctions lets
you pick the right tool and avoid surprises around binding and hoisting. This guide maps out
the function types and the parameter features that go with them.
Declarations vs expressions
A function declaration is a standalone statement; a function expression assigns a function to a variable. The crucial difference is hoisting.
sayHi() // works — declarations are fully hoisted
function sayHi() { return 'hi' }
sayBye() // TypeError — expression not yet assigned
const sayBye = function () { return 'bye' }
Declarations are hoisted entirely, so you can call them before their definition in the source.
Expressions are not — the variable is hoisted but holds undefined until the assignment runs.
Use declarations for top-level named functions; expressions when assigning conditionally or
passing inline.
Named vs anonymous expressions
A function expression can be anonymous or carry a name. A named function expression helps in stack traces and allows the function to reference itself for recursion.
const factorial = function fact(n) {
return n <= 1 ? 1 : n * fact(n - 1) // can call itself by its internal name
}
factorial(5) // 120
// fact is NOT visible outside the function body
The internal name (fact) is scoped to the function itself, keeping the outer namespace
clean while enabling self-reference.
Arrow functions
Arrow functions are concise and, crucially, don't have their own this,
arguments, super, or new.target — they inherit this lexically from the surrounding
scope. This makes them perfect for callbacks but unsuitable as methods or constructors.
const add = (a, b) => a + b // implicit return
const square = n => n * n // single param, no parens needed
const makeObj = () => ({ x: 1 }) // wrap object literal in parens
class Timer {
seconds = 0
start() {
setInterval(() => { this.seconds++ }, 1000) // `this` is the Timer
}
}
Because arrows capture this lexically, the setInterval callback keeps the class instance's
this — no bind needed. The flip side: you can't use an arrow as a method that needs its
own this, or as a constructor (new throws).
IIFE — immediately invoked function expressions
An IIFE runs immediately and creates a private scope. Before block scoping and modules, it was the main tool for avoiding global pollution.
(function () {
const secret = 42 // not visible outside
console.log(secret)
})()
Modern code uses block scope (let/const) and ES modules instead, but you'll still see
IIFEs in older code and for one-off async setup ((async () => { await init() })()).
Default parameters
Default parameters supply a value when an argument is undefined (not for other falsy
values). Defaults can reference earlier parameters and are evaluated at call time.
function greet(name, greeting = 'Hello') {
return `${greeting}, ${name}`
}
greet('Ada') // 'Hello, Ada'
greet('Ada', undefined) // 'Hello, Ada' — undefined triggers the default
greet('Ada', null) // 'null, Ada' — null does NOT
Defaults are evaluated left to right, so a later default can use an earlier parameter:
function range(start, end = start + 10) {...}.
Rest parameters
A rest parameter (...args) gathers any number of trailing arguments into a real array.
It replaces the awkward arguments object for variadic functions.
function sum(...nums) {
return nums.reduce((a, b) => a + b, 0) // nums is a real array
}
sum(1, 2, 3, 4) // 10
function log(level, ...messages) { // fixed param + rest
console.log(level, messages.join(' '))
}
The rest parameter must be last, and unlike arguments, it's a genuine array with all the
array methods available.
The arguments object
Inside regular (non-arrow) functions, arguments is an array-like object holding all
passed arguments. It predates rest parameters and has quirks.
function old() {
return Array.from(arguments).reduce((a, b) => a + b, 0) // must convert first
}
arguments isn't a real array (no map/reduce directly), and arrow functions don't have
it at all. Modern code should prefer rest parameters, which are cleaner and array-native.
Reach for arguments only in legacy contexts.
Parameter destructuring
You can destructure objects or arrays right in the parameter list, often with defaults — a clean way to accept "options" objects.
function createUser({ name, role = 'user', active = true } = {}) {
return { name, role, active }
}
createUser({ name: 'Ada' }) // { name: 'Ada', role: 'user', active: true }
createUser() // works — the `= {}` default prevents a crash
The trailing = {} is important: without it, calling createUser() with no argument would try
to destructure undefined and throw. Always default the destructured parameter itself.
Function metadata: length and name
Functions expose a couple of introspective properties. length is the number of parameters
before the first default or rest; name is the function's name (inferred for
expressions).
function f(a, b, c = 1, ...rest) {}
f.length // 2 — counts a, b only (stops at the default)
f.name // 'f'
const g = () => {}
g.name // 'g' — inferred from the variable
These are occasionally used by libraries (e.g. dependency injection inspecting arity), but mostly they're good to understand for debugging.
Methods vs standalone functions
A function stored as an object property is a method, and its this depends on how it's
called. Detaching a method loses its this — a frequent bug fixed by arrow-function fields or
bind.
const counter = {
count: 0,
increment() { this.count++ } // method — `this` is `counter` when called as counter.increment()
}
const fn = counter.increment
fn() // `this` is undefined (strict) — detached
Understanding that this is determined by the call site, not where the function is
defined, is the key to avoiding these traps.
Key takeaways
- Declarations are fully hoisted; expressions are not — pick based on whether you need to call before defining.
- Arrow functions have no own
this/arguments/superand can't be constructors — ideal for callbacks, wrong for methods. - Default parameters fill in for
undefinedonly and can reference earlier params. - Rest parameters (
...args) give a real array of extra arguments — prefer them over the array-likeargumentsobject (absent in arrows). - Parameter destructuring with a
= {}default cleanly handles options objects. thisis set by the call site; detached methods lose it — use arrows orbind.
Knowing each function form's hoisting and this behavior — plus the modern parameter features —
lets you write functions that behave exactly as you intend, with no binding surprises.