Arrow Function Syntax, Precisely
An arrow function is a shorter way to write a function expression, added in ES6. The syntax has several optional parts, and knowing exactly which are optional means you stop guessing and stop being surprised.
The full form is (a, b) => { return a + b; }. With exactly one parameter the brackets around it may be dropped, giving n => { ... }. With zero parameters, or two or more, the brackets are required — () is how you write "no parameters". When the body is a single expression, the braces and the word return may both be dropped together, never one without the other, and the value of that expression is returned. That is called an implicit return.
There is one syntax trap and it catches everybody once. () => { name: 'Ananya' } does not return an object. The braces are read as a function body, name: is read as a statement label, and the function returns undefined. Wrap the object in brackets — () => ({ name: 'Ananya' }) — and it does what you meant.
Arrows shine as small callbacks, which is exactly why map, filter and reduce read so well with them. They are a poor choice for a long function body, where a named function declaration communicates intent and puts a useful name into stack traces when something throws. Length is a fair guide: if the body needs more than about three lines, give it a name.
// Full form
const add = (a, b) => {
return a + b;
};
// One parameter: the brackets are optional
const double = n => n * 2;
// No parameters: the brackets are required
const now = () => Date.now();
// Implicit return - drop the braces AND the return together
const square = n => n * n;
console.log(square(5)); // 25
// Returning an object literal needs brackets around it
const brokenUser = () => { name: 'Ananya' };
console.log(brokenUser()); // undefined
const makeUser = () => ({ name: 'Ananya' });
console.log(makeUser()); // { name: 'Ananya' }
// Where arrows genuinely earn their place
const marks = [45, 82, 30, 91];
console.log(marks.filter(m => m >= 40).map(m => m + 5)); // [50, 87, 96] - Arrow functions are always anonymous. Assigning one to a
constgives it a name for debugging purposes, which is one more small reason to name your functions rather than passing long arrows inline.
The this Difference, and Where an Arrow Is Wrong
The shorter syntax is the smaller half of what arrows changed. The important half is that an arrow function has no this of its own at all.
A regular function gets a this decided by how it is called — the rule from Lesson 5. An arrow function never gets one, so when you write this inside an arrow, JavaScript looks outwards to the enclosing scope exactly as it would for any ordinary variable. This is called lexical this, and "lexical" again means decided by where the code is written rather than by how it is called.
That single change fixed the oldest annoyance in the language. A callback inside a method used to lose this completely, so people wrote const self = this; at the top of every method, or attached .bind(this) to every callback they passed. With an arrow the callback simply keeps the surrounding this, and both workarounds disappear. If you read older code, those two patterns are what you are looking at.
The same property makes an arrow the wrong choice in three specific places. As a method on an object literal, because the surrounding scope is not the object. As a constructor with new, which throws is not a constructor. And as a DOM event handler where you wanted this to be the element — use event.currentTarget there instead, which works with either style. Arrows also have no arguments object; use a rest parameter, which is better anyway.
const timer = {
seconds: 0,
// Regular function as a method: this is timer
start() {
// Arrow callback: this is STILL timer, inherited from start()
setTimeout(() => {
this.seconds++;
console.log('seconds:', this.seconds); // 1
}, 100);
},
// Arrow used as a method: this is NOT timer
broken: () => {
console.log(this); // the surrounding scope, not the object
}
};
timer.start();
// What people had to write before arrows existed
const oldTimer = {
seconds: 0,
start: function () {
const self = this; // the classic workaround
setTimeout(function () {
self.seconds++;
}, 100);
}
};
// Arrows cannot be constructors
const Person = (name) => { this.name = name; };
// new Person('Ananya'); // TypeError: Person is not a constructor - The rule in one line: use a regular function when you want
thisto be decided by the call, and an arrow when you want it inherited from where you wrote the code. Methods take the former, callbacks inside them take the latter.
Destructuring Beyond Objects
Lesson 5 introduced object destructuring. The same idea applies to arrays, and the array form has a few uses that the object form cannot cover at all.
Array destructuring is positional: const [first, second] = arr takes elements 0 and 1. Skip an element by leaving its slot empty — const [, , third] = arr. Collect whatever remains with a rest element, which must come last. Defaults work exactly as they do for objects, firing when the element is undefined, which includes the case where the array was simply too short.
Two array patterns are worth committing to memory. Swapping two variables without a temporary is [a, b] = [b, a], which reads far better than three lines with a spare variable. And because Object.entries hands you an array of pairs, for (const [key, value] of Object.entries(obj)) is the clean way to walk an object — a pattern you have now met three times in this course, and one you will use constantly.
Destructuring in a parameter list is where it pays off most. A function that takes an options object can name exactly what it needs, with per-option defaults, so the call site reads as labelled arguments instead of a row of positional values whose meaning nobody can recall. Give the whole parameter a default of {} so that calling the function with no argument does not throw.
const marks = [82, 74, 79, 91];
const [first, second] = marks;
console.log(first, second); // 82 74
const [, , third] = marks; // skip the first two
console.log(third); // 79
const [top, ...rest] = marks;
console.log(top, rest); // 82 [74, 79, 91]
const [a = 0, b = 0, c = 0, d = 0, e = 0] = marks;
console.log(e); // 0 - there was no fifth element
// Swap without a temporary variable
let x = 1;
let y = 2;
[x, y] = [y, x];
console.log(x, y); // 2 1
// Object entries, destructured right in the loop header
const scores = { maths: 82, physics: 74 };
for (const [subject, score] of Object.entries(scores)) {
console.log(`${subject}: ${score}`);
}
// Named options with defaults, and a default for the whole parameter
function createReport({ title = 'Report', showTotals = true, rows = [] } = {}) {
console.log(title, showTotals, rows.length);
}
createReport(); // 'Report' true 0
createReport({ title: 'Term 1', rows: marks }); // 'Term 1' true 4 - Destructuring a function's return value is how you unpack several results at once:
const [min, max] = getRange(). Returning an array makes the order meaningful; returning an object and destructuring by name is usually safer, because a caller cannot get two values the wrong way round.
Spread and Rest: the Same Three Dots, Two Jobs
The three dots do two opposite jobs, and which one you get depends entirely on where you write them. In a parameter list, or on the left of an assignment, they gather — that is rest. Anywhere else they spread out — that is spread. Once you see it as "gathering on the left, spreading on the right", the two stop blurring together.
Spread copies an array or object one level deep. [...a, ...b] concatenates, { ...defaults, ...overrides } merges with later keys winning, and Math.max(...marks) turns an array into a list of separate arguments. Between them, those three replace concat, Object.assign and apply in almost all everyday code, and they read better than any of them.
It is shallow, exactly as Lesson 5 warned. Spreading an array of objects gives you a new array holding the very same objects, so editing one of them is visible through both arrays and the bug appears far from the copy that caused it. Use structuredClone, or spread each element as you go with arr.map(o => ({ ...o })).
Rest gathers. In a parameter list, function sum(...numbers) collects every remaining argument into a real array, and it has to be the last parameter. In destructuring, const { id, ...rest } = obj is the tidy way to remove one field without mutating the original — precisely what you want when preparing an object to send to a server, or when stripping a field you must not expose.
const first = [1, 2];
const second = [3, 4];
// Spread: expand into place
console.log([...first, ...second]); // [1, 2, 3, 4]
console.log(Math.max(...first)); // 2
console.log([...'hi']); // ['h', 'i']
const defaults = { theme: 'light', fontSize: 14 };
const chosen = { fontSize: 18 };
console.log({ ...defaults, ...chosen }); // { theme: 'light', fontSize: 18 }
// Shallow: the nested object is still shared
const original = [{ name: 'Ananya' }];
const copy = [...original];
copy[0].name = 'Rahul';
console.log(original[0].name); // 'Rahul'
// Rest: gather up whatever is left
function sum(...numbers) {
return numbers.reduce((total, n) => total + n, 0);
}
console.log(sum(10, 20, 30)); // 60
// Rest in destructuring: drop one field without mutating
const student = { id: 7, name: 'Ananya', marks: 82 };
const { id, ...withoutId } = student;
console.log(withoutId); // { name: 'Ananya', marks: 82 }
console.log(student.id); // 7 - the original is untouched - Spread works on anything iterable, not only arrays.
[...new Set(marks)]is the shortest way to remove duplicates from an array, because aSetkeeps each value once and spread turns it back into an array.
Classes, and What They Really Are
ES6 added class syntax, and it is worth being clear about what that is: a cleaner way to write the prototype-based object system JavaScript already had, not a new one bolted on. A class is still a function underneath — typeof Student is 'function' — and the methods still live on a prototype shared by every instance.
The parts you need are few. constructor runs when you write new. Methods use the shorthand from Lesson 5. extends sets up inheritance and super reaches the parent's version, and super(...) must be called before you touch this in a subclass constructor. Fields can be declared directly in the class body, and a # prefix makes one genuinely private — unreachable from outside, not merely marked with an underscore and a hope.
Two rules follow directly from the this discussion above. Methods lose their this when detached, so passing student.describe as a callback breaks; bind it in the constructor, or define the method as a class field holding an arrow function. And a class body always runs in strict mode, so a detached method's this is undefined rather than the global object — which means you get a clear error instead of silently writing properties onto window.
Should you use classes at all? For most page code you do not need them, and plain objects with plain functions are lighter, easier to test and easier to reason about. They earn their place when you have several things of the same kind that each carry their own state and behaviour, and when a library or framework expects them.
let nextId = 1;
class Student {
#id; // genuinely private - not reachable from outside
constructor(name, marks) {
this.#id = nextId++;
this.name = name;
this.marks = marks;
}
get id() { // a getter is read like a property
return this.#id;
}
get grade() {
if (this.marks >= 75) return 'Distinction';
if (this.marks >= 40) return 'Pass';
return 'Fail';
}
describe() {
return `${this.name}: ${this.marks} (${this.grade})`;
}
}
class Topper extends Student {
constructor(name, marks, rank) {
super(name, marks); // must come before any use of this
this.rank = rank;
}
describe() {
return `${super.describe()} - rank ${this.rank}`;
}
}
const a = new Student('Ananya', 82);
console.log(a.id); // 1
console.log(a.grade); // 'Distinction' - no brackets, it is a getter
console.log(a.describe()); // 'Ananya: 82 (Distinction)'
const t = new Topper('Priya', 91, 1);
console.log(t.describe()); // 'Priya: 91 (Distinction) - rank 1'
console.log(t instanceof Student); // true
console.log(typeof Student); // 'function'
// console.log(a.#id); // SyntaxError - private really means private - Class declarations are not hoisted the way function declarations are. Using a class above the line that declares it throws the same temporal-dead-zone error as a
let, so declare your classes before you use them.
Modules: import and export
ES6 modules are how modern JavaScript is organised into files. Before them, every script shared one global namespace and the order of your script tags was part of your program's logic. Now each file has its own scope and states exactly what it provides and what it needs.
There are two kinds of export. A named export is imported by exactly that name, inside braces: export function addGst() {} paired with import { addGst } from './utils.js'. A default export is one per file and can be imported under any name the importer chooses. Named exports are the better default, because the name is fixed on both sides and every editor can then rename and find usages reliably.
Three practical points. In a browser you must write <script type="module" src="app.js">, and module scripts are deferred automatically, so the timing problem from Lesson 15 simply goes away. Relative import paths need the file extension — './utils.js', not './utils' — which surprises anyone arriving from a bundler-based project. And modules always run in strict mode, so a stray undeclared assignment throws instead of quietly creating a global.
Imports are hoisted and resolved before the rest of the file runs, so a module's imports are always ready by the time your code executes. When you need something loaded conditionally or only on demand, import('./heavy.js') written as a function call returns a promise — the subject of the next few lessons — and is how you avoid downloading code that most users will never need.
// ---- utils.js ----
export const GST_RATE = 0.18;
export function addGst(amount) {
return amount * (1 + GST_RATE);
}
export default function formatRupees(amount) {
return `Rs ${amount.toFixed(2)}`;
}
// ---- app.js ----
import formatRupees, { addGst, GST_RATE } from './utils.js';
console.log(GST_RATE); // 0.18
console.log(formatRupees(addGst(1000))); // 'Rs 1180.00'
// Rename on import when two modules export the same name
// import { addGst as addTax } from './utils.js';
// Bring everything in under one name
// import * as utils from './utils.js';
// Lazy loading: downloaded only when this line actually runs
// const module = await import('./heavy.js'); n => n * 2— one parameter, one expression, implicit return() => ({ a: 1 })— brackets are required to return an object literal- An arrow has no
this, noarguments, and cannot be used withnew - Regular function for methods; arrow for the callbacks inside them
[a, b] = [b, a]— swap two variables with no temporaryconst { id, ...rest } = obj— remove one field without mutating- Spread is shallow — nested objects stay shared between the copies
exportandimportwithtype="module"; relative paths need the extension
- Two files that import each other is a circular dependency. It sometimes works and often leaves you with an
undefinedimport that is very hard to explain. If you hit one, the usual fix is to move the shared piece into a third module that both can import.
