... syntax for opposite jobs;arguments object.Learn JS Series):Exercise 1 - a delay with a callback:
function delay(ms, callback) {
setTimeout(() => callback("done waiting"), ms);
}
delay(500, (msg) => console.log(msg));
console.log("meanwhile");
// prints "meanwhile" first, then "done waiting" after ~500ms
The insight: setTimeout schedules the callback for later and returns immediately, so "meanwhile" runs before the callback fires.
Exercise 2 - an error-first divide:
function divide(a, b, callback) {
setTimeout(() => {
if (b === 0) return callback(new Error("cannot divide by zero"), null);
callback(null, a / b);
}, 50);
}
divide(10, 2, (err, result) => {
if (err) return console.error(err.message);
console.log(result); // 5
});
The insight: the callback receives the error first; the caller checks it before touching the result.
Exercise 3 - nesting, and the flat future:
divide(10, 2, (err, half) => {
if (err) return console.error(err.message);
divide(half, 5, (err, result) => {
if (err) return console.error(err.message);
console.log(result); // 1
});
});
// flat version (Phase 6): const half = await divide(10,2); const r = await divide(half,5);
The insight: each dependent step adds a level of nesting and another if (err) return; async/await will collapse it into a straight line.
Now let's make function signatures flexible with three related tools. All three are small, all three you will use constantly, and two of them share a symbol that trips up almost every newcomer at least once. By the end of this episode that confusion should be gone for good.
We met defaults briefly in episode 9. Let's go further, because there is more nuance here than the one-line version suggests. A default value kicks in only when the argument is undefined -- either because it was omitted entirely, or because undefined was passed explicitly. It is NOT used for other "empty-looking" values like 0, "", null, or false. That distinction matters a great deal in practice:
function connect(host, port = 8080, secure = false) {
return `${secure ? "https" : "http"}://${host}:${port}`;
}
console.log(connect("example.com")); // "http://example.com:8080"
console.log(connect("example.com", 443, true)); // "https://example.com:443"
console.log(connect("example.com", undefined, true)); // port defaults, secure true
Notice that last call. Passing undefined explicitly triggers the default, which is precisely how you "skip" a middle argument to reach a later one you do care about. If you had passed null there instead, the default would NOT apply -- port would become the string "null" in the template, which is almost certainly a bug. This is why defaults and null are a classic mismatch: a default answers "no value was given", while null is an explicit value meaning "intentionally nothing". They are not the same thing, and JavaScript treats them differently on purpose.
A genuinely powerful detail is that a default can be any expression, not just a constant, and that expression can reference earlier parameters. It is also re-evaluated fresh on every call, so it can depend on live arguments rather than a value baked in once:
function makeRange(start, end = start + 10, step = 1) {
const result = [];
for (let i = start; i < end; i += step) result.push(i);
return result;
}
console.log(makeRange(0)); // [0..9] end defaulted to start + 10
console.log(makeRange(5, 8)); // [5, 6, 7]
console.log(makeRange(0, 10, 2)); // [0, 2, 4, 6, 8]
Here end = start + 10 computes from the earlier start parameter, so the default adapts to the other arguments instead of being a fixed number. The ordering rule is strict, though: a default may only look at parameters declared before it, never after. function f(a = b, b) throws, because b is still in its temporal dead zone (episode 10) when a's default runs. Read left to right, each default sees only what came earlier -- exactly the mental model you already built for scope.
One more practical use: a default expression can call a function, which is handy for "required argument" guards. If you want to make an argument mandatory and fail loudly when it is missing, you can default it to a function call that throws:
function required(name) {
throw new Error(`missing argument: ${name}`);
}
function saveUser(id = required("id"), name = "anonymous") {
return `${id}:${name}`;
}
console.log(saveUser(7)); // "7:anonymous"
// saveUser(); // throws: missing argument: id
Because the default is only evaluated when the argument is undefined, required("id") runs only when id was left out. Call saveUser(7) and the guard never fires. That is a neat little trick that falls straight out of "defaults are expressions", and it shows how much these small features compose.
Sometimes a function should accept any number of arguments -- think of Math.max, or a logging helper, or a function that sums a list. The rest parameter, a parameter prefixed with ..., collects all the remaining arguments into a real, honest-to-goodness array. It must be the last parameter in the list (nothing may come after it, since it scoops up "the rest"):
function sum(...numbers) {
return numbers.reduce((total, n) => total + n, 0);
}
console.log(sum(1, 2, 3)); // 6
console.log(sum(10, 20, 30, 40)); // 100
console.log(sum()); // 0 - no args, empty array
The key word there is real. numbers is a genuine Array, so every array method from episode 11 works on it directly -- reduce, map, filter, sort, length, the lot. No conversion, no ceremony. You can also mix fixed parameters up front and gather everything after them:
function tagList(tagName, ...items) {
return items.map((item) => `${tagName}: ${item}`);
}
console.log(tagList("fruit", "apple", "pear", "plum"));
// ["fruit: apple", "fruit: pear", "fruit: plum"]
tagName takes the first argument; ...items scoops up everything after it into an array. This is the clean, modern way to write variadic functions (functions that take a variable number of arguments), and it reads exactly like it behaves. Contrast that with the pre-2015 world, where writing a variadic function meant reaching for a clumsy special object we will meet in a moment. The rest parameter made the whole thing obvious at a glance.
There is a subtle but important point here worth stating plainly: rest gathers only the arguments that were actually passed. Call tagList("fruit") with no items and items is simply [], an empty array -- not undefined, not an error. That means the array methods still work without any guard, which is a big part of why rest is so pleasant to work with in real code.
The spread operator uses the exact same three-dot ... syntax, but it does the opposite job. Instead of gathering many values into one array, it takes one array (or any iterable) and spreads it out into individual values. In a function call, that means it expands an array into separate positional arguments:
const nums = [5, 2, 9, 1];
console.log(Math.max(...nums)); // 9 - same as Math.max(5, 2, 9, 1)
function greet(first, second, third) {
return `${first}, ${second}, and ${third}`;
}
const people = ["scipio", "alice", "bob"];
console.log(greet(...people)); // "scipio, alice, and bob"
Math.max expects separate number arguments, not an array -- hand it an array and you get NaN, because it tries to coerce the whole array to a number. So ...nums spreads the array into the individual arguments it actually wants. Before spread existed, people wrote genuinely awkward things like Math.max.apply(null, nums) (we will meet apply properly in episode 23) just to feed an array into a function that wanted loose arguments. Spread turned that ugly incantation into three characters, and honestly it is one of the quality-of-life wins that makes modern JS so much nicer to write than the code of a decade ago.
You can also freely mix spread with ordinary arguments, and spread more than once in the same call. The engine just lays everything out in order, left to right:
const middle = [2, 3, 4];
function five(a, b, c, d, e) {
return a + b + c + d + e;
}
console.log(five(1, ...middle, 5)); // 1 + 2 + 3 + 4 + 5 = 15
Here is the one thing you must keep straight, and it is the source of nearly all early confusion with .... The same three dots mean rest when they gather and spread when they expand. Context alone tells them apart. The rule of thumb that never fails: on the receiving side (a parameter list, or the left side of a destructuring assignment) ... gathers many into one array; on the giving side (a function call, or inside an array or object literal) ... explodes one array into many:
function collect(...args) { // REST: gather the incoming arguments
return args; // args is an array
}
const list = [1, 2, 3];
console.log(collect(...list)); // SPREAD: expand the array into 3 arguments
// spread turns [1,2,3] into collect(1, 2, 3), rest gathers them back into [1,2,3]
Read that last example slowly, because it is the whole idea in miniature. We spread on the way in (collect(...list) becomes collect(1, 2, 3)), and inside the function we gather on the way out (...args collects those three loose values back into [1, 2, 3]). The very same three dots, doing mirror-image work depending on which side of the function boundary they sit. Once you see that symmetry -- giving side spreads, receiving side gathers -- both stop feeling like two features to memorise and start feeling like one idea with two directions.
Spread is not just for function calls. It also builds new arrays and objects by expanding existing ones, which we touched on back in episodes 11 and 12. This is the modern, everyday way to copy and merge without mutating the original:
const base = [1, 2, 3];
const more = [...base, 4, 5]; // [1, 2, 3, 4, 5]
const copy = [...base]; // a shallow copy, independent array
const defaults = { theme: "dark", size: 14 };
const custom = { ...defaults, size: 16 }; // { theme: 'dark', size: 16 }
console.log(more, custom);
For arrays, [...base, 4, 5] reads as "everything from base, then 4 and 5". For objects, { ...defaults, size: 16 } reads as "everything from defaults, but override size" -- because when a key appears twice, the later one wins. That single line is how you make a modified copy of a config object without touching the original, a non-mutating habit we keep reinforcing throughout this series because it prevents a whole category of "who changed my data?" bugs.
One honest caveat you must internalise now, so it does not bite you later: spread makes a shallow copy. The top level is fresh, but any nested objects or arrays are shared by reference with the original, not duplicated:
const original = { name: "app", nested: { count: 1 } };
const shallow = { ...original };
shallow.name = "renamed"; // safe: top-level string, independent
shallow.nested.count = 99; // DANGER: mutates original.nested too!
console.log(original.nested.count); // 99 <-- the shared inner object changed
Changing shallow.name is safe because strings sit at the top level. But shallow.nested and original.nested are the same object in memory, so writing through one is visible through the other. This shallow-versus-deep distinction is important enough that we devote real attention to it in Phase 3; for now, just carry the awareness that ... copies one level deep, no more.
Before rest parameters arrived in ES2015, the only way to accept an arbitrary number of arguments was a strange, clunky, semi-magical object called arguments, automatically available inside every regular (non-arrow) function. It looked like an array -- it had indexes and a length -- but it was not one. No map, no reduce, no filter. To do anything useful you first had to convert it into a real array, which was its own little dance:
// the OLD way, avoid it:
function oldSum() {
// 'arguments' is array-LIKE, not a real array
return Array.from(arguments).reduce((a, b) => a + b, 0);
}
// the modern way, use this:
function newSum(...nums) {
return nums.reduce((a, b) => a + b, 0); // nums IS a real array
}
console.log(oldSum(1, 2, 3), newSum(1, 2, 3)); // 6 6
Rest parameters beat arguments on every axis that matters. First, rest is explicit in the signature: you can see from the parameter list that the function is variadic, whereas arguments is invisible until you read the body. Second, rest gives you a real array immediately, no Array.from conversion needed. Third -- and this one is decisive -- arrow functions do NOT have an arguments object at all (an arrow inherits arguments from its enclosing scope, just like it inherits this, which we will unpack in episode 16's sequel on this), so if you are writing arrow-style code, rest is your only option anyway. In modern JavaScript there is essentially never a reason to reach for arguments; treat it as a historical curiosity you can recognise in old code but never write yourself.
Many of you arrived here from the Learn Python Series, and a few from Rust and Go, so a look sideways helps place these three tools. The good news: the ideas are close to universal. Almost every modern language has some way to give parameters defaults, to gather extra arguments, and to unpack a collection into arguments. Only the spelling changes.
Python is strikingly close, and if you know it well the mapping is almost one-to-one. Python has default parameters, *args to gather positional arguments into a tuple (JavaScript's rest), and * at the call site to unpack an iterable into arguments (JavaScript's spread). It even adds **kwargs for keyword arguments, which JS approximates with an options object instead:
def sum_all(*numbers): # *numbers is Python's rest parameter
return sum(numbers)
nums = [5, 2, 9, 1]
print(sum_all(*nums)) # 17 -- * unpacks the list (spread)
print(max(*nums)) # 9
def connect(host, port=8080, secure=False): # defaults, just like JS
return f"{'https' if secure else 'http'}://{host}:{port}"
print(connect("example.com")) # http://example.com:8080
One sharp difference worth flagging: Python's default values are evaluated once, when the function is defined, not fresh on every call. That is the infamous "mutable default argument" trap (def f(x=[]) shares one list across all calls). JavaScript sidesteps that entirely, because a JS default expression runs anew on each call -- so function f(x = []) gives every call its own fresh array. A small design choice, a big difference in behaviour.
Rust has variadic support that is more restrained, in keeping with its "be explicit" philosophy. It does not have arbitrary variadic functions for your own code (macros like println! handle the variadic-looking cases), but it does have spread-like unpacking through pattern matching and the .. rest pattern in destructuring, and slices let you accept "many values" cleanly:
fn sum(numbers: &[i32]) -> i32 { // take a slice: any number of values
numbers.iter().sum()
}
fn main() {
let nums = [5, 2, 9, 1];
println!("{}", sum(&nums)); // 17
let (first, rest) = nums.split_first().unwrap();
println!("{} then {:?}", first, rest); // 5 then [2, 9, 1]
}
The recurring Rust trade-off shows up here too: you pass an explicit slice rather than a loose pile of arguments, which is a touch more ceremony at the call site but gives the compiler full knowledge of the types. Different priorities, same underlying need.
Go takes the middle road with its variadic ...T parameter, which is remarkably close to JavaScript's rest in feel, and it can spread a slice into a variadic call with a trailing ... -- the same three dots doing the "expand" job:
package main
import "fmt"
func sum(numbers ...int) int { // variadic: gather into a slice
total := 0
for _, n := range numbers {
total += n
}
return total
}
func main() {
fmt.Println(sum(1, 2, 3)) // 6
nums := []int{10, 20, 30}
fmt.Println(sum(nums...)) // 60 -- slice spread into the call
}
Notice Go even reuses the ... symbol for both directions, exactly like JavaScript: numbers ...int gathers, nums... spreads. Three languages, three flavours of the same trio -- defaults, gather, unpack -- which tells you these are not JavaScript quirks but genuine, cross-cutting tools for shaping function signatures. Learn them once and you will recognise them everywhere.
Three exercises, increasing in difficulty. Type them out, run them, and predict the output before you check it -- that gap between your guess and the result is where the learning actually happens. Full solutions open the next episode.
average(...nums) that returns the mean of any number of arguments, returning 0 when called with none. Test it with average(2, 4, 6) and average().merge(...objects) that spreads any number of objects into one combined object, with later ones overriding earlier ones. Test it with merge({a:1}, {b:2}, {a:9}).const nums = [3, 1, 4, 1, 5, 9], use spread with Math.min and Math.max to print the smallest and largest in one line each. Then explain, in one sentence, how the SAME ... acts as rest inside a function f(...xs) but as spread inside f(...nums).undefined (never for 0, null, false, or ""), and can be expressions that reference earlier parameters and even throw for required-argument guards....name gathers any number of arguments into a real array; it must come last, and it is [] when nothing extra is passed....array expands an array (or any iterable) into individual arguments, elements, or properties.... syntax, mirror-image jobs: rest gathers on the receiving side, spread expands on the giving side.arguments object, which is explicit-in-signature, gives a real array, and works in arrow functions where arguments does not even exist.Next episode we combine functions with destructuring to get clean, self-documenting "named arguments" -- one of the nicest ergonomic patterns in modern JavaScript, and a natural partner to the defaults you just learned.