Learn javascript, with practice after every lesson
10 lessons, about 162 minutes of reading, and 30 multiple-choice questions. Each lesson names the mistake that most often costs people the interview, because that is where the hours actually go. Part of the free coding course.
Variables and the browser
BasicsJavaScript runs inside every web browser, which makes it the only language where you can see the result of your code on a real page within minutes. That immediate feedback is why many people find it the most motivating first language β you change a line, refresh, and something visibly moves.
Use let for a value that will change and const for one that will not. var still works but belongs to older code and behaves in ways that surprise people, so there is no reason to start with it. The distinction that confuses everyone once: const prevents reassignment, not mutation. You cannot point the name at a different array, but you can absolutely push items into the one it already points at.
The equality rule is worth learning on day one rather than discovering later. == converts types before comparing, so "5" == 5 is true, "" == 0 is true, and null == undefined is true. Every one of those has caused a production bug somewhere. Use === unless you have a specific reason not to, and the whole category disappears.
Syntax
const name = "Priya";
let visits = 0;
visits = visits + 1;
console.log(`${name} has ${visits} visit`);
// const protects the binding, not the contents:
const scores = [1, 2];
scores.push(3); // fine
// scores = [4]; // TypeError
Key points
- const prevents reassignment, not mutation β you can still push to a const array.
- Template literals use backticks and ${...}, not quotes.
- console.log is how you inspect values; open DevTools with F12 to see the output.
Practice challenge
Use classList.add() to add a class.
<div id="card" class="highlight">...</div>
Show a hint
- Use .classList.add()
- Pass class name as string
- No dot before class name
Check yourself
1. Which declares a value that will be reassigned?
Show answer
B. let
2. What does "5" === 5 evaluate to?
Show answer
B. false
3. Can you push to a const array?
Show answer
A. Yes β const blocks reassignment only
Arrays: map, filter, reduce
Working levelThree array methods cover the overwhelming majority of day-to-day JavaScript. map transforms every item, filter keeps the items matching a condition, and reduce collapses a list into a single value. Chained together they express most data handling you will do in a browser, in a form that reads top to bottom.
Reaching for these instead of a manual for loop is one of the clearest signals in an interview that you have written real JavaScript rather than only studied it. It is not about elegance: map and filter return new arrays and leave the original untouched, which prevents an entire class of bug where a function quietly modifies data its caller still needs.
The detail people miss is reduce's second argument. reduce((sum, x) => sum + x, 0) starts at zero; without that 0, an empty array throws rather than returning zero. Since empty arrays are exactly what you get on a quiet day or a filtered-down dataset, omitting it produces a crash that only appears in the situation you least tested.
Syntax
const orders = [
{ item: "Desk", price: 8000, paid: true },
{ item: "Chair", price: 4500, paid: false },
{ item: "Lamp", price: 1200, paid: true }
];
const names = orders.map(o => o.item);
const unpaid = orders.filter(o => !o.paid);
const revenue = orders
.filter(o => o.paid)
.reduce((sum, o) => sum + o.price, 0);
console.log(names, unpaid.length, revenue); // 9200
Key points
- map always returns an array of the same length; filter returns the same or fewer.
- reduce needs a starting value (the 0 above) or an empty array throws.
- These methods return new arrays β the original is untouched, which prevents a whole class of bug.
Practice challenge
Define canDrive(age) that returns true if age>=18, else false. Test with 20.
true
Show a hint
- Use >= comparison
- Return the boolean result
- true and false are keywords in JS
Check yourself
1. What does revenue evaluate to above?
Show answer
B. 9200
2. Which returns an array of the same length as the input?
Show answer
C. map
3. Why pass 0 as reduce's second argument?
Show answer
B. It is the starting value and prevents an empty-array error
Async: promises and await
AdvancedFetching data takes time, and JavaScript does not wait around β it starts the request and carries on. A promise represents a value that will arrive later, and await pauses inside an async function until it does. Everything about network code in the browser rests on this.
This is where most self-taught developers get stuck, and it is heavily tested in interviews precisely because misunderstanding it produces code that appears to work and then fails intermittently under real network conditions. The classic version: a page that works on your fast connection and breaks for users on mobile, because a value was read before it had arrived.
Two specifics are worth memorising. fetch does not reject on 404 or 500 β it resolves, and you must check res.ok yourself, or you will parse an error page as JSON. And awaiting in a loop runs requests one after another: ten sequential 200ms calls take two seconds, while Promise.all takes 200 milliseconds. Interviewers ask about that difference often.
Syntax
async function loadUser(id) {
try {
const res = await fetch(`/api/users/${id}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const user = await res.json();
return user;
} catch (err) {
console.error("Could not load user:", err.message);
return null;
}
}
// Parallel, not one-after-another:
const [a, b] = await Promise.all([loadUser(1), loadUser(2)]);
Key points
- await only works inside an async function; the function itself returns a promise.
- fetch does not reject on 404 or 500 β you must check res.ok yourself.
- Promise.all runs requests in parallel; awaiting them one by one is needlessly slow.
Practice challenge
Split word into letters, loop through and count "a".
3
Show a hint
- split("") breaks word into letter array
- Count when letter equals "a"
- Increment count each time
Check yourself
1. Does fetch throw an error on an HTTP 404?
Show answer
B. No β you must check res.ok
2. What runs requests in parallel?
Show answer
B. Promise.all
3. Where can you use await?
Show answer
B. Inside an async function
Functions, scope and arrow syntax
BasicsA function packages work you want to repeat. Modern JavaScript uses arrow functions for short ones, which are both briefer and better behaved inside array methods and callbacks, where the older function keyword brings its own value of `this` and causes confusion.
Scope decides where a name is visible. A variable declared with let or const inside braces exists only inside those braces, which prevents a whole class of accidental overwrites. var ignores block scope entirely and leaks out of if statements and loops, which is the main reason it is avoided in new code.
The arrow-function detail that catches people is the implicit return. (x) => x * 2 returns double x; (x) => { x * 2 } returns undefined, because adding braces turns it into a normal function body where you must write return yourself. The two forms look nearly identical and behave completely differently, and it is worth training your eye to spot the braces.
Syntax
function greet(name) {
return `Hello, ${name}`;
}
const greetShort = (name) => `Hello, ${name}`;
const add = (a, b = 0) => a + b;
if (true) {
const inner = "only in here";
}
// console.log(inner); // ReferenceError
console.log(add(2), add(2, 3)); // 2 5
Key points
- An arrow function with no braces returns its expression automatically.
- Default parameters (b = 0) let one function cover several call shapes.
- let and const are block-scoped; var leaks out of the block, which is why it is avoided.
Practice challenge
Define add(a, b) that returns a+b, call it with 10 and 20, and print result.
30
Show a hint
- Use return keyword
- Return a + b
- The console.log will print the returned value
Check yourself
1. What does (x) => { x * 2 } return?
Show answer
B. undefined
2. Which is block-scoped?
Show answer
B. let
3. What does add(2) return given (a, b = 0) => a + b?
Show answer
A. 2
Objects and destructuring
Working levelAn object groups related values under names. Almost everything you receive from an API is an object, so reading from them fluently is unavoidable in real work β and doing it defensively is what separates code that survives a missing field from code that throws on the first unusual record.
Destructuring pulls fields out into variables in one line, and optional chaining (?.) lets you read a nested field that might not exist without the whole expression throwing. user.company?.name gives undefined rather than a TypeError when company is absent, which is the difference between a blank space on the page and a blank page.
The default-value trap is worth knowing precisely. || falls back whenever the left side is falsy, which includes 0 and the empty string β so count || 10 turns a genuine zero into ten. ?? only falls back on null and undefined, which is almost always what you actually meant. Wherever zero is a valid value, || is a bug waiting for the right data.
Syntax
const user = { name: "Priya", role: "Analyst", address: { city: "Pune" } };
const { name, role } = user;
const { city } = user.address;
console.log(name, role, city);
console.log(user.company?.name); // undefined, not a crash
console.log(user.company?.name ?? "β"); // "β"
const updated = { ...user, role: "Senior Analyst" };
Key points
- ?. stops evaluation and yields undefined instead of throwing on a missing parent.
- ?? supplies a fallback only for null/undefined β unlike ||, it keeps 0 and "".
- { ...user, role: x } copies then overrides, leaving the original untouched.
Practice challenge
Print: 1, 2, 3, 4, 5, 6 (runs per over)
1 2 3 4 5 6
Show a hint
- 6 overs total
- Each over number equals runs
- Use a simple counting loop
Check yourself
1. What does user.company?.name give when company is absent?
Show answer
B. undefined
2. Which preserves a valid 0 when defaulting?
Show answer
B. ??
3. What does { ...user, role: "X" } do?
Show answer
B. Copies user, overriding role
Changing the page (DOM)
Working levelThe DOM is the browser's live model of the page. JavaScript reads and changes it, which is how every interactive site works β and being able to demonstrate this is what makes a portfolio project feel real rather than a static mock-up.
The pattern is always the same: select an element, then either change it or listen for an event on it. querySelector takes any CSS selector, so the selectors you already know from styling work here unchanged, which is why CSS and DOM work reinforce each other.
Two safety habits matter more than any technique. Prefer textContent over innerHTML for anything a user typed, because innerHTML executes markup and is the most common route to a cross-site scripting hole in beginner code. And run your script after the elements exist β at the end of body, or with defer β or querySelector returns null and you get 'Cannot read properties of null', which is the single most-asked beginner question about JavaScript.
Syntax
const btn = document.querySelector("#save");
const out = document.querySelector("#status");
btn.addEventListener("click", (e) => {
e.preventDefault();
out.textContent = "Saved at " + new Date().toLocaleTimeString();
out.classList.add("ok");
});
document.querySelectorAll(".row").forEach((row, i) => {
row.textContent = `Row ${i + 1}`;
});
Key points
- querySelector takes any CSS selector: #id, .class, tag.
- Prefer textContent over innerHTML β innerHTML with user input is an XSS hole.
- e.preventDefault() stops a form submitting and reloading the page.
Practice challenge
Loop from 1 to 10 and only print even numbers.
2 4 6 8 10
Show a hint
- A number is even if it divides by 2 with no remainder
- Use i % 2 === 0
- The % operator gives remainder
Check yourself
1. Which is safer for inserting user text?
Show answer
B. textContent
2. Why does querySelector return null?
Show answer
A. The element does not exist yet or the selector is wrong
3. What stops a form reloading the page?
Show answer
B. e.preventDefault()
Coercion, ===, and the comparisons that lie
Working levelJavaScript converts types automatically when you compare or combine values, and that convenience produces its most notorious results. == converts both sides before comparing, so '5' == 5 is true, and so are a handful of pairs nobody wants: 0 == '', null == undefined, and '0' == false. === compares without converting and is what you should use everywhere; the rule is simple enough that 'always ===' costs nothing and removes a whole category of bug.
The + operator is the other half, because it means both addition and string concatenation and decides by looking at its operands. If either side is a string, both become strings, which is why '5' + 3 is '53' while '5' - 3 is 2 β minus has no string meaning so both sides become numbers. Values arriving from a form input, a URL parameter or JSON are strings, so a total computed with + silently concatenates and produces something that looks almost right, like '1500' from 15 and 00.
Two more are worth committing to memory. NaN is not equal to itself, so x === NaN is always false and Number.isNaN(x) is the only reliable check. And numbers are floating point, so 0.1 + 0.2 is 0.30000000000000004 β not a JavaScript flaw but the binary representation every language shares, which is why money should be held in the smallest unit as an integer, in paise or cents, rather than as a decimal you round at the end and hope.
Syntax
// == converts, === does not. Always ===.
'5' == 5 // true
'5' === 5 // false
0 == '' // true
0 == '0' // true
'' == '0' // false <- not even transitive
null == undefined // true
null === undefined// false
// + means addition OR concatenation, decided by the operands
'5' + 3 // '53' -- either side a string: both become strings
'5' - 3 // 2 -- minus has no string meaning
[] + {} // '[object Object]'
// input is ALWAYS a string
const qty = document.querySelector('#qty').value; // '15'
const total = qty + '00'; // '1500' looks plausible
const total2 = Number(qty) * 100; // 1500 correct
// parseInt vs Number
Number('12abc') // NaN -- strict
parseInt('12abc') // 12 -- stops at the first non-digit
Number('') // 0 <- empty string is NOT NaN
// NaN is not equal to itself
NaN === NaN // false
Number.isNaN(x) // the only reliable check
// floating point -- true in every language
0.1 + 0.2 // 0.30000000000000004
0.1 + 0.2 === 0.3 // false
// money: hold the smallest unit as an integer
const paise = 1050; // not 10.50
(paise / 100).toFixed(2); // '10.50' for display only
// falsy values, exactly: false 0 -0 0n '' null undefined NaN
if (count) { } // skipped when count is 0 -- often a bug
if (count !== undefined) { }
Key points
- Use === everywhere. == converts before comparing and produces 0 == '', '0' == false and a comparison that is not even transitive.
- + concatenates if either operand is a string, and every value from an input, URL or JSON is a string. Convert with Number() before arithmetic.
- Money in floating point accumulates error. Hold the smallest unit as an integer and format only for display.
Practice challenge
Use addEventListener to handle click event.
Button clicked!
Show a hint
- Use addEventListener()
- First param is "click"
- Second param is callback function
Check yourself
1. What is '5' + 3?
Show answer
B. '53' β either operand a string makes both strings
2. How do you reliably test whether x is NaN?
Show answer
B. Number.isNaN(x)
3. Why store money as paise or cents in an integer?
Show answer
B. Floating point cannot represent decimal fractions exactly, so errors accumulate
fetch, JSON and handling failure properly
Advancedfetch makes an HTTP request and returns a promise for a Response, and its first surprise is what counts as failure. The promise rejects only on a network-level problem β offline, DNS failure, CORS refusal. A 404 or a 500 is a successful round trip as far as fetch is concerned, so the promise resolves and your catch block never runs. You must check response.ok yourself, and code that does not will happily parse an error page as data and fail somewhere further downstream with a confusing message.
Reading the body is a second async step: response.json() itself returns a promise, which is why the pattern is two awaits. The body can also be read only once, so calling .json() after .text() on the same response throws. When an error response carries a JSON body explaining the problem, read it before throwing so your error message contains the server's reason rather than a bare status code β that difference is what makes a production log useful.
For real use, three things belong around every request. A timeout, because fetch has none by default and a hung request will wait indefinitely β AbortController with a timer provides it. A distinction in your catch between a network failure, an HTTP error and a JSON parse failure, since they need different responses. And never put a secret in the request from browser code: anything the browser can send, a user can read in the network tab, so an API key in client-side fetch is a published key regardless of how the code is bundled.
Syntax
async function loadOrders(customerId) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 8000); // fetch has NO default timeout
try {
const res = await fetch(`/api/customers/${customerId}/orders`, {
headers: { 'Accept': 'application/json' },
signal: controller.signal,
});
// A 404 or 500 DOES NOT reject. You must check.
if (!res.ok) {
let detail = '';
try { detail = (await res.json()).message ?? ''; } catch { /* not JSON */ }
throw new Error(`HTTP ${res.status} ${res.statusText}${detail ? ': ' + detail : ''}`);
}
return await res.json(); // reading the body is a SECOND await
} catch (err) {
if (err.name === 'AbortError') throw new Error('Request timed out');
throw err; // network vs HTTP vs parse: different responses
} finally {
clearTimeout(timer);
}
}
// POST with a JSON body
await fetch('/api/orders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ items, total }), // must be a string
});
// The body can be read ONCE
const res = await fetch(url);
await res.text();
await res.json(); // TypeError: body already read
// NEVER put a secret in browser code. Anything the browser sends,
// a user can read in the network tab. Proxy it through your server.
// parallel, not sequential
const [a, b] = await Promise.all([fetch(u1), fetch(u2)]);
Key points
- fetch rejects only on network failure. A 404 or 500 resolves normally, so check response.ok or you will parse an error page as data.
- Reading the body is a second promise, and it can be read only once. Calling .json() after .text() on the same response throws.
- fetch has no default timeout. Use AbortController with a timer, and never send a secret from browser code β the network tab shows everything.
Practice challenge
Check if email contains @ and . with correct pattern.
true
Show a hint
- Email must have @ symbol
- Email must have . after @
- Use includes() or regex
Check yourself
1. Does fetch reject on a 404?
Show answer
B. No β it resolves; you must check response.ok
2. What happens if you call res.json() after res.text()?
Show answer
B. It throws β the body can only be read once
3. How do you time out a fetch?
Show answer
B. AbortController with a timer β fetch has no default timeout
Closures, `this`, and the callback that loses itself
AdvancedA closure is a function that remembers the variables where it was defined, even after that scope has returned. It is not an advanced feature you occasionally reach for; it is the mechanism behind every callback that uses an outer variable, every event handler, and every function returning a configured function. Once you see that a function carries its birthplace with it, a whole class of behaviour stops being mysterious β including why a counter created by a factory keeps its own private count that nothing outside can reach.
The classic demonstration is a loop creating functions. With var, there is one shared binding for the whole loop, so every function created inside it sees the final value and three handlers all report 3. With let, each iteration gets a fresh binding, and each function closes over its own β which is the single most useful practical reason to have stopped using var. The same shape appears whenever handlers are created in a loop over rows and every one of them acts on the last row.
`this` is a separate mechanism that people merge with closures, and the difference is exactly this: `this` in a regular function is decided by how the function is called, not where it was written. Pass an object's method as a callback and it is called plainly, so `this` is undefined and the method fails on a field that clearly exists. An arrow function has no `this` of its own and takes it from the surrounding scope at definition β which is why arrows fixed the callback problem, and equally why an arrow is the wrong choice for an object method that needs `this` to be the object.
Syntax
// A function remembers where it was defined
function makeCounter() {
let count = 0; // private; nothing outside can reach it
return () => ++count;
}
const next = makeCounter();
next(); next(); // 1, 2
// THE LOOP CLASSIC
for (var i = 0; i < 3; i++) setTimeout(() => console.log(i), 0);
// 3, 3, 3 -- one shared binding, all three see the final value
for (let i = 0; i < 3; i++) setTimeout(() => console.log(i), 0);
// 0, 1, 2 -- a fresh binding per iteration
// `this` is decided by HOW a function is CALLED
const cart = {
items: ['a', 'b'],
count() { return this.items.length; },
};
cart.count(); // 2 -- called ON cart
const fn = cart.count;
fn(); // TypeError: this.items is undefined
setTimeout(cart.count, 100); // same failure: called plainly
// fixes
setTimeout(() => cart.count(), 100); // arrow keeps the surrounding `this`
setTimeout(cart.count.bind(cart), 100);
// ...but an arrow is WRONG for a method that needs `this` to be the object
const broken = {
items: ['a'],
count: () => this.items.length, // `this` is the outer scope, not broken
};
// The React/DOM shape of the same bug
class Row {
constructor(n) { this.n = n; }
handle() { console.log(this.n); }
}
btn.addEventListener('click', row.handle); // `this` is the button
btn.addEventListener('click', () => row.handle()); // correct
Key points
- A closure captures variables, not values. var gives one binding for a whole loop so every function sees the final value; let gives a fresh binding per iteration.
- `this` in a regular function depends on how it is called. Passing a method as a callback detaches it, and `this` becomes undefined.
- An arrow function takes `this` from the surrounding scope, which fixes callbacks and breaks object methods. The right choice depends on which you need.
Practice challenge
Loop through array and create list items.
<li>Anita</li><li>Bhavna</li><li>Chitra</li>
Show a hint
- createElement("li") creates list item
- forEach loops through array
- appendChild adds to parent
Check yourself
1. Why do three setTimeout callbacks in a var loop all print 3?
Show answer
B. var creates one shared binding, so all closures see the final value
2. You pass cart.count to setTimeout and it throws. Why?
Show answer
B. It is called plainly, so `this` is not cart
3. When is an arrow function the wrong choice?
Show answer
B. As an object method that needs `this` to be the object
Modules, npm and shipping a real project
Job-readyModern JavaScript is written in modules: each file has its own scope, states what it exports, and states what it imports. That replaces the era of scripts sharing one global namespace, where load order mattered and any file could overwrite another's variable. Two systems coexist β ES modules with import/export, which is the standard and what to write today, and CommonJS with require/module.exports, which Node used first and which you will still meet constantly in older code and tutorials.
npm is where dependencies come from, and two files describe the state of your project. package.json lists what you asked for, usually as a range like ^4.17.0 meaning 'this version or any compatible newer one'. package-lock.json records exactly what was installed, down to every transitive dependency. The lock file is what makes a build reproducible, so it belongs in version control β and the single most common cause of 'works locally, fails in CI' is a lock file that was not committed, letting CI resolve different versions from the same range.
Adding a dependency is a decision with a cost, not a free action. Each one brings its own dependencies, its own vulnerabilities and its own maintenance risk, so a package that saves ten lines is rarely worth it while one that saves a thousand usually is. Beyond that, the habits that make a project look professional are small: a .gitignore that excludes node_modules and .env from the very first commit, dependencies split from devDependencies so a production install does not pull your test framework, a README stating how to run it, and npm audit run before shipping rather than after.
Syntax
// ES modules -- the standard. Each file has its own scope.
// money.js
export function toPaise(rupees) { return Math.round(rupees * 100); }
export const GST = 0.18;
export default class Invoice { }
// main.js
import Invoice, { toPaise, GST } from './money.js'; // note the extension
import * as money from './money.js';
// CommonJS -- Node's original, still everywhere in older code
const { toPaise } = require('./money.js');
module.exports = { toPaise };
// package.json -- what you ASKED for
{
"type": "module",
"scripts": { "start": "node main.js", "test": "node --test" },
"dependencies": { "date-fns": "^4.1.0" }, // ^ = compatible newer allowed
"devDependencies": { "vitest": "^2.0.0" } // not installed in production
}
// package-lock.json -- what was ACTUALLY installed, exactly,
// including every transitive dependency. COMMIT IT.
// Not committing it is the usual cause of "works locally, fails in CI":
// CI resolves different versions from the same range.
npm ci // install exactly the lock file -- what CI should run
npm install // may update the lock file
npm audit // before shipping, not after
// .gitignore, from the FIRST commit
node_modules/
.env
dist/
// Every dependency carries its own dependencies, vulnerabilities and
// maintenance risk. Saves 10 lines: rarely worth it. Saves 1,000: usually is.
Key points
- Modules give each file its own scope and explicit imports, replacing load-order-dependent globals. Write ES modules; expect to read CommonJS.
- package.json records the range you asked for; package-lock.json records exactly what was installed. Commit the lock file β it is what makes CI match your machine.
- Every dependency brings its own tree, vulnerabilities and maintenance burden. Judge it against how much it actually saves.
Practice challenge
Count occurrences of "potion" in array.
2
Show a hint
- Loop through array
- Count when item === "potion"
- Two potions in the list
Check yourself
1. What does package-lock.json record?
Show answer
B. Exactly what was installed, including transitive dependencies
2. Which command installs exactly the lock file, for CI?
Show answer
B. npm ci
3. What does "^4.1.0" mean in package.json?
Show answer
B. That version or any compatible newer one
Common questions
Do I need any background to start JavaScript?
No. This track begins at its own beginning and assumes nothing, and the first lesson explains what the thing is before showing you any syntax.
How long does the JavaScript track take?
About 162 minutes of reading across 10 lessons, plus the practice challenges and 30 multiple-choice questions, which is where the time actually goes.
Is it free?
Yes, and there is no account. Everything runs in your browser.
More: all 15 tracks · what employers actually ask for · the full syllabus