Learn JS Series (#20) - Default, Rest, and Spread: Flexible Function Signatures
Published on HivePostify by @scipio · Wed Sep 02 2026
Learn JS Series (#20) - Default, Rest, and Spread: Flexible Function Signatures
What will I learn - You will learn default parameters in depth, including defaults computed from earlier parameters; - the rest parameter, which gathers any number of arguments into a real array; - the spread operator, which expands an array into individual arguments; - how rest and spread use the same ... syntax for opposite jobs; - why these features replaced the old, awkward arguments object.
Requirements - A working modern computer running macOS, Windows or Ubuntu; - An installed Node.js (20+) distribution, or just a modern browser console; - Episodes 1-19 read, especially functions and arrays.
Difficulty - Beginner
Curriculum (of the Learn JS Series): - [Learn JS Series (#1) - What Is JavaScript, Why It Runs Everywhere, and How to Run It](https://hive.blog/hive-196387/@scipio/learn-js-series-1-what-is-javascript-why-it-runs-everywhere-and-how-to-run-it) - [Learn JS Series (#2) - Variables and Bindings](https://hive.blog/hive-196387/@scipio/learn-js-series-2-variables-and-bindings) - [Learn JS Series (#3) - The Primitive Types: number, string, boolean, null, undefined, symbol, bigint](https://hive.blog/hive-196387/@scipio/learn-js-series-3-the-primitive-types-number-string-boolean-null-undefined-symbol-bigint) - [Learn JS Series (#4) - Operators and Expressions: Arithmetic, Comparison, Logical, and Short-Circuiting](https://hive.blog/hive-196387/@scipio/learn-js-series-4-operators-and-expressions-arithmetic-comparison-logical-and-short-circuiting) - [Learn JS Series (#5) - Strings: Template Literals, Unicode, and the Methods You Actually Use](https://hive.blog/hive-196387/@scipio/learn-js-series-5-strings-template-literals-unicode-and-the-methods-you-actually-use) - [Learn JS Series (#6) - Numbers: IEEE 754, Why 0.1 + 0.2 Is Not 0.3, and How to Cope](https://hive.blog/hive-196387/@scipio/learn-js-series-6-numbers-ieee-754-why-01-02-is-not-03-and-how-to-cope) - [Learn JS Series (#7) - Control Flow: if/else, switch, and the Ternary Expression](https://hive.blog/hive-196387/@scipio/learn-js-series-7-control-flow-ifelse-switch-and-the-ternary-expression) - [Learn JS Series (#8) - Loops: for, while, for...of, for...in, and When to Use Which](https://hive.blog/hive-196387/@scipio/learn-js-series-8-loops-for-while-forof-forin-and-when-to-use-which) - [Learn JS Series (#9) - Functions: Declarations, Parameters, Return Values, and Hoisting](https://hive.blog/hive-196387/@scipio/learn-js-series-9-functions-declarations-parameters-return-values-and-hoisting) - [Learn JS Series (#10) - Scope and the Temporal Dead Zone: How JavaScript Finds Your Variables](https://hive.blog/hive-196387/@scipio/learn-js-series-10-scope-and-the-temporal-dead-zone-how-javascript-finds-your-variables) - [Learn JS Series (#11) - Arrays: The Workhorse Data Structure and Its Core Methods](https://hive.blog/hive-196387/@scipio/learn-js-series-11-arrays-the-workhorse-data-structure-and-its-core-methods) - [Learn JS Series (#12) - Objects: Key-Value Data, Dot vs Bracket Access, and Nesting](https://hive.blog/hive-196387/@scipio/learn-js-series-12-objects-key-value-data-dot-vs-bracket-access-and-nesting) - [Learn JS Series (#13) - Truthiness, Equality, and Coercion: == vs === Done Properly](https://hive.blog/hive-196387/@scipio/learn-js-series-13-truthiness-equality-and-coercion-vs-done-properly) - [Learn JS Series (#14) - Mini Project: A Command-Line Tip Calculator](https://hive.blog/hive-196387/@scipio/learn-js-series-14-mini-project-a-command-line-tip-calculator) - [Learn JS Series (#15) - First-Class Functions: Passing, Returning, and Storing Functions](https://hive.blog/hive-196387/@scipio/learn-js-series-15-first-class-functions-passing-returning-and-storing-functions) - [Learn JS Series (#16) - Arrow Functions vs function: Syntax, this, and When Each Wins](https://hive.blog/hive-196387/@scipio/learn-js-series-16-arrow-functions-vs-function-syntax-this-and-when-each-wins) - [Learn JS Series (#17) - Closures: The Single Most Important Idea in JavaScript](https://hive.blog/hive-196387/@scipio/learn-js-series-17-closures-the-single-most-important-idea-in-javascript) - [Learn JS Series (#18) - Higher-Order Functions: Functions That Take or Return Functions](https://hive.blog/hive-196387/@scipio/learn-js-series-18-higher-order-functions-functions-that-take-or-return-functions) - [Learn JS Series (#19) - Callbacks and the Callback Pattern (Before We Reach Promises)](https://hive.blog/hive-196387/@scipio/learn-js-series-19-callbacks-and-the-callback-pattern-before-we-reach-promises) - [Learn JS Series (#20) - Default, Rest, and Spread: Flexible Function Signatures](https://hive.blog/hive-196387/@scipio/learn-js-series-20-default-rest-and-spread-flexible-function-signatures) (this post)
Learn JS Series (#20) - Default, Rest, and Spread: Flexible Function Signatures
Solutions to Episode 19 Exercises
Exercise 1 - a delay with a callback:
js 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:
js 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:
js 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.
Default parameters, revisited and deepened
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:
js 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:
js function makeRange(start, end = start + 10, step = 1) { const result = []; for (let i = start; i 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:
js 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: expanding an array into arguments
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:
js 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:
js 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
Same syntax, opposite jobs
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:
js 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 in arrays and objects too
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:
js 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:
js 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 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.
How other languages handle this
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:
Tags: #stem#stemsocial#steemstem#javascript#programming