Functions

Understand function objects, declaration timing, parameter binding, returns, and higher-order calls to write clear, testable JavaScript contracts.

level beginner time 12 min at Standard depth
version Node 24
what

A JavaScript function is a callable first-class value. It binds parameters for one call, executes its body, then returns an explicit result or undefined.

trap

Declarations and expressions initialize at different times; defaults handle only undefined, and an ordinary function’s this also depends on its call form.

fix

Define inputs, returns, errors, and the callback contract before choosing a function form; test omission, undefined, null, and detached methods.

What it is and why it exists

A JavaScript function packages behavior in a callable object. Callers supply data for one execution through arguments, and the function delivers a result through a return value or an explicit side effect. A function isn’t syntax fixed to one location: it is a first-class function that you can assign, store in an object, pass as an argument, and return from another function.

Functions solve behavior reuse and boundary definition. An accurately named function can hide how a calculation works behind a stable interface, leaving callers to care about inputs, outputs, and failure modes. It also establishes local scope so temporary names don’t leak into surrounding code.

Function declarations, traditional function expressions, and arrow functions all produce function values, but they aren’t interchangeable layout choices. Declarations initialize differently; arrows have no own this or arguments and can’t be constructed. Choose a form from its calling contract, not just from which one is shortest.

A parameter appears in a function definition; an argument appears in a call expression. A call binds each argument by position to a parameter , and the counts don’t have to match. A missing parameter receives undefined; an extra argument matters only if the function chooses to read it.

Functions also make behavior into data. A higher-order function accepts or returns functions so validation, transformation, and policy can be composed without putting every variation in a conditional. A passed function is often called a callback, but the calling API must still define its parameters, return value, call count, and error handling.

You encounter functions in event handling, array transformations, route handlers, scheduled work, test doubles, and dependency injection. Closures, arrow details, and the complete rules for call, apply, and bind have their own topics; this one covers the function foundations shared by all of them.

How it works

Evaluating function code produces a function object. The object stores executable code and a reference to the lexical environment where it was created, and it has its own identity; matching source text doesn’t make two function objects equal. Assigning a function to another variable copies the object reference, not the function body.

A call expression first evaluates the callee and arguments. The runtime then creates an execution context for that call, establishes parameter and local bindings, and determines this from the function kind and call form. The call ends when the body reaches return, throws, or runs to completion.

Every call expression in the diagram creates fresh parameter and local bindings. A function object can be called repeatedly, but ordinary locals aren’t shared between those calls. State that must survive calls belongs in an outside object, a module binding, or a closure.

Creating functions and binding names

A function declaration creates and initializes its binding when its scope is instantiated, so code in that scope can usually call it before its textual declaration. This is often shortened to “function hoisting,” but the important fact is that the binding already holds the function object; the name wasn’t merely registered early.

A function expression only produces a function value, while its containing declaration determines when the outside binding can be read. In const calculate = function () {}, calculate exists in the temporal dead zone from the start of the scope but can’t be read until its initializer executes. Saying only that “function expressions aren’t hoisted” hides the actual reason for the failure.

A named function expression provides an internal name inside its own body, which supports recursion and better diagnostics. That internal name normally doesn’t leak into the outer scope. Anonymous expressions and arrows often infer name from their assignment target.

FormWhen the outer name can be calledOwn thisConstructible with new
function load() {}After scope instantiationYesAn ordinary declaration usually is
const load = function () {}After the initializerYesAn ordinary expression usually is
const load = () => {}After the initializerNoNo

The table describes ordinary functions, not every combination of generators, async functions, methods, and bound functions. Don’t guess whether an arbitrary value is constructible from its prototype property; when an API requires a constructor, state that contract and test it directly.

Binding parameters and arguments

At call time, argument expressions are evaluated from left to right and then bound to parameters. Default parameter initializers are also handled from left to right, so a later default can refer to an earlier parameter. An initializer runs for each applicable call, not when the function is defined.

A default applies only when an argument is missing or strictly undefined. null, false, 0, and the empty string are supplied values and don’t trigger a default. If the domain treats null as missing too, the function must normalize it explicitly.

A rest parameter collects arguments not already bound into a real array and must come last. It is clearer than legacy arguments: its name expresses intent, and array methods work directly. Arrows have no own arguments, while the older alias behavior of arguments also depends on strict mode and parameter form.

A destructuring parameter first requires its outer value to be destructurable, then processes defaults inside the pattern. function read({ id }) {} throws TypeError with no argument or undefined; function read({ id } = {}) {} covers a missing whole object. An inner id = 'draft' default handles only a property whose value is undefined.

Call caseOrdinary parameterParameter with defaultRest parameter
Missing argumentundefinedEvaluate default expressionAdd nothing to the array
Argument is undefinedundefinedEvaluate default expressionCollect undefined
Argument is nullnullnullCollect null
Extra argumentNo corresponding bindingNo corresponding bindingCollect the remaining value

Completing a call

return expression evaluates the expression and immediately ends the current call. A bare return and reaching the end of the body both produce undefined. Callers should distinguish a command function that intentionally returns nothing from a function missing its return, even though the runtime values match.

JavaScript inserts semicolons at restricted positions. If a newline immediately follows return, that return statement is already complete, and an object literal on the next line isn’t returned. Put the opening token of a multiline expression on the same line as return, or wrap the expression explicitly in parentheses.

Throwing skips the normal return path and searches up the call stack for a matching handler. A function contract should say which failures are returned and which are thrown; mixing null, error objects, and exceptions forces every caller to guess.

Every call to an async function returns a Promise, even when its source says return 3. An ordinary callback API doesn’t start awaiting merely because it receives an async function. The concurrency and propagation rules for asynchronous control flow belong in javascript/async-await and javascript/promises.

The call form determines the receiver

An ordinary function’s this isn’t fixed at definition time. object.method() supplies the object to the left of the dot as receiver; after extracting the same function and running detached(), the receiver rule has changed. A plain ordinary call in strict mode receives undefined.

call, apply, and bind can explicitly supply an ordinary function’s receiver. An arrow captures this from its surroundings, so those methods can’t replace an arrow’s this. If a function doesn’t need this, pass dependencies as ordinary parameters to make its contract easier to read and test.

An extracted method remains the same function object, but it doesn’t remember its original object. APIs such as event subscriptions also remove callbacks by function identity, so creating two temporary wrappers or bound functions fails too. See javascript/this-binding and javascript/call-apply-bind for the full precedence and repair patterns.

Examples

These four examples verify name initialization, parameter boundaries, composition of function values, and call-site receivers in sequence. Every output comes from running its file locally with Node 24.

Declaration and expression timing

The formatOrder declaration can be called before its source declaration. The const binding for calculateTotal is still in its temporal dead zone, so the first read throws ReferenceError; the same binding works after initialization.

declaration_vs_expression.js
console.log(formatOrder('A7'));

function formatOrder(orderId) {
  return `order:${orderId}`;
}

try {
  console.log(calculateTotal([12, 8]));
} catch (error) {
  console.log(error.name);
}

const calculateTotal = function total(lineTotals) {
  return lineTotals.reduce((sum, amount) => sum + amount, 0);
};

console.log(calculateTotal([12, 8]));
console.log(calculateTotal.name);
order:A7
ReferenceError
20
total

The total output comes from the named function expression’s internal name. The outer variable is still calculateTotal; the two names serve different purposes: the outside name retrieves the function value, while the inside name supports self-reference and diagnostics.

The error is caught only to show behavior before and after initialization in one run. Production code shouldn’t use the temporal dead zone as a branch mechanism; move the declaration, or choose a function declaration when early calls are genuinely required.

Defaults, destructuring, and rest arguments

summarizeOrder provides separate defaults for the whole object parameter and one object property. The rest parameter amounts is an array, so an empty input can also reduce safely from the seed 0.

parameter_contracts.js
function summarizeOrder({ id, currency = 'EUR' } = {}, ...amounts) {
  const total = amounts.reduce((sum, amount) => sum + amount, 0);
  return `${id ?? 'draft'}: ${currency} ${total.toFixed(2)}`;
}

const regularOrder = summarizeOrder({ id: 'A-42' }, 10, 5.5);
const omittedCurrency = summarizeOrder({ id: 'B-7', currency: undefined }, 4);
const nullCurrency = summarizeOrder({ id: 'C-9', currency: null }, 4);
const draftOrder = summarizeOrder();

console.log(regularOrder);
console.log(omittedCurrency);
console.log(nullCurrency);
console.log(draftOrder);
A-42: EUR 15.50
B-7: EUR 4.00
C-9: null 4.00
draft: EUR 0.00

The second call supplies undefined explicitly, so the property default still runs. The third preserves null, proving that a default parameter isn’t a general nullish-coalescing mechanism. If null violates the domain contract, validate and reject or normalize it at the boundary.

The whole-object = {} protects only omission and undefined; passing null still can’t be destructured. Boundary tests should treat omission, undefined, and null as three separate cases.

Passing functions as strategies

pipe accepts an initial value and any number of step functions. Each step’s return becomes the next step’s argument, so the input and output types of adjacent steps must connect.

higher_order_pipeline.js
function pipe(value, ...steps) {
  return steps.reduce((current, step) => step(current), value);
}

const addTax = (amount) => amount * 1.2;
const roundCents = (amount) => Math.round(amount * 100) / 100;
const formatEuros = (amount) => `EUR ${amount.toFixed(2)}`;

const total = pipe(80, addTax, roundCents, formatEuros);

console.log(total);
console.log(typeof addTax);
console.log(addTax === ((amount) => amount * 1.2));
EUR 96.00
function
false

pipe is higher-order, while the three steps are ordinary function values. The last line creates a new arrow function object; matching source behavior doesn’t give it the same identity as addTax.

This small pipeline handles synchronous return values only and lets errors propagate directly. Don’t insert asynchronous steps into the same interface without defining Promise, error, and cancellation semantics.

Preserving a call-site receiver

The same formatId function is called first as a method and then as a plain function. In strict mode, the plain call’s this is undefined, so reading this.id throws TypeError.

call_site_receiver.js
'use strict';

function formatId(prefix) {
  return `${prefix}-${this.id}`;
}

const invoice = { id: 7, formatId };
console.log(invoice.formatId('INV'));

const detached = invoice.formatId;
try {
  console.log(detached('INV'));
} catch (error) {
  console.log(error.name);
}

const fixed = detached.bind({ id: 8 });
console.log(fixed('RET'));
INV-7
TypeError
RET-8

bind returns a new function object and permanently stores the receiver supplied here. If code later unregisters this callback, retain that returned value and use the same reference for registration and removal.

If formatting doesn’t need method semantics, formatId(id, prefix) is a simpler interface. An explicit parameter removes the hidden receiver and lets the function be passed directly to most callback APIs.

Pitfalls

Fix: inventory the call sites and the function capabilities they depend on. Replace the form with an arrow only after proving there is no early call, dynamic receiver, arguments, or construction path, and run tests for those contracts.

Fix: distinguish “omitted” from “explicitly empty.” Use a default parameter when only undefined means omission; when both meanings match, normalize nullish input deliberately at the boundary without replacing valid values such as 0, false, or the empty string.

Fix: keep an expression body for one expression or write return in a larger body. Use item => ({ id: item.id }) for an object literal, and test the actual return rather than only asserting that the callback ran.

Fix: declare ...args in new code. A rest parameter gives you a real array and an intentional name, without the alias rules that simple parameters can have in old non-strict code.

Fix: forward explicitly with event => service.handle(event), or bind once and retain the result. When a listener must be removed, registration and removal need the same function identity.

Deep Default parameters have an initialization environment

Default parameters have an initialization environment

A non-simple parameter list contains a default, destructuring, or a rest parameter. The specification establishes an environment for parameter initialization before entering the lexical declaration environment of the body. This distinction explains several edge cases that “local scope” alone doesn’t predict.

A later default initializer can read an earlier initialized parameter, as in function range(start, end = start) {}. An earlier initializer can’t read a later parameter that is still in its temporal dead zone. A default can call an outer binding, but it can’t read a let, const, or function declaration that exists only in the function body.

Every applicable call reevaluates its default expression. function collect(items = []) {} therefore creates a new array for each call that omits the argument; it doesn’t share one definition-time container across calls as some languages do. If callers explicitly pass the same array, that aliasing still belongs to the callers.

A default initializer can run arbitrary JavaScript, including function calls and side effects. Complex initialization makes ordering and failure points hard to see. Validation, logging, or asynchronous preparation is usually easier to review when the body performs it in explicit steps.

A destructuring pattern has two levels of missing-value handling. An outer default chooses the object when the whole argument is undefined; a property default chooses a value when that property is undefined. Neither level automatically accepts null or validates the property’s type.

name and length are descriptive

A function object’s name often comes from an explicit name, but it can also be inferred from an assignment target, property definition, or default export. It helps stacks and diagnostics, yet minifiers, wrappers, and binding can all change it. Business logic must not choose permissions, routes, or serialization formats from function.name.

A function’s length is the number of parameters before its first default; a rest parameter doesn’t count. It isn’t the number of required arguments and knows nothing about runtime validation, destructured properties, or TypeScript types. Capability detection based on it commonly misclassifies callbacks.

DefinitionTypical namelength
function save(a, b) {}save2
const save = function (a, b = 0) {}save1
const save = (...items) => {}save0
function save({ id }) {}save1

Own property descriptors and other built-in function properties have more detail, but public APIs shouldn’t depend on that heuristic metadata. A framework that needs injection, validation, or routing metadata should use explicit configuration, stable markers, or a well-defined wrapper.

Callable does not mean constructible

Every function value discussed here supports ordinary call syntax, but not every function implements construction. Ordinary declarations and traditional function expressions are generally constructible; arrows and method definitions aren’t. Async functions and generators aren’t ordinary constructors either.

A construction call creates a new object and supplies it as this inside an ordinary constructor body. If the constructor explicitly returns an object, that object can replace the newly created result; returning a primitive doesn’t replace it. This special return rule belongs to constructor contracts and shouldn’t be mixed into ordinary business functions.

Don’t probe an arbitrary function’s constructibility on a production path by catching TypeError, and don’t treat an own prototype property as a complete answer. Cases such as bound functions separate surface properties from target capabilities. An API that requires constructors should accept explicitly registered constructors and execute the expected construction path in tests.

Class syntax makes construction intent clearer, and a class can’t be called normally without new. When you need instance methods, inheritance, or several state operations, a class is usually clearer than an old-style function serving simultaneously as ordinary function and constructor.

Choosing a function form

Choose a function form by deciding name lifetime, receiver, and construction capability first. Style rules come only after those semantic constraints. Normalizing every function into one form removes contract signals that readers could otherwise see in the syntax.

RequirementUseful starting formBoundary to confirm
Allow early calls in the same scopeFunction declarationWhether that initialization timing is intentional
Initialize behavior as a value at one pointFunction expression or arrowReads happen after initialization
Recurse without depending on an outer variable nameNamed function expressionThe internal name doesn’t leak outward
Capture outer this in a short callbackArrow functionNo dynamic receiver or new is required
Expose behavior on an objectMethod definitionHow detached uses retain the receiver

An ordinary function can also ignore this completely. In that case, the main difference between a declaration and a traditional expression is binding and initialization. Read both the body and its call sites; the function keyword alone doesn’t prove something is a method or constructor.

An arrow’s expression body suits short transformations, but brevity isn’t the central reason to choose one. Lexical this, no own arguments, and non-constructibility are the semantic differences. The related arrow-functions topic covers the complete rules and migration hazards.

Testing function contracts

Start function tests from the public contract: given an input, what returns, what changes, and what throws. Proving only that “the function was called” misses absent returns, shifted arguments, and ignored callback results. Assert results for pure calculations and deliberate observable effects for command functions.

For optional input, separately test omission, explicit undefined, null, the wrong type, and valid falsy values. A destructured parameter also needs cases for a missing whole object, missing properties, and missing nested objects. These cases prove that defaults sit at the intended level.

Receiver tests should cover the intended method call and a detached call at minimum. If an API retains a callback, also test the full register, trigger, remove, trigger-again lifecycle and prove that removal uses the same function identity.

Test a higher-order function with a small double that records arguments and call counts. Assert which arguments the callback receives, in what order it runs, how its result is consumed, and whether exceptions propagate. For an async callback, also state whether the API awaits serially, awaits concurrently, or doesn’t await at all.

Finally, test that function values are created at the lifetime your ownership model expects. Recreating functions in a loop or render path can break identity-based deduplication or removal; over-reusing one closure can share state that should be isolated. The contract to verify is lifetime, not a vague goal of fewer allocations.

Function identity is part of the contract

Aliasing a variable doesn’t create a function. After const alias = handler, alias === handler is true, and either name can be used interchangeably with an identity-based registry. A new function object appears only when a function expression, arrow, bind, or wrapper is evaluated again.

An API that stores callbacks by identity makes the caller manage that object’s lifetime. Writing event => handle(event) inline at registration is convenient, but code must first retain the wrapper in a stable binding when the removal API also needs the function value. Repeating the same source can’t recover the original object.

Caching a function object doesn’t mean it belongs at global scope. A function may own request, component, or tenant state through a closure, and excessive reuse merges those ownership boundaries. Stable identity and correct isolation must be designed together.

OperationPreserves identityOwnership effect
const alias = handlerYesBoth bindings reference one object
handler.bind(receiver)NoA new object stores target and receiver
(value) => handler(value)NoA new object captures the current lexical environment
Calling a factory for its inner function againNoA new object usually owns fresh closure state

Further reading

checkpoint

4 questions · 2 predict-the-output · 1 spot-the-bug

Copy as Markdown Interview bank Edit on GitHub Report an error Was this clear?