Functions
Writing reusable code: function declarations and expressions, arrow functions, parameters with defaults and rest, return values, scope, closures and hoisting.
Declaring and calling a function
A function is a
named, reusable block of code. The most common way to create one is
a function declaration:
the keyword function, a name, a list of parameters in
parentheses and a body in curly braces. You call it by writing its
name followed by parentheses with the arguments.
return ends the function and hands a value back to the
caller.
function energyCost(kwh, tariff) {
return kwh * tariff;
}
const cost = energyCost(318.4, 0.27);
console.log(cost.toFixed(2));
console.log(energyCost(100, 0.21));
console.log(typeof energyCost);
85.97 21 function
The general ideas behind functions (decomposition, abstraction, a
signature as a contract) are on
Functions and Abstraction. Function names are usually verbs in lowerCamelCase:
energyCost, formatTicket,
isValidMeterId.
Function expressions and arrow functions
Functions in JavaScript are first-class values: a function is an object, just like an array or a string, so it can be stored in a variable, put in an array, passed as an argument and returned from another function.
Function expressions
A
function expression
creates a function as part of an expression, usually to assign it to
a variable. The name after function is optional;
without one it is an anonymous function. Unlike a declaration, a
function expression only exists once the line that creates it has
run (see hoisting).
const vat = function (amount) {
return amount * 0.21;
};
const formatters = [
function (n) { return n.toFixed(2); },
function (n) { return `€${n.toFixed(2)}`; },
];
console.log(vat(100));
for (const format of formatters) {
console.log(format(vat(86)));
}
21 18.06 €18.06
Arrow functions
An arrow function (ES2015) is
a shorter function expression written with =>. If
the body is a single expression, you can leave out the braces and
the return: the value of the expression is returned
automatically. With one parameter the parentheses are optional,
though many teams always write them.
const double = (n) => n * 2; // expression body: returns implicitly
const add = (a, b) => a + b;
const now = () => 'no parameters, empty parentheses';
const describe = (kwh) => { // block body: needs return
const level = kwh > 300 ? 'high' : 'normal';
return `${kwh} kWh (${level})`;
};
const toReading = (kwh) => ({ kwh, unit: 'kWh' }); // object: wrap in ( )
console.log(double(21), add(2, 3), now());
console.log(describe(318.4));
console.log(toReading(290.1));
console.log([118, 96, 104].map((kwh) => kwh / 2));
42 5 no parameters, empty parentheses
318.4 kWh (high)
{ kwh: 290.1, unit: 'kWh' }
[ 59, 48, 52 ]
Arrow functions are not just shorter. They have no
this of their own (they use the this of
the surrounding code), no arguments object, and cannot
be called with new. That makes them ideal for short
callbacks, and a poor choice for object methods that need
this; see
Classes, Prototypes and this.
Parameters and arguments
Parameters
are the names in the function definition; arguments are the values
passed in a call. JavaScript is lenient about the number of
arguments: a missing argument makes its parameter
undefined, and extra arguments are silently ignored.
There is no error either way, so a forgotten argument usually shows
up later as NaN or undefined in a result.
function energyCost(kwh, tariff) {
return kwh * tariff;
}
console.log(energyCost(100, 0.27));
console.log(energyCost(100)); // tariff is undefined
console.log(energyCost(100, 0.27, 'x')); // extra argument ignored
27 NaN 27
Default parameters
A parameter can have a
default value, written with =. It is used when the argument is
missing or is undefined (but not when it is
null). The default expression is evaluated anew on
every call, so a default of [] gives each call a fresh
array.
function energyCost(kwh, tariff = 0.27) {
return Number((kwh * tariff).toFixed(2));
}
console.log(energyCost(100), energyCost(100, 0.21), energyCost(100, undefined));
function addReading(value, log = []) {
log.push(value);
return log;
}
console.log(addReading(318.4));
console.log(addReading(290.1)); // a new empty array again, not [318.4, 290.1]
27 21 27 [ 318.4 ] [ 290.1 ]
Rest parameters and spread
A rest parameter, written with
three dots before the last parameter name
(...readings), collects all remaining arguments into a
real array. It is the counterpart of Python's *args.
The same three dots in a call do the opposite: they spread
an array out into separate arguments.
function average(label, ...readings) {
const total = readings.reduce((sum, r) => sum + r, 0);
return `${label}: ${(total / readings.length).toFixed(1)} kWh from ${readings.length} readings`;
}
console.log(average('KE-0042', 318.4, 290.1, 305.7));
const week = [118, 96, 104.5, 99];
console.log(average('KE-0040', ...week)); // spread the array into arguments
console.log(Math.max(...week));
KE-0042: 304.7 kWh from 3 readings KE-0040: 104.4 kWh from 4 readings 118
Older code uses the arguments object, an array-like
list of all arguments that exists inside every non-arrow function.
Rest parameters replace it: they are a real array, have a clear name
and also work in arrow functions.
Named options with an object
When a function has several optional settings, pass one object and
unpack it in the parameter list with destructuring and defaults.
Calls then read like named arguments, and the order no longer
matters. The final = {} allows calling the function
with no argument at all.
function createTicket(title, { priority = 'P3', team = 'Service desk', notify = true } = {}) {
return `[${priority}] ${title} -> ${team}${notify ? ' (notified)' : ''}`;
}
console.log(createTicket('Meter display blank'));
console.log(createTicket('Substation South offline', { priority: 'P1', team: 'NOC' }));
console.log(createTicket('Typo on invoice', { notify: false }));
[P3] Meter display blank -> Service desk (notified) [P1] Substation South offline -> NOC (notified) [P3] Typo on invoice -> Service desk
Arguments are passed the same way as in Python: the function gets the value, and for an object or array that value is a reference to the same object. A function that changes an object it received changes the caller's object too; see pass by object reference.
Return values
return stops the function and gives back one
return value. A
function that ends without return, or with a bare
return;, returns undefined. To return
several values, return an array or an object and destructure it at
the call site.
function logReading(kwh) {
console.log(`logged ${kwh}`);
} // no return statement
function stats(values) {
return { min: Math.min(...values), max: Math.max(...values) };
}
console.log(logReading(318.4));
const { min, max } = stats([12.4, 15.1, 9.8, 17.3]);
console.log(min, max);
logged 318.4 undefined 9.8 17.3
function status() {
return
{ state: 'ok' };
}
console.log(status());
undefined
Scope and shadowing
Scope is the part of the program where a name can be used. Each function call creates its own scope for its parameters and the variables declared inside it; those are local variables, invisible from outside. Code inside a function can read and change variables from the scopes around it.
Block scope
Block scope means that a variable
declared with let or const
exists only inside the nearest pair of curly braces: an
if block, a loop body or a bare { } block.
The older var ignores blocks; it is scoped to the whole
function (or the whole script), so it leaks out of
if blocks and loops. That is one of the main reasons
modern code uses let and const only.
function checkTemperature(tempC) {
if (tempC > 30) {
let message = 'heat alert';
var legacy = 'set inside the if block';
console.log(message);
}
console.log(typeof message); // let: gone outside its block
console.log(legacy); // var: still visible in the whole function
}
checkTemperature(34);
heat alert undefined set inside the if block
Shadowing
When an inner scope declares a variable with the same name as an outer one, the inner variable shadows the outer one: inside the inner scope, the name refers to the inner variable, and the outer variable is untouched. Shadowing is legal, but it makes code harder to read, and linters can warn about it.
const tariff = 0.27; // outer (global) variable
function nightCost(kwh) {
const tariff = 0.21; // shadows the outer tariff inside this function
return kwh * tariff;
}
console.log(nightCost(100));
console.log(tariff); // the outer variable did not change
{
const tariff = 0.35; // a block can shadow too
console.log('peak', tariff);
}
21 0.27 peak 0.35
Hoisting
Hoisting is JavaScript's behavior of registering declarations at the start of their scope, before any code in that scope runs. The effect differs per kind of declaration:
- Function declarations are hoisted completely, so you can call them above the line where they are written.
-
varvariables are hoisted and start asundefined, so reading one too early givesundefinedinstead of an error. -
let,constand classes are hoisted too, but stay uninitialized until their line runs. Using them earlier throws aReferenceError; that gap is called the temporal dead zone. - Function expressions and arrow functions follow the rules of the variable they are assigned to, so they cannot be called before that line.
console.log(formatTicket(31877)); // works: declarations are hoisted
console.log(region); // var: hoisted, but still undefined
var region = 'South';
function formatTicket(n) {
return `INC-${n}`;
}
INC-31877 undefined
console.log(tariff); // let/const: temporal dead zone
const tariff = 0.27;
ReferenceError: Cannot access 'tariff' before initialization
The temporal dead zone is a feature: an error at the exact line is
far easier to fix than a silent undefined. A function
expression stored in var fails differently: the
variable exists but holds undefined, so calling it
gives TypeError: … is not a function.
Closures
A closure is a function together with the variables of the scope in which it was created. A JavaScript function keeps a reference to that surrounding scope, so it can still read and update those variables later, even after the outer function has returned. Every function in JavaScript is a closure in this sense; it becomes interesting when a function outlives the call that created it.
function makeTicketCounter(prefix) {
let next = 31877; // lives on after makeTicketCounter returns
return function () {
const id = `${prefix}-${next}`;
next++;
return id;
};
}
const newIncident = makeTicketCounter('INC');
const newChange = makeTicketCounter('CHG');
console.log(newIncident(), newIncident(), newIncident());
console.log(newChange()); // its own, separate counter
INC-31877 INC-31878 INC-31879 CHG-31877
next is effectively
private.
Closures give you private state without classes: nothing outside can
reach next except through the returned function. They
are also how callbacks remember context, how functions such as
makeTicketCounter configure other functions, and how
many libraries hide their internals. Functions that take or return
other functions are called
higher-order functions.
Closures in loops
A closure captures the variable, not a copy of its value at
that moment. With var, a loop has one shared counter
variable, so every function created in the loop sees its final
value. With let, each round of a for loop
gets a new binding, so each function keeps its own value. This is a
classic interview question, and another reason to use
let.
const withVar = [];
for (var i = 1; i <= 3; i++) {
withVar.push(() => `feeder ${i}`);
}
const withLet = [];
for (let j = 1; j <= 3; j++) {
withLet.push(() => `feeder ${j}`);
}
console.log(withVar.map((f) => f()));
console.log(withLet.map((f) => f()));
[ 'feeder 4', 'feeder 4', 'feeder 4' ] [ 'feeder 1', 'feeder 2', 'feeder 3' ]
Callbacks and timers
A callback function is a
function you pass as an argument to another function, which then
calls it at the right moment: for each element of an array, when a
timer expires, when a user clicks, or when data arrives. You hand
over the function itself, without parentheses;
check(reading) would call it immediately and pass its
result instead.
function processReadings(readings, onAlert) {
for (const r of readings) {
if (r.kwh > 300) {
onAlert(r); // call back the function we were given
}
}
}
const readings = [
{ meter: 'KE-0040', kwh: 118 },
{ meter: 'KE-0042', kwh: 318.4 },
{ meter: 'KE-0047', kwh: 412.9 },
];
processReadings(readings, (r) => console.log(`High usage on ${r.meter}: ${r.kwh} kWh`));
const isHigh = (r) => r.kwh > 300;
console.log(readings.filter(isHigh).length); // pass isHigh, not isHigh()
High usage on KE-0042: 318.4 kWh High usage on KE-0047: 412.9 kWh 2
setTimeout and setInterval
Timers are the classic callbacks.
setTimeout(fn, ms) runs fn once, after at
least ms milliseconds;
setInterval(fn, ms) runs it repeatedly until you stop
it with clearInterval(id). Both return an ID for
cancelling. They are not part of the language standard itself but
are provided by browsers and Node.js alike.
The callback never interrupts running code. It waits until the
current script has finished and is then started by the
event loop. That is why "after 0 ms" still prints after the last line of the
script. How this works, and what promises and
async/await add, is on
Asynchronous JavaScript.
console.log('Start polling');
setTimeout(() => console.log('Timeout after 0 ms'), 0);
let polls = 0;
const timerId = setInterval(() => {
polls++;
console.log(`Poll ${polls}: meter KE-0042 online`);
if (polls === 3) {
clearInterval(timerId);
console.log('Polling stopped');
}
}, 20);
console.log('End of script');
Start polling End of script Timeout after 0 ms Poll 1: meter KE-0042 online Poll 2: meter KE-0042 online Poll 3: meter KE-0042 online Polling stopped
Recursion
A function that calls itself is recursive. Every recursive function needs a base case that stops the calls, and each call must move toward it. Recursion suits data that is itself nested, such as a tree of grid connections, folders or a JSON document. The theory, including the call stack, is on Recursion.
// A substation feeds feeders, which feed streets, which have customers
const grid = {
name: 'Substation South',
children: [
{ name: 'Feeder 1', children: [{ name: 'Oude Delft', customers: 120 }, { name: 'Markt', customers: 85 }] },
{ name: 'Feeder 2', children: [{ name: 'Phoenixstraat', customers: 207 }] },
],
};
function countCustomers(node) {
if (!node.children) { // base case: a street
return node.customers;
}
let total = 0;
for (const child of node.children) {
total += countCustomers(child); // recursive case: same question, smaller part
}
return total;
}
console.log(countCustomers(grid));
console.log(countCustomers(grid.children[0]));
412 205
Without a reachable base case, the calls never end. Each call takes space on the call stack, and the engine stops the program when the stack is full:
function countdown(n) {
return countdown(n - 1); // no base case
}
countdown(3);
RangeError: Maximum call stack size exceeded
Apart from Safari's JavaScriptCore, which optimizes tail calls in strict mode, engines do not optimize deep recursion away. In Node.js with its default settings, a very simple function reaches the limit after roughly 10,000 nested calls; the exact number depends on the engine and on how much each call stores. For very deep data, or a long chain of steps, a loop is the safer choice.