What are the data types in JavaScript?
There are seven primitive types - string, number, boolean, null, undefined, symbol and bigint - and one non-primitive type, object. Arrays, functions and dates are all objects underneath.
79 questions - Beginner to advanced
JavaScript is where most frontend interviews are won or lost. The topics below repeat in almost every company: scope and closures, how this is decided, the event loop, promises, and prototypes. Each answer stays short and uses everyday words.
The basics that everything else is built on.
There are seven primitive types - string, number, boolean, null, undefined, symbol and bigint - and one non-primitive type, object. Arrays, functions and dates are all objects underneath.
undefined means a value has never been set - a variable you declared but did not assign, or a missing function argument. null means you deliberately set it to "no value". undefined is given by JavaScript, null is given by you.
let a;
console.log(a); // undefined
let b = null;
console.log(b); // null
console.log(typeof a); // "undefined"
console.log(typeof b); // "object" (a very old bug in the language)
=== compares value and type with no conversion. == converts the types first, which produces surprising results. Always use === unless you deliberately want to treat null and undefined as equal.
0 == "0" // true
0 === "0" // false
null == undefined // true
null === undefined // false
NaN === NaN // false - use Number.isNaN()
Exactly eight: false, 0, -0, 0n, "" (empty string), null, undefined and NaN. Everything else is truthy, including "0", "false", [] and {}.
Primitives are copied by value - changing the copy does not touch the original. Objects and arrays are copied by reference - both names point at the same thing in memory, so a change through one name is visible through the other.
let a = 1, b = a; b = 2;
console.log(a); // 1
let x = { n: 1 }, y = x; y.n = 2;
console.log(x.n); // 2
A shallow copy copies only the top level, so nested objects are still shared. Use spread or Object.assign. A deep copy copies everything; use structuredClone, which also handles dates, maps and cycles.
const shallow = { ...original };
const deep = structuredClone(original);
// JSON.parse(JSON.stringify(x)) also works but loses dates, undefined and functions
Numbers are stored as 64-bit floating point in binary, and 0.1 and 0.2 cannot be represented exactly, so a tiny error remains. Compare with a small tolerance, or work in the smallest unit - store money in paise, not rupees.
0.1 + 0.2 // 0.30000000000000004
Math.abs(0.1 + 0.2 - 0.3) < 1e-9 // true
"object", which is not helpful. Use Array.isArray(value) to check for an array.
It is JavaScript automatically converting one type into another - for example turning a number into a string when you use + with a string. It is the reason "5" + 1 gives "51" but "5" - 1 gives 4, because - has no string meaning.
A unique value used as an object key that can never clash with another key, even one with the same description. Libraries use symbols to add hidden behaviour to objects without breaking normal loops.
A Map accepts any type as a key, including objects, remembers insertion order, has a size property and is faster for frequent adding and deleting. A plain object only supports string and symbol keys and inherits properties from its prototype.
A collection of unique values. Adding a value that already exists does nothing, which makes removing duplicates from an array a one-liner.
const unique = [...new Set([1, 2, 2, 3])]; // [1, 2, 3]
The most common source of interview questions.
var is function scoped and can be redeclared, and it is hoisted as undefined. let and const are block scoped, cannot be redeclared in the same scope, and sit in the temporal dead zone until the line runs. const cannot be reassigned, though the contents of a const object can still change.
if (true) {
var a = 1;
let b = 2;
}
console.log(a); // 1
console.log(b); // ReferenceError
Before running code, JavaScript moves declarations to the top of their scope. Function declarations are fully hoisted so you can call them before they appear. var is hoisted but set to undefined. let and const are hoisted too, but you cannot touch them before the declaration line.
The gap between the start of a block and the line where a let or const is declared. Reading the variable in that gap throws a ReferenceError. It exists to catch bugs that var would silently hide.
A closure is a function that remembers the variables from the place where it was created, even after that outer function has finished. It is how JavaScript creates private data.
function counter() {
let count = 0; // private
return function () {
count += 1;
return count;
};
}
const next = counter();
next(); // 1
next(); // 2
Data privacy in a module, remembering configuration in a factory function, and helpers like debounce and throttle that need to keep a timer id between calls. React hooks are also built on closures.
Because var has one single binding shared by every iteration, so by the time the timers run, the loop has finished and all of them read the final value. let creates a fresh binding per iteration, which fixes it.
for (var i = 0; i < 3; i++) setTimeout(() => console.log(i)); // 3 3 3
for (let i = 0; i < 3; i++) setTimeout(() => console.log(i)); // 0 1 2
It means the scope of a variable is decided by where the code is written, not by where the function is called from. Nested functions can read the variables of the functions around them.
An Immediately Invoked Function Expression - a function that runs the moment it is defined. Before modules existed, it was the standard way to avoid putting variables on the global object.
(function () {
const secret = 42; // not visible outside
})();
A declaration starts with the function keyword and is fully hoisted, so it can be called earlier in the file. An expression is assigned to a variable and only exists after that line runs.
By how the function is called, not where it is written. Called as a method, this is the object before the dot. Called on its own in a normal function, this is undefined in strict mode. With new, this is the new object. With call, apply or bind, this is whatever you passed. An arrow function has no own this and simply uses the one from the surrounding scope.
An arrow function has no own this, no arguments object, cannot be used with new, and has no prototype. That makes it perfect for callbacks and wrong for object methods and constructors.
const obj = {
name: "Asha",
normal() { return this.name; }, // "Asha"
arrow: () => this.name, // undefined
};
call and apply both run the function immediately with a chosen this; call takes arguments one by one and apply takes them as an array. bind does not run the function - it returns a new function with this locked in.
function greet(city) { return this.name + " from " + city; }
const user = { name: "Ravi" };
greet.call(user, "Pune");
greet.apply(user, ["Pune"]);
const bound = greet.bind(user);
bound("Pune");
Every object has a hidden link to another object called its prototype. When you read a property, JavaScript looks on the object, then on its prototype, then on that prototype, until it finds it or reaches null. That chain is how inheritance works in JavaScript.
prototype is a property on a constructor function; it becomes the prototype of objects created with new. __proto__ is the actual link on an instance pointing to its prototype object. Use Object.getPrototypeOf() instead of __proto__ in real code.
No, they are syntax sugar over prototypes. A class body is easier to read, adds real private fields with #, and forces you to use new, but underneath it still builds a prototype chain.
class Animal {
#id = 1; // private field
constructor(name) { this.name = name; }
speak() { return this.name + " makes a sound"; }
}
class Dog extends Animal {
speak() { return super.speak() + " (woof)"; }
}
Four steps: it creates an empty object, links that object prototype to the constructor prototype, runs the constructor with this pointing at the new object, and returns the object unless the constructor returns another object.
const stops you from reassigning the variable name. Object.freeze stops you from changing the properties inside the object. Freeze is also shallow - nested objects can still be changed.
?. reads a nested property and returns undefined instead of throwing when something in the middle is null or undefined. ?? gives a fallback only when the left side is null or undefined, unlike || which also fires on 0 and empty string.
const city = user?.address?.city;
const count = input ?? 0; // 0 stays 0
const wrong = input || 0; // 0 becomes 0 even when input is 0 - hides bugs
A short way to pull values out of an array or object into variables, with support for defaults and renaming.
const { name, age = 18, address: { city } = {} } = user;
const [first, ...rest] = list;
They look the same but do opposite things. Rest collects several values into one array or object, usually in a parameter list. Spread expands one array or object into separate values.
function sum(...nums) { return nums.reduce((a, b) => a + b, 0); } // rest
const merged = { ...a, ...b }; // spread
Turning a function with several arguments into a chain of functions that each take one. It lets you fix some arguments early and reuse the result.
const add = (a) => (b) => a + b;
const add5 = add(5);
add5(3); // 8
A function that returns the same output for the same input and changes nothing outside itself - no network calls, no mutation of arguments, no writing to globals. Pure functions are easy to test and safe to reuse.
map returns a new array of the same length with each item transformed. filter returns a new shorter array with only the items that pass a test. forEach returns undefined and is only for side effects.
reduce walks the array and builds a single result. It takes a callback with an accumulator and the current item, plus a starting value. Always pass the starting value, otherwise an empty array throws.
const total = items.reduce((sum, item) => sum + item.price, 0);
const byRole = users.reduce((acc, u) => {
(acc[u.role] ||= []).push(u);
return acc;
}, {});
slice returns a copy of a part of the array and leaves the original alone. splice changes the original array by removing or inserting items and returns what it removed.
find returns the first matching item itself, or undefined. filter returns an array of all matching items, which may be empty.
sort converts items to strings by default, so [10, 9] sorts as [10, 9]. Pass a compare function. Also note that sort changes the original array, so copy it first if that matters. toSorted() returns a new array.
const asc = [...nums].sort((a, b) => a - b);
const byName = [...users].sort((a, b) => a.name.localeCompare(b.name));
Use flat with a depth, or flat(Infinity) for any depth. flatMap does a map and a one-level flat together.
[1, [2, [3, [4]]]].flat(2); // [1, 2, 3, [4]]
[1, [2, [3, [4]]]].flat(Infinity);
for...in loops over the keys of an object, including inherited ones, so it is a poor fit for arrays. for...of loops over the values of anything iterable - arrays, strings, maps, sets.
Build a Map keyed by id and take the values, which keeps the last occurrence and stays O(n).
const unique = [...new Map(list.map((x) => [x.id, x])).values()];
Arrays of the object own enumerable keys, values, and [key, value] pairs. Object.fromEntries turns a pairs array back into an object, which pairs nicely with a filter or a map.
const cleaned = Object.fromEntries(
Object.entries(obj).filter(([, v]) => v != null)
);
push, pop, splice, sort and reverse change the original array. map, filter, slice, concat and the newer toSorted, toReversed and with return a new array. In React and Redux you must use the non-mutating ones.
Expect at least two questions from this section.
Yes, it runs your code on one thread with one call stack. It still feels concurrent because timers, network requests and file work are handled by the browser or Node outside that thread, and the results come back as callbacks.
The call stack runs your code. When it is empty, the event loop first empties the microtask queue - promise callbacks and queueMicrotask - and only then takes one task from the macrotask queue, such as a setTimeout callback or a click handler. Microtasks always run before the next macrotask.
Synchronous code first, then microtasks (promises), then macrotasks (timers). So the order is 1, 4, 3, 2.
console.log(1);
setTimeout(() => console.log(2), 0);
Promise.resolve().then(() => console.log(3));
console.log(4);
// 1, 4, 3, 2
Microtasks are promise callbacks, queueMicrotask and MutationObserver. Macrotasks are setTimeout, setInterval, I/O and UI events. The whole microtask queue is drained after each macrotask, so a promise chain that never ends can block rendering.
No. It schedules the callback as a macrotask, so it runs after the current code and after all pending microtasks. Browsers also clamp nested timers to about 4ms.
An object that represents a value that is not ready yet. It is pending at first and then settles once, either fulfilled with a value or rejected with an error. You react with then, catch and finally.
Callback hell is deeply nested callbacks where each step is indented inside the last, making errors hard to handle. Promises flatten that into a chain, and async/await makes it look like normal sequential code.
all waits for every promise and rejects immediately if any one fails. allSettled waits for all and always succeeds, giving you the status of each. race settles with whichever finishes first, success or failure. any resolves with the first success and only rejects if all fail.
const [user, orders] = await Promise.all([getUser(), getOrders()]);
const results = await Promise.allSettled(tasks);
Syntax sugar over promises. An async function always returns a promise, and await pauses inside that function until the promise settles, without blocking the main thread. Handle failures with try/catch.
async function loadUser(id) {
try {
const res = await fetch("/api/users/" + id);
if (!res.ok) throw new Error("HTTP " + res.status);
return await res.json();
} catch (err) {
console.error(err);
return null;
}
}
Awaiting inside a for loop makes the requests run one after another, which is slow when they are independent. Start them all and await together with Promise.all. Keep the sequential version only when each step needs the previous result.
// slow
for (const id of ids) { await fetchUser(id); }
// fast
await Promise.all(ids.map(fetchUser));
With an AbortController. You pass its signal to fetch and call abort() when the user navigates away or types a new search term.
const controller = new AbortController();
fetch(url, { signal: controller.signal });
controller.abort();
A rejected promise with no catch attached. In the browser it logs an error, and in Node it can crash the process. Always attach a catch or wrap the await in try/catch.
A function written with function* that can pause at each yield and resume later. Calling it returns an iterator. Generators are used for lazy sequences and by libraries like redux-saga.
function* ids() {
let i = 1;
while (true) yield i++;
}
const gen = ids();
gen.next().value; // 1
The Document Object Model is the browser tree of objects built from your HTML. JavaScript reads and changes that tree, and the browser redraws the page to match.
getElementById takes only an id and is slightly faster. querySelector takes any CSS selector and returns the first match, and querySelectorAll returns a static NodeList of all matches.
When you click an element, the event first travels down from the document to the target (capturing), then back up to the document (bubbling). Handlers run in the bubbling phase by default; pass true or { capture: true } to run during capturing.
Instead of adding a listener to every child, you add one listener on the parent and check event.target. It uses less memory and works for elements added later.
list.addEventListener("click", (e) => {
const item = e.target.closest("li");
if (item) console.log(item.dataset.id);
});
preventDefault stops the browser default action, like following a link or submitting a form. stopPropagation stops the event from travelling further to parent handlers. They are unrelated and you sometimes need both.
target is the element that was actually clicked. currentTarget is the element whose listener is running. In event delegation they are usually different.
Debounce waits until the user has stopped for a set time and then runs once - good for a search box. Throttle runs at most once per interval no matter how often the event fires - good for scroll and resize.
function debounce(fn, wait) {
let t;
return (...args) => {
clearTimeout(t);
t = setTimeout(() => fn(...args), wait);
};
}
innerHTML reads or writes HTML and is a security risk with user input. textContent reads all the text including hidden elements and is fast. innerText reads only what is visible and triggers a reflow, so it is slower.
Cross-Origin Resource Sharing. By default a browser blocks a page on one origin from reading a response from another origin. The server must send Access-Control-Allow-Origin headers to permit it. It is a server-side fix, not something you can turn off in your JavaScript.
Cross-Site Scripting is when attacker-controlled text is executed as script on your page. Prevent it by never inserting raw user input with innerHTML, escaping output, using textContent, and adding a Content Security Policy.
ES modules use import and export, are loaded statically so bundlers can tree-shake them, and support top-level await. CommonJS uses require and module.exports, is loaded at runtime and is the older Node format.
A browser API that tells you when an element enters or leaves the viewport, without listening to scroll events. It is the right way to build lazy loading, infinite scroll and scroll animations.
The short puzzles interviewers use to check depth.
[] + {} gives the string "[object Object]" because both are converted to strings. In a console, {} + [] can give 0 because the leading {} is read as an empty block, not an object, and then +[] is 0.
It is a bug from the very first version of JavaScript, where the type tag for objects was 0 and null was represented as an all-zero pointer. It was never fixed because too much code depends on it.
Two objects are only === when they are literally the same object in memory. { a: 1 } === { a: 1 } is false. Compare the contents yourself, or use a helper.
It turns silent mistakes into errors: you cannot create an undeclared global, this is undefined in a plain function call, and duplicate parameter names are illegal. ES modules and class bodies are always strict.
Caching the result of a function against its arguments so repeated calls are instant. It works only for pure functions and costs memory.
function memoize(fn) {
const cache = new Map();
return (n) => {
if (cache.has(n)) return cache.get(n);
const value = fn(n);
cache.set(n, value);
return value;
};
}
Memory that is no longer needed but cannot be freed because something still references it. Common causes: forgotten setInterval, listeners never removed, growing global caches, and closures holding large objects. WeakMap and WeakRef help because they do not stop garbage collection.
Synchronous code runs line by line and each line waits for the previous one. Asynchronous code starts something and continues, then handles the result later through a callback, promise or await.
The call stack keeps track of the function currently running and everything that called it; it holds primitives and references. The heap is the larger unordered memory area where objects live.
A bundler feature that removes exported code nobody imports. It works with static ES module imports, which is one reason to avoid dynamic require in frontend code.
localStorage is readable by any script on the page, so an XSS bug leaks the token. An httpOnly cookie cannot be read by JavaScript but is sent automatically, so it needs CSRF protection. Most teams choose the httpOnly cookie plus a SameSite setting.
CrackInterviewAI listens to the live interview and gives a structured answer on screen for coding, system design, HR and project questions. Download for Windows.