What are the data types in JavaScript?
There are seven primitive types: string, number, boolean, null, undefined, symbol and bigint. There is one non-primitive type: object. Arrays, functions and dates are all objects underneath.
83 questions - Beginner to advanced
JavaScript is where most frontend interviews are won or lost. The same topics repeat in almost every company: scope and closures, how this is decided, the event loop, promises, and prototypes. Each answer below is short and uses everyday words.
Last updated:
The basics that everything else is built on.
There are seven primitive types: string, number, boolean, null, undefined, symbol and bigint. There is one non-primitive type: object. Arrays, functions and dates are all objects underneath.
undefined means a value was never set. You declared a variable but did not give it a value, or a function argument is missing. null means you set it to "no value" on purpose. JavaScript gives you undefined. You give null.
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)
=== checks value and type, with no conversion. == changes the types first, which gives strange results. Always use === unless you want null and undefined to be treated 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 {}.
NaN means Not a Number. You get it when a maths operation cannot give a real number, like Number("abc") or 0/0. Its type is still "number". NaN is not equal to anything, not even itself, so use Number.isNaN() to test it.
Number("abc") // NaN
NaN === NaN // false
Number.isNaN(NaN) // true
Primitives are copied by value. Changing the copy does not touch the original. Objects and arrays are copied by reference. Both names point to the same thing in memory, so a change through one name is seen 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 loops in the data.
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. 0.1 and 0.2 cannot be stored exactly, so a very small error is left behind. 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 does not help. Use Array.isArray(value) to check for an array.
It is JavaScript changing one type into another on its own. For example it turns a number into a string when you use + with a string. That is why "5" + 1 gives "51" but "5" - 1 gives 4, because - has no string meaning.
A Symbol is a unique value used as an object key. It 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. It keeps the insertion order, has a size property, and is faster when you add and delete a lot. A plain object only takes string and symbol keys and also inherits properties from its prototype.
A Set is a collection of unique values. Adding a value that is already there does nothing. That 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, can be declared again, and is hoisted as undefined. let and const are block scoped, cannot be declared again in the same scope, and cannot be used before their line runs. const cannot be given a new value, but 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 your code, JavaScript moves declarations to the top of their scope. Function declarations move fully, so you can call them before they appear. var moves too, but its value is undefined until the line runs. let and const also move, but you cannot use them before their line.
It is 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 hide silently.
A closure is a function that remembers the variables from the place where it was created, even after the outer function has finished. It is how JavaScript keeps data private.
function counter() {
let count = 0; // private
return function () {
count += 1;
return count;
};
}
const next = counter();
next(); // 1
next(); // 2
Keeping data private inside a module. Remembering settings in a factory function. Helpers like debounce and throttle that must keep a timer id between calls. React hooks are also built on closures.
Because var has one single variable shared by every round of the loop. By the time the timers run, the loop is over and they all read the final value. let makes a fresh variable for each round, 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 you wrote the code, not by where the function is called from. A function inside another function can read the outer variables.
IIFE means Immediately Invoked Function Expression. It is a function that runs the moment it is written. Before modules existed, it was the normal way to keep variables off the global object.
(function () {
const secret = 42; // not visible outside
})();
A declaration starts with the function keyword and is hoisted fully, so you can call it earlier in the file. An expression is stored in a variable and exists only after that line runs.
By how the function is called, not by where it is written. Called as a method, this is the object before the dot. Called on its own in strict mode, this is undefined. With new, this is the new object. With call, apply or bind, this is what you passed. An arrow function has no this of its own and uses the one around it.
An arrow function has no this of its own, 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
};
A callback is a function you pass into another function, to be called later. Array methods take callbacks. So do event listeners and timers. The other function decides when to call it.
setTimeout(() => console.log("later"), 1000);
[1, 2, 3].map((n) => n * 2);
A higher-order function is a function that takes a function as an argument, or returns a function, or both. map, filter and reduce are higher-order functions. So is a function that returns a debounced version of another function.
function once(fn) { // takes a function, returns a function
let done = false;
return (...args) => {
if (done) return;
done = true;
return fn(...args);
};
}
call and apply both run the function at once with a this you choose. call takes arguments one by one. apply takes them as an array. bind does not run the function. It returns a new function with this fixed.
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 one, until it finds the property 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 made with new. __proto__ is the real link on an instance that points to its prototype object. In real code use Object.getPrototypeOf() instead of __proto__.
No. They are a nicer way to write prototypes. A class body is easier to read, gives real private fields with #, and forces you to use new. 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 makes an empty object. It links that object to the prototype of the constructor. It runs the constructor with this pointing to the new object. It returns that object, unless the constructor returns another object.
const stops you from giving the variable a new value. Object.freeze stops you from changing the properties inside the object. freeze is also shallow, so nested objects can still change.
?. reads a nested property and gives undefined instead of an error when something in the middle is null or undefined. ?? gives a fallback only when the left side is null or undefined. || also fires on 0 and on an empty string, which hides bugs.
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
It is a short way to pull values out of an array or object into variables. It also supports default values and renaming.
const { name, age = 18, address: { city } = {} } = user;
const [first, ...rest] = list;
They look the same but do the opposite. Rest collects many values into one array or object, usually in a parameter list. Spread opens one array or object out into separate values.
function sum(...nums) { return nums.reduce((a, b) => a + b, 0); } // rest
const merged = { ...a, ...b }; // spread
Currying turns a function with many arguments into a chain of functions that each take one. You can then fix some arguments early and reuse the result.
const add = (a) => (b) => a + b;
const add5 = add(5);
add5(3); // 8
A pure function gives the same output for the same input and changes nothing outside itself. No network calls, no changing its 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 every item changed. 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 one result. It takes a callback with a running total and the current item, plus a starting value. Always pass the starting value, because an empty array without it throws an error.
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 adding items, and returns what it removed.
find returns the first matching item itself, or undefined. filter returns an array of every matching item, which can be empty.
sort turns items into strings by default, so [10, 9] stays [10, 9]. Pass a compare function. sort also changes the original array, so copy it first if that matters. toSorted() returns a new array instead.
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 in one step.
[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 fits arrays badly. for...of loops over the values of anything iterable: arrays, strings, maps and sets.
Build a Map with the id as the key and take its values. It keeps the last one for each id and stays O(n).
const unique = [...new Map(list.map((x) => [x.id, x])).values()];
They return arrays: the own keys, the own values, and [key, value] pairs. Object.fromEntries turns a pairs array back into an object, which works well after 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 like many things happen together, because timers, network calls and file work are handled outside that thread by the browser or Node. The results come back as callbacks.
The call stack runs your code. When the stack is empty, the event loop first empties the microtask queue, which holds promise callbacks and queueMicrotask. Only then it takes one task from the macrotask queue, like a setTimeout callback or a click handler. Microtasks always run before the next macrotask.
Synchronous code runs first. Then microtasks, which are promises. Then macrotasks, which are 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, input and output work, and UI events. The whole microtask queue is emptied after each macrotask, so a promise chain that never ends can block drawing.
No. It puts the callback in the macrotask queue, so it runs after the current code and after all waiting microtasks. Browsers also push nested timers to about 4ms.
setTimeout runs the callback once, after the delay. setInterval runs it again and again with that gap. setInterval does not wait for slow work to finish, so calls can pile up. A safer pattern is setTimeout that schedules itself again at the end.
function poll() {
doWork();
setTimeout(poll, 5000); // next timer starts only after doWork finishes
}
poll();
A Promise is an object for a value that is not ready yet. It starts as pending and then settles once: fulfilled with a value, or rejected with an error. You react to it with then, catch and finally.
Callback hell is callbacks inside callbacks, each step pushed further to the right. Errors become very hard to handle. Promises make that a flat chain, and async/await makes it read like normal step-by-step code.
all waits for all of them and fails at once if any one fails. allSettled waits for all and never fails, giving you the status of each. race settles with whichever finishes first, success or failure. any resolves with the first success and fails only if every one fails.
const [user, orders] = await Promise.all([getUser(), getOrders()]);
const results = await Promise.allSettled(tasks);
It is a nicer way to write promises. An async function always returns a promise. 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;
}
}
await inside a for loop makes the requests run one after another. That is slow when they do not depend on each other. Start them all and wait together with Promise.all. Keep the one-by-one version only when each step needs the result of the last one.
// slow
for (const id of ids) { await fetchUser(id); }
// fast
await Promise.all(ids.map(fetchUser));
Use an AbortController. Pass its signal to fetch, then call abort() when the user leaves the page or types a new search term.
const controller = new AbortController();
fetch(url, { signal: controller.signal });
controller.abort();
It is a rejected promise with no catch on it. The browser logs an error. In Node it can stop the process. Always add a catch, or put the await inside try/catch.
A generator is written with function* and can pause at each yield and start again 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
DOM means Document Object Model. It is the tree of objects the browser builds 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. querySelectorAll returns a static NodeList of all matches.
When you click an element, the event first travels down from the document to that element, which is capturing. Then it travels back up to the document, which is bubbling. Handlers run during bubbling by default. Pass true or { capture: true } to run during capturing.
Instead of a listener on every child, you put one listener on the parent and check event.target. It uses less memory and also 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 default browser action, like following a link or sending a form. stopPropagation stops the event from moving on to parent handlers. They do different jobs, and sometimes you need both.
target is the element that was really clicked. currentTarget is the element whose listener is running now. In event delegation they are usually different.
Debounce waits until the user has stopped for a set time and then runs once. It suits a search box. Throttle runs at most once in each interval, no matter how often the event fires. It suits scroll and resize.
function debounce(fn, wait) {
let t;
return (...args) => {
clearTimeout(t);
t = setTimeout(() => fn(...args), wait);
};
}
innerHTML reads and writes HTML, and it is a security risk with user input. textContent reads all the text, even from hidden elements, and is fast. innerText reads only what is visible and forces a layout, so it is slower.
CORS means Cross-Origin Resource Sharing. By default the browser stops a page on one origin from reading a response from another origin. The server must send Access-Control-Allow-Origin headers to allow it. It is fixed on the server, not in your JavaScript.
XSS means Cross-Site Scripting. It happens when text controlled by an attacker runs as script on your page. Prevent it: never put raw user input into innerHTML, escape output, use textContent, and add a Content Security Policy.
ES modules use import and export. They are read before running, so bundlers can drop unused code, and they support top-level await. CommonJS uses require and module.exports, is loaded while running, and is the older Node format.
It is a browser API that tells you when an element enters or leaves the screen, 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 sides are turned into strings. In a console, {} + [] can give 0, because the {} at the start is read as an empty block, not an object, and then +[] is 0.
It is a bug from the first version of JavaScript. The type tag for objects was 0, and null was stored as an all-zero pointer. It was never fixed because too much code now depends on it.
Two objects are === only when they are the same object in memory. So { a: 1 } === { a: 1 } is false. To compare the contents you must check them yourself or use a helper.
It turns silent mistakes into errors. You cannot create a global by accident. this is undefined in a plain function call. Duplicate parameter names are not allowed. ES modules and class bodies are always strict.
Memoization saves the result of a function against its arguments, so the same call again is instant. It works only for pure functions and it uses extra 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;
};
}
It is memory you no longer need but cannot free, because something still points to it. Common causes: a setInterval you forgot to clear, listeners never removed, global caches that keep growing, and closures holding big objects. WeakMap and WeakRef help, because they do not block garbage collection.
Synchronous code runs line by line, and each line waits for the one before it. Asynchronous code starts a job and moves on, then handles the result later through a callback, a promise or await.
The call stack tracks the function running now and everything that called it. It holds primitives and references. The heap is the large, unordered memory area where objects live.
Tree shaking is a bundler feature that removes exported code nobody imports. It needs static ES module imports, which is one reason to avoid dynamic require in frontend code.
localStorage can be read by any script on the page, so one XSS bug leaks the token. An httpOnly cookie cannot be read by JavaScript, but it is sent on its own, so it needs CSRF protection. Most teams pick the httpOnly cookie with 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.