Essential JavaScript Interview Questions and Answers
Hey there! 👋 If you're preparing for a JavaScript interview, you've come to the right place. I've compiled a list of the most common JavaScript interview questions I've encountered both as an interviewer and interviewee over the years.
This guide follows my approach from my previous interview preparation articles - Java Interview Questions and Answers and Essential Coding Patterns for Technical Interviews. While those covered Java specifics and algorithmic patterns, this one focuses on JavaScript concepts that frequently appear in technical interviews.
For each question, I'll not only provide the answer but also explain the underlying concepts with clear examples. Let's boost your interview confidence together!
Table of Contents
- Core JavaScript Concepts
- JavaScript Functions
- Closures and Scoping
- Prototypes and Inheritance
- Asynchronous JavaScript
- ES6+ Features
- Common Coding Problems
- Best Practices
Core JavaScript Concepts
What's the difference between null
and undefined
?
let variable; // undefined - variable declared but not assigned
let emptyValue = null; // null - explicitly assigned "no value"
console.log(typeof undefined); // "undefined"
console.log(typeof null); // "object" (this is a historical bug in JS)
console.log(undefined == null); // true - loose equality
console.log(undefined === null); // false - strict equality
undefined
represents a variable that has been declared but not assigned a value yet. It's the default value for uninitialized variables and missing function parameters.
null
is an explicit assignment that represents "no value" or "empty value". It's used when we want to deliberately indicate that a variable has no value.
Note: The fact that typeof null
returns "object"
is actually a bug in JavaScript that has persisted for historical compatibility reasons.
Explain data types in JavaScript
JavaScript has 8 data types:
// Primitive types (stored by value)
let myString = 'Hello'; // String
let myNumber = 42; // Number
let myBigInt = 9007199254740991n; // BigInt (for integers larger than Number can handle)
let myBoolean = true; // Boolean
let myUndefined = undefined; // Undefined
let myNull = null; // Null
let mySymbol = Symbol('unique'); // Symbol (unique identifier)
// Reference type (stored by reference)
let myObject = { name: 'Alex' }; // Object (includes arrays, functions, dates, etc.)
The key distinction is how these types are stored in memory:
- Primitive types are immutable and stored directly in the variable's location.
- Reference types store a reference (pointer) to the location of the actual data.
This distinction becomes clear when comparing or copying values:
// Primitive values are copied by value
let num1 = 5;
let num2 = num1; // Copy the value
num1 = 10;
console.log(num2); // Still 5
// Objects are copied by reference
let obj1 = { name: 'Alex' };
let obj2 = obj1; // Copy the reference
obj1.name = 'Sam';
console.log(obj2.name); // "Sam" - both variables point to the same object
What's the difference between ==
and ===
?
console.log(5 == '5'); // true - values are equal after type conversion
console.log(5 === '5'); // false - values are equal but types are different
console.log(0 == false); // true - both convert to falsy values
console.log(0 === false); // false - different types
==
(Loose equality): Compares values after attempting type conversion===
(Strict equality): Compares both values and types without conversion
Best practice: Always use ===
(strict equality) unless you have a specific reason to use type coercion. It prevents unexpected behaviors and makes your code more predictable.
How does type coercion work in JavaScript?
Type coercion is JavaScript's automatic conversion of values from one data type to another.
// String coercion
console.log('5' + 3); // "53" (number converted to string)
console.log(`Value: ${5}`); // "Value: 5" (template literals coerce to string)
// Number coercion
console.log('5' - 3); // 2 (string converted to number)
console.log('5' * '3'); // 15 (both strings converted to numbers)
console.log(+'5'); // 5 (unary plus converts string to number)
// Boolean coercion
console.log(Boolean('')); // false
console.log(Boolean('hello')); // true
console.log(Boolean(0)); // false
console.log(Boolean(1)); // true
console.log(Boolean({})); // true (all objects are truthy)
// In conditional statements
if ('hello') {
console.log('Strings are truthy!');
}
Falsy values in JavaScript:
false
0
,-0
,0n
(BigInt zero)""
(empty string)null
undefined
NaN
Everything else is truthy!
JavaScript Functions
What are different ways to create functions in JavaScript?
JavaScript offers several ways to define functions, each with different behaviors and use cases:
// 1. Function Declaration
function greet(name) {
return `Hello, ${name}!`;
}
// 2. Function Expression
const sayHello = function (name) {
return `Hello, ${name}!`;
};
// 3. Arrow Function
const welcome = (name) => {
return `Welcome, ${name}!`;
};
// Shorter arrow function (implicit return)
const welcome2 = (name) => `Welcome, ${name}!`;
// 4. Function Constructor (rarely used)
const multiply = new Function('a', 'b', 'return a * b');
// 5. Method in an object
const person = {
name: 'Alex',
greet() {
return `Hi, I'm ${this.name}`;
},
};
// 6. IIFE (Immediately Invoked Function Expression)
(function () {
console.log('I run immediately!');
})();
// 7. Generator Function
function* idGenerator() {
let id = 0;
while (true) {
yield id++;
}
}
Key differences:
-
Hoisting: Function declarations are hoisted completely (you can call them before they're defined). Function expressions are not hoisted (well, the variable is hoisted but not initialized).
-
this
binding: Regular functions have dynamicthis
binding based on how they're called, while arrow functions inheritthis
from their surrounding scope. -
Arguments object: Traditional functions have an
arguments
object, arrow functions don't.
Explain call, apply, and bind methods
These methods allow you to control what this
refers to inside a function:
const person = {
name: 'Alex',
greet(greeting, punctuation) {
return `${greeting}, I'm ${this.name}${punctuation}`;
},
};
const anotherPerson = {
name: 'Sam',
};
// call - invoke function with specified 'this' and comma-separated arguments
console.log(person.greet.call(anotherPerson, 'Hi', '!')); // "Hi, I'm Sam!"
// apply - like call but takes arguments as an array
console.log(person.greet.apply(anotherPerson, ['Hello', '...'])); // "Hello, I'm Sam..."
// bind - returns a new function with 'this' bound permanently
const samGreeter = person.greet.bind(anotherPerson);
console.log(samGreeter('Hey', '.')); // "Hey, I'm Sam."
// You can also partially apply arguments with bind
const samSayHi = person.greet.bind(anotherPerson, 'Hi');
console.log(samSayHi('!!')); // "Hi, I'm Sam!!"
When to use each:
- call: When you want to invoke a function immediately with a different
this
context - apply: When you have your arguments already in an array (or when spreading arguments)
- bind: When you want to create a new function with a fixed
this
context, or partially apply arguments
What are higher-order functions?
Higher-order functions either take functions as arguments or return functions as their result (or both).
// Example 1: Function that accepts a function as an argument
function performOperation(x, y, operation) {
return operation(x, y);
}
const add = (a, b) => a + b;
const multiply = (a, b) => a * b;
console.log(performOperation(5, 3, add)); // 8
console.log(performOperation(5, 3, multiply)); // 15
// Example 2: Function that returns a function
function createMultiplier(factor) {
// Returns a new function
return function (number) {
return number * factor;
};
}
const double = createMultiplier(2);
const triple = createMultiplier(3);
console.log(double(5)); // 10
console.log(triple(5)); // 15
// Example 3: Built-in higher-order functions
const numbers = [1, 2, 3, 4, 5];
// map - takes a function and applies it to each element
const doubled = numbers.map((n) => n * 2);
console.log(doubled); // [2, 4, 6, 8, 10]
// filter - takes a function that returns a boolean
const evens = numbers.filter((n) => n % 2 === 0);
console.log(evens); // [2, 4]
// reduce - uses a function to accumulate a value
const sum = numbers.reduce((total, n) => total + n, 0);
console.log(sum); // 15
Higher-order functions are fundamental to functional programming in JavaScript and make your code more modular and composable.
Closures and Scoping
What is a closure in JavaScript?
A closure is a function that remembers and can access its lexical scope even when the function is executed outside that scope.
function createCounter() {
let count = 0; // Private variable
return function () {
count += 1; // Accessing the variable from parent scope
return count;
};
}
const counter = createCounter();
console.log(counter()); // 1
console.log(counter()); // 2
console.log(counter()); // 3
// Creating a new counter starts fresh
const counter2 = createCounter();
console.log(counter2()); // 1
In this example, the inner function forms a closure over the count
variable from its parent scope. Even after createCounter()
finishes execution, the returned function still maintains access to count
.
Practical uses of closures:
- Data privacy (like the counter example above)
- Function factories
- Maintaining state in async operations
- Implementing modules (pre-ES6)
Here's a more practical example - a function that creates customized HTML element creators:
function createElementCreator(element) {
return function (content, className) {
const el = document.createElement(element);
if (content) el.textContent = content;
if (className) el.className = className;
return el;
};
}
const createDiv = createElementCreator('div');
const createButton = createElementCreator('button');
document.body.appendChild(createDiv('Hello world', 'greeting'));
document.body.appendChild(createButton('Click me', 'btn-primary'));
Explain variable scoping in JavaScript (var, let, const)
JavaScript has three ways to declare variables, each with different scoping rules:
// 1. var - function scoped
function varExample() {
var x = 1;
if (true) {
var x = 2; // Same variable as above
console.log(x); // 2
}
console.log(x); // 2 - the value was modified
}
// 2. let - block scoped
function letExample() {
let y = 1;
if (true) {
let y = 2; // Different variable than outside the block
console.log(y); // 2
}
console.log(y); // 1 - unaffected by inner block
}
// 3. const - block scoped and cannot be reassigned
function constExample() {
const z = 1;
// z = 2; // Error: Assignment to constant variable
// But for objects, the content can be modified:
const obj = { prop: 1 };
obj.prop = 2; // This works
// obj = {}; // Error: Assignment to constant variable
}
// Hoisting behavior
function hoistingExample() {
// Variables are hoisted differently:
console.log(a); // undefined - var is hoisted but not initialized
// console.log(b); // ReferenceError - let is hoisted but in "temporal dead zone"
// console.log(c); // ReferenceError - const is hoisted but in "temporal dead zone"
var a = 1;
let b = 2;
const c = 3;
}
Key differences:
Feature | var | let | const |
---|---|---|---|
Scope | Function | Block | Block |
Hoisting | Yes, initialized as undefined | Yes, but in "temporal dead zone" | Yes, but in "temporal dead zone" |
Reassignment | Yes | Yes | No |
Redeclaration in same scope | Yes | No | No |
Global object property when declared globally | Yes | No | No |
Best practice: Use const
by default, then let
when you need to reassign. Generally avoid var
in modern JavaScript.
Prototypes and Inheritance
How does prototypal inheritance work in JavaScript?
JavaScript uses prototypal inheritance, where objects can inherit properties and methods from other objects through a prototype chain.
// 1. Using Object.create
const person = {
isHuman: true,
printIntro: function () {
console.log(`My name is ${this.name}. Am I human? ${this.isHuman}`);
},
};
const me = Object.create(person);
me.name = 'Alex';
me.printIntro(); // "My name is Alex. Am I human? true"
// 2. Constructor functions with prototypes
function Animal(name) {
this.name = name;
}
Animal.prototype.speak = function () {
console.log(`${this.name} makes a sound`);
};
function Dog(name, breed) {
Animal.call(this, name); // Call parent constructor
this.breed = breed;
}
// Set up inheritance
Dog.prototype = Object.create(Animal.prototype);
Dog.prototype.constructor = Dog; // Fix the constructor property
// Add method to Dog prototype
Dog.prototype.speak = function () {
console.log(`${this.name} barks! I'm a ${this.breed}`);
};
const rex = new Dog('Rex', 'German Shepherd');
rex.speak(); // "Rex barks! I'm a German Shepherd"
// 3. ES6 Classes (syntactic sugar over prototypes)
class AnimalClass {
constructor(name) {
this.name = name;
}
speak() {
console.log(`${this.name} makes a sound`);
}
}
class DogClass extends AnimalClass {
constructor(name, breed) {
super(name); // Call parent constructor
this.breed = breed;
}
speak() {
console.log(`${this.name} barks! I'm a ${this.breed}`);
}
}
const max = new DogClass('Max', 'Golden Retriever');
max.speak(); // "Max barks! I'm a Golden Retriever"
Key aspects of prototypal inheritance:
- Each object has an internal link to another object called its prototype
- When accessing a property, JavaScript first looks on the object itself, then up the prototype chain
- Methods are typically defined on the prototype to save memory (all instances share one copy)
- The chain ends with
Object.prototype
, whose prototype isnull
Note: ES6 classes are just syntactic sugar over JavaScript's prototype-based inheritance. Under the hood, they still use prototypes!
What are the different ways to create objects in JavaScript?
JavaScript offers several ways to create objects:
// 1. Object literal (most common)
const person1 = {
name: 'Alex',
greet() {
return `Hello, I'm ${this.name}`;
},
};
// 2. Constructor function
function Person(name) {
this.name = name;
this.greet = function () {
return `Hello, I'm ${this.name}`;
};
}
const person2 = new Person('Sarah');
// 3. Object.create()
const personProto = {
greet() {
return `Hello, I'm ${this.name}`;
},
};
const person3 = Object.create(personProto);
person3.name = 'Mike';
// 4. ES6 Classes
class PersonClass {
constructor(name) {
this.name = name;
}
greet() {
return `Hello, I'm ${this.name}`;
}
}
const person4 = new PersonClass('Emma');
// 5. Factory function
function createPerson(name) {
return {
name,
greet() {
return `Hello, I'm ${name}`; // Uses closure
},
};
}
const person5 = createPerson('John');
Each approach has its own use cases:
- Object literals are great for one-off objects with unique properties and methods
- Constructor functions and classes are ideal for creating multiple similar objects
- Object.create() provides fine-grained control over object creation and inheritance
- Factory functions are useful when you need encapsulation and don't want to use
this
Asynchronous JavaScript
Explain promises and how they work
Promises are objects representing the eventual completion (or failure) of an asynchronous operation. They improve upon callback-based code by making it more readable and manageable.
// Creating a promise
function fetchData(url) {
return new Promise((resolve, reject) => {
// Simulate an API call
setTimeout(() => {
if (url.includes('success')) {
resolve({ data: 'Success data', url });
} else {
reject(new Error(`Failed to fetch from ${url}`));
}
}, 1000);
});
}
// Using a promise
fetchData('https://api.example.com/success')
.then((result) => {
console.log('Success:', result.data);
return fetchData('https://api.example.com/success2'); // Chain another promise
})
.then((result) => {
console.log('Second success:', result.data);
})
.catch((error) => {
console.error('Error:', error.message);
})
.finally(() => {
console.log('Operation completed (success or failure)');
});
// Promise combinators
// Promise.all - waits for all promises to resolve, or rejects if any reject
Promise.all([
fetchData('https://api.example.com/success1'),
fetchData('https://api.example.com/success2'),
])
.then((results) => {
console.log('All succeeded:', results);
})
.catch((error) => {
console.error('At least one failed:', error.message);
});
// Promise.race - resolves/rejects as soon as the first promise resolves/rejects
Promise.race([
fetchData('https://api.example.com/success'),
new Promise((_, reject) =>
setTimeout(() => reject(new Error('Timeout')), 2000)
),
])
.then((result) => console.log('Race winner:', result))
.catch((error) => console.error('Race error:', error.message));
// Promise.allSettled - waits for all promises to settle (resolve or reject)
Promise.allSettled([
fetchData('https://api.example.com/success'),
fetchData('https://api.example.com/fail'),
]).then((results) => {
// Results will contain status (fulfilled/rejected) and value/reason
console.log('All settled:', results);
});
Promise states:
- Pending: Initial state, neither fulfilled nor rejected
- Fulfilled: The operation completed successfully
- Rejected: The operation failed
Key benefits of promises:
- Chainable: Makes sequential async operations more readable
- Better error handling: One catch for the entire chain
- Composable: Combine multiple async operations in various ways
What is async/await and how does it simplify working with promises?
async/await
is syntactic sugar over promises that makes asynchronous code look and behave more like synchronous code.
// Function that returns a promise
function fetchData(url) {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (url.includes('success')) {
resolve({ data: `Data from ${url}`, url });
} else {
reject(new Error(`Failed to fetch from ${url}`));
}
}, 1000);
});
}
// Using async/await
async function fetchUserData() {
try {
// await pauses execution until the promise resolves
const userData = await fetchData('https://api.example.com/user/success');
console.log('User data:', userData);
// Sequential requests
const postsData = await fetchData('https://api.example.com/posts/success');
console.log('Posts data:', postsData);
return { user: userData, posts: postsData };
} catch (error) {
// Catches any errors in the try block
console.error('Error fetching data:', error.message);
throw error; // Re-throw if needed
}
}
// Remember that async functions always return a promise
fetchUserData()
.then((result) => console.log('All data:', result))
.catch((error) => console.error('Caught outside:', error.message));
// Parallel requests with async/await
async function fetchMultipleInParallel() {
try {
// Start both requests at the same time
const userPromise = fetchData('https://api.example.com/user/success');
const postsPromise = fetchData('https://api.example.com/posts/success');
// Wait for both to complete
const userData = await userPromise;
const postsData = await postsPromise;
return { user: userData, posts: postsData };
} catch (error) {
console.error('Parallel fetch error:', error.message);
throw error;
}
}
// Or more simply with Promise.all
async function fetchMultipleWithPromiseAll() {
try {
const [userData, postsData] = await Promise.all([
fetchData('https://api.example.com/user/success'),
fetchData('https://api.example.com/posts/success'),
]);
return { user: userData, posts: postsData };
} catch (error) {
console.error('Promise.all error:', error.message);
throw error;
}
}
Benefits of async/await:
- Cleaner syntax: No .then() chains
- Better readability: Looks like synchronous code
- Better error handling: Try/catch works as expected
- Easier debugging: More predictable control flow and stack traces
Note: Behind the scenes, async/await
is just promises. An async
function always returns a promise, and await
can only be used inside an async
function.
How does the JavaScript event loop work?
The event loop is the mechanism that allows JavaScript to perform non-blocking operations despite being single-threaded.
Here's a simplified explanation of how the event loop works:
console.log('Start'); // 1. Executes immediately
setTimeout(() => {
console.log('Timeout callback'); // 4. Executes after at least 0ms
}, 0);
Promise.resolve().then(() => {
console.log('Promise callback'); // 3. Executes after current execution completes
});
console.log('End'); // 2. Executes immediately
// Output order:
// Start
// End
// Promise callback
// Timeout callback
The JavaScript runtime maintains several queues:
- Call Stack: Where functions are executed one at a time
- Callback Queue (Task Queue): Where callbacks from async operations (setTimeout, events, etc.) wait
- Microtask Queue: Where promises and certain other callbacks wait (has higher priority)
Event Loop Process:
- Execute code on the call stack until it's empty
- Process all tasks in the microtask queue until it's empty
- Process one task from the callback queue (if available)
- Go back to step 1
Visual representation:
┌─────────────┐
│ Call Stack │
└─────────────┘
↑
│
│
┌─────────────┐ ┌───────────────┐
│ Event Loop │ ← │ Task Queue │
└─────────────┘ └───────────────┘
│
↓
┌─────────────┐
│ Microtask Q │
└─────────────┘
Common sources of tasks and microtasks:
- Tasks: setTimeout, setInterval, requestAnimationFrame, I/O, UI rendering
- Microtasks: Promise callbacks, queueMicrotask(), MutationObserver
This event loop mechanism is what allows JavaScript to handle many operations concurrently despite being single-threaded.
ES6+ Features
What are the major features introduced in ES6 (ECMAScript 2015)?
ES6 was a major update to JavaScript that introduced many new features:
// 1. Arrow Functions
const add = (a, b) => a + b;
// 2. let and const declarations
let variable = 'can be reassigned';
const constant = 'cannot be reassigned';
// 3. Template Literals
const name = 'Alex';
console.log(`Hello, ${name}!`);
// 4. Destructuring
const person = { name: 'Alex', age: 30 };
const { name: personName, age } = person;
const numbers = [1, 2, 3];
const [first, second] = numbers;
// 5. Default Parameters
function greet(name = 'Guest') {
return `Hello, ${name}!`;
}
// 6. Rest and Spread Operators
// Rest - collects multiple elements into an array
function sum(...numbers) {
return numbers.reduce((total, num) => total + num, 0);
}
// Spread - expands an array into individual elements
const nums = [1, 2, 3];
console.log(Math.max(...nums));
// 7. Classes
class Person {
constructor(name) {
this.name = name;
}
greet() {
return `Hello, I'm ${this.name}`;
}
}
// 8. Modules
// In file math.js
export function add(a, b) {
return a + b;
}
// In another file
import { add } from './math.js';
// 9. Promises
const promise = new Promise((resolve, reject) => {
// async code
});
// 10. Map and Set collections
const map = new Map();
map.set('key', 'value');
const set = new Set([1, 2, 3, 3]); // Stores unique values: 1, 2, 3
// 11. Symbol primitive type
const uniqueKey = Symbol('description');
// 12. Iterators and for...of loops
for (const value of [1, 2, 3]) {
console.log(value);
}
// 13. Generators
function* idGenerator() {
let id = 1;
while (true) {
yield id++;
}
}
Subsequent ECMAScript versions added more features:
- ES2016: Array.prototype.includes, Exponentiation operator (**)
- ES2017: async/await, Object.values/entries, String padding
- ES2018: Rest/spread properties, async iteration, Promise.finally
- ES2019: Array.flat, Object.fromEntries, String.trimStart/trimEnd
- ES2020: Optional chaining (?.), Nullish coalescing (??), BigInt
- ES2021: String.replaceAll, Promise.any, Logical assignment operators
- ES2022: Top-level await, Object.hasOwn, Class fields
Explain destructuring in JavaScript
Destructuring allows you to extract multiple values from arrays or objects and assign them to variables in a single statement.
// 1. Object Destructuring
const person = {
name: 'Alex',
age: 30,
location: {
city: 'New York',
country: 'USA',
},
interests: ['programming', 'music'],
};
// Basic destructuring
const { name, age } = person;
console.log(name, age); // "Alex" 30
// Renaming variables
const { name: fullName } = person;
console.log(fullName); // "Alex"
// Default values
const { salary = 50000 } = person;
console.log(salary); // 50000 (default value since it doesn't exist)
// Nested destructuring
const {
location: { city, country },
} = person;
console.log(city, country); // "New York" "USA"
// Rest operator in object destructuring
const { name: personName, ...rest } = person;
console.log(personName); // "Alex"
console.log(rest); // { age: 30, location: {...}, interests: [...] }
// 2. Array Destructuring
const colors = ['red', 'green', 'blue'];
// Basic array destructuring
const [first, second, third] = colors;
console.log(first, second, third); // "red" "green" "blue"
// Skipping elements
const [primary, , tertiary] = colors;
console.log(primary, tertiary); // "red" "blue"
// Default values
const [main, secondary, third1, fourth = 'yellow'] = colors;
console.log(fourth); // "yellow"
// Rest operator in array destructuring
const [head, ...tail] = colors;
console.log(head); // "red"
console.log(tail); // ["green", "blue"]
// Swapping variables
let a = 1;
let b = 2;
[a, b] = [b, a];
console.log(a, b); // 2 1
// Destructuring function returns (returning multiple values)
function getCoordinates() {
return [10, 20];
}
const [x, y] = getCoordinates();
console.log(x, y); // 10 20
// Destructuring in function parameters
function displayPerson({ name, age }) {
console.log(`${name} is ${age} years old`);
}
displayPerson(person); // "Alex is 30 years old"
Destructuring is particularly useful for:
- Making function parameters more readable
- Working with API responses
- Extracting values from complex data structures
- Returning multiple values from functions
ES6+ Features (continued)
What are arrow functions and how do they differ from regular functions?
Arrow functions are a shorter syntax for defining functions in JavaScript:
// Regular function
function add(a, b) {
return a + b;
}
// Arrow function
const add = (a, b) => a + b;
// Arrow function with single parameter (parentheses optional)
const square = (x) => x * x;
// Arrow function with no parameters (parentheses required)
const sayHello = () => 'Hello';
// Arrow function with function body (curly braces required)
const greet = (name) => {
const greeting = `Hello, ${name}!`;
return greeting;
};
Key differences between arrow functions and regular functions:
- Lexical
this
binding: Arrow functions don't have their ownthis
context
// With regular functions, 'this' depends on how the function is called
const user = {
name: 'Alex',
regularFunction: function () {
console.log(this.name); // 'Alex' - 'this' refers to user object
},
arrowFunction: () => {
console.log(this.name); // undefined - 'this' is inherited from surrounding scope
},
};
// Common problem in callbacks solved by arrow functions
function Person() {
this.age = 0;
// Problem: setTimeout callback has its own 'this'
setTimeout(function () {
this.age++; // 'this' is not Person instance
console.log(this.age); // NaN
}, 1000);
// Solution with arrow function
setTimeout(() => {
this.age++; // 'this' is Person instance
console.log(this.age); // 1
}, 2000);
}
new Person();
- No
arguments
object
function regularFunc() {
console.log(arguments); // Arguments object is available
}
const arrowFunc = () => {
// console.log(arguments); // Error: arguments is not defined
};
// Use rest parameters instead
const arrowWithRest = (...args) => {
console.log(args); // Array of all arguments
};
- Cannot be used as constructors
const RegularFunc = function () {
this.value = 10;
};
const regInstance = new RegularFunc(); // Works
const ArrowFunc = () => {
this.value = 10;
};
// const arrowInstance = new ArrowFunc(); // TypeError: ArrowFunc is not a constructor
- No
prototype
property
console.log(RegularFunc.prototype); // {} (exists)
console.log(ArrowFunc.prototype); // undefined
- Cannot use
yield
(cannot be generator functions)
When to use arrow functions:
- For short, simple functions
- For callbacks where you want to preserve the lexical
this
- For functional-style programming (map, filter, reduce)
When to avoid arrow functions:
- As object methods (when you need
this
to refer to the object) - As constructors or prototype methods
- When you need the
arguments
object - When you need a dynamic
this
binding
What is the spread operator and how is it used?
The spread operator (...
) unpacks elements from arrays or properties from objects:
// 1. Array spread
const arr1 = [1, 2, 3];
const arr2 = [4, 5, 6];
// Combine arrays
const combined = [...arr1, ...arr2];
console.log(combined); // [1, 2, 3, 4, 5, 6]
// Create a copy of an array
const copy = [...arr1];
copy.push(4);
console.log(arr1); // [1, 2, 3] - original unaffected
console.log(copy); // [1, 2, 3, 4]
// Insert elements at a specific position
const insert = [1, 2, ...arr2, 7, 8];
console.log(insert); // [1, 2, 4, 5, 6, 7, 8]
// 2. Object spread
const person = { name: 'Alex', age: 30 };
const job = { title: 'Developer', salary: 100000 };
// Combine objects
const employee = { ...person, ...job };
console.log(employee); // { name: 'Alex', age: 30, title: 'Developer', salary: 100000 }
// Override properties
const updatedPerson = { ...person, age: 31 };
console.log(updatedPerson); // { name: 'Alex', age: 31 }
// Conditional properties
const conditionalObj = {
...person,
...(true ? { premium: true } : {}),
};
console.log(conditionalObj); // { name: 'Alex', age: 30, premium: true }
// 3. Function arguments
function sum(a, b, c) {
return a + b + c;
}
const numbers = [1, 2, 3];
console.log(sum(...numbers)); // 6
// 4. Practical applications
// Convert NodeList to Array
const nodeList = document.querySelectorAll('div');
const divArray = [...nodeList];
// Convert string to array of characters
const chars = [...'hello'];
console.log(chars); // ['h', 'e', 'l', 'l', 'o']
// Get max value in an array
console.log(Math.max(...[5, 10, 3])); // 10
// Merge objects with computed properties
const key = 'newProp';
const merged = {
...person,
[key]: 'Dynamic value',
};
console.log(merged); // { name: 'Alex', age: 30, newProp: 'Dynamic value' }
Note: For both arrays and objects, the spread operator creates a shallow copy, not a deep copy. Nested objects or arrays are still referenced, not duplicated.
Common Coding Problems
How would you implement a debounce function?
A debounce function limits how often a function can be called. It's useful for things like search input handlers or window resize handlers.
/**
* Creates a debounced function that delays invoking the provided function
* until after 'delay' milliseconds have elapsed since the last time it was invoked.
*
* @param {Function} func - The function to debounce
* @param {number} delay - The delay in milliseconds
* @return {Function} - The debounced function
*/
function debounce(func, delay) {
let timeoutId;
return function (...args) {
// Save 'this' context
const context = this;
// Clear previous timeout
clearTimeout(timeoutId);
// Set new timeout
timeoutId = setTimeout(() => {
func.apply(context, args);
}, delay);
};
}
// Example usage
const handleSearch = (query) => {
console.log(`Searching for: ${query}`);
// API call or other expensive operation
};
// Create debounced version (only fires after 300ms of inactivity)
const debouncedSearch = debounce(handleSearch, 300);
// In a real application, you might do:
// searchInput.addEventListener('input', (e) => debouncedSearch(e.target.value));
// Simulating rapid calls
debouncedSearch('a');
debouncedSearch('ap');
debouncedSearch('app');
debouncedSearch('appl');
debouncedSearch('apple');
// Only the last call for "apple" will execute after 300ms
How debounce works:
- When the debounced function is called, it sets a timeout
- If the function is called again before the timeout completes, it cancels the previous timeout and sets a new one
- The original function only executes after the specified delay has passed since the last call
Common use cases:
- Search input handling (wait until user stops typing)
- Window resize handlers
- Scroll event handlers
- Form validation as user types
- Autocomplete features
How would you implement throttle?
Throttling is similar to debouncing but guarantees a function is called at a regular interval even if the event is triggered more frequently.
/**
* Creates a throttled function that only invokes the provided function
* at most once per every 'limit' milliseconds.
*
* @param {Function} func - The function to throttle
* @param {number} limit - The time limit in milliseconds
* @return {Function} - The throttled function
*/
function throttle(func, limit) {
let lastCallTime = 0;
let timeoutId = null;
return function (...args) {
const context = this;
const now = Date.now();
// If enough time has passed since last call, execute immediately
if (now - lastCallTime >= limit) {
func.apply(context, args);
lastCallTime = now;
} else {
// Otherwise, schedule to execute at the end of the limit period
// This ensures we catch the last call if many are made within limit period
clearTimeout(timeoutId);
timeoutId = setTimeout(() => {
func.apply(context, args);
lastCallTime = Date.now();
}, limit - (now - lastCallTime));
}
};
}
// Example usage
const handleScroll = () => {
console.log('Scroll event processed at:', new Date().toLocaleTimeString());
// Expensive operation like DOM manipulation
};
// Create throttled version (executes at most once every 500ms)
const throttledScroll = throttle(handleScroll, 500);
// In a real app, you might do:
// window.addEventListener('scroll', throttledScroll);
// Simulate rapid scroll events
const simulateRapidScrolls = () => {
for (let i = 0; i < 10; i++) {
setTimeout(() => {
console.log(
'Scroll event triggered at:',
new Date().toLocaleTimeString()
);
throttledScroll();
}, i * 100); // Events every 100ms
}
};
simulateRapidScrolls();
// The handler will execute immediately for the first event
// and then at most once every 500ms after that
When to use throttle vs. debounce:
- Debounce: Use when you want to execute only after activity has stopped (e.g., search input)
- Throttle: Use when you want regular execution during continuous activity (e.g., scroll handling)
How would you flatten a nested array?
There are multiple ways to flatten a nested array in JavaScript:
// 1. Using Array.prototype.flat (ES2019+)
const nestedArray = [1, [2, 3], [4, [5, 6]]];
// Flatten one level
console.log(nestedArray.flat());
// [1, 2, 3, 4, [5, 6]]
// Flatten recursively with depth parameter
console.log(nestedArray.flat(2));
// [1, 2, 3, 4, 5, 6]
// Flatten completely with Infinity
console.log(nestedArray.flat(Infinity));
// [1, 2, 3, 4, 5, 6]
// 2. Custom recursive function (for older browsers)
function flattenArray(arr) {
let result = [];
arr.forEach((item) => {
if (Array.isArray(item)) {
// Recursively flatten and concatenate
result = result.concat(flattenArray(item));
} else {
result.push(item);
}
});
return result;
}
console.log(flattenArray(nestedArray));
// [1, 2, 3, 4, 5, 6]
// 3. Using reduce (functional approach)
function flattenWithReduce(arr) {
return arr.reduce((flat, item) => {
return flat.concat(Array.isArray(item) ? flattenWithReduce(item) : item);
}, []);
}
console.log(flattenWithReduce(nestedArray));
// [1, 2, 3, 4, 5, 6]
// 4. Iterative approach with a stack
function flattenIterative(arr) {
const stack = [...arr];
const result = [];
while (stack.length) {
const next = stack.pop();
if (Array.isArray(next)) {
stack.push(...next);
} else {
result.unshift(next);
}
}
return result;
}
console.log(flattenIterative(nestedArray));
// [1, 2, 3, 4, 5, 6]
Performance considerations:
- For modern browsers,
Array.prototype.flat()
is optimized and recommended - For very deep nesting, iterative approaches can be more efficient than recursive ones (avoid stack overflow)
- When dealing with very large arrays, consider if you really need to flatten the entire array at once
How would you deep clone an object in JavaScript?
Deep cloning objects (creating completely independent copies, including nested objects) can be done in several ways:
// Original complex object
const original = {
name: 'Alex',
age: 30,
address: {
city: 'New York',
country: 'USA',
},
hobbies: ['reading', 'gaming'],
sayHi: function () {
return `Hi, I'm ${this.name}`;
},
createdAt: new Date(),
pattern: /test/i,
nullValue: null,
undefinedValue: undefined,
};
// 1. Using JSON (simple but with limitations)
function cloneWithJSON(obj) {
return JSON.parse(JSON.stringify(obj));
}
const jsonClone = cloneWithJSON(original);
console.log(jsonClone.address.city); // "New York"
jsonClone.address.city = 'Boston';
console.log(original.address.city); // "New York" (unchanged)
// Limitations of JSON method:
// - Functions are lost
// - Date objects become strings
// - RegExp objects become empty objects
// - undefined values are lost
// - Cannot handle circular references
// 2. Custom deep clone function
function deepClone(obj) {
// Handle primitive types, null, and undefined
if (obj === null || typeof obj !== 'object') {
return obj;
}
// Handle Date objects
if (obj instanceof Date) {
return new Date(obj.getTime());
}
// Handle RegExp objects
if (obj instanceof RegExp) {
return new RegExp(obj);
}
// Handle Arrays
if (Array.isArray(obj)) {
return obj.map((item) => deepClone(item));
}
// Handle Objects
const clonedObj = {};
for (let key in obj) {
if (Object.prototype.hasOwnProperty.call(obj, key)) {
clonedObj[key] = deepClone(obj[key]);
}
}
return clonedObj;
}
const customClone = deepClone(original);
console.log(customClone.hobbies); // ["reading", "gaming"]
customClone.hobbies.push('swimming');
console.log(original.hobbies); // ["reading", "gaming"] (unchanged)
// 3. Using the structuredClone API (newer browsers)
try {
const structuredClone = window.structuredClone(original);
console.log(structuredClone.address.city); // "New York"
// Note: structuredClone doesn't support functions either
} catch (e) {
console.log('structuredClone not supported in this environment');
}
// 4. Using libraries
// Libraries like lodash provide robust deep cloning:
// const lodashClone = _.cloneDeep(original);
When to use each approach:
- JSON method: Quick solution for simple objects without special JavaScript types or functions
- Custom function: When you need to handle special types and functions
- structuredClone: For modern browsers and environments when you don't need to clone functions
- Libraries: For production code where you need battle-tested implementation
Note: Deep cloning can be expensive for large, complex objects. Sometimes a shallow clone (Object.assign or spread) is sufficient if you only need to modify top-level properties.
Best Practices
What are common JavaScript code quality tools?
Professional JavaScript developers use several tools to maintain code quality:
-
Linters: Static code analysis tools that check for potential errors and enforce style guidelines
- ESLint: Most popular JavaScript linter
- JSHint: Simpler alternative to ESLint
- StandardJS: Opinionated linter with zero configuration
-
Formatters: Tools that automatically format code according to a set of rules
- Prettier: Popular opinionated code formatter
- EditorConfig: Maintains consistent coding styles across editors
-
Type Checkers: Add static typing to JavaScript
- TypeScript: Microsoft's typed superset of JavaScript
- Flow: Facebook's type checker for JavaScript
-
Testing Frameworks: Tools for writing automated tests
- Jest: Facebook's test runner with built-in assertions and mocking
- Mocha: Flexible test framework often paired with Chai for assertions
- Jasmine: Behavior-driven development framework
-
Package Management: Tools for managing dependencies
- npm: Default package manager for Node.js
- Yarn: Alternative to npm with additional features
- pnpm: Fast, disk space efficient package manager
-
Bundlers and Build Tools: Tools that optimize code for production
- Webpack: Powerful and highly configurable bundler
- Rollup: Specializes in ES module bundling
- Vite: Modern, fast build tool leveraging native ES modules
- Parcel: Zero-configuration bundler
-
Documentation Tools: Generate documentation from code comments
- JSDoc: Standard documentation system for JavaScript
- ESDoc: Modern documentation generator
Setup example for a typical project:
// .eslintrc.js
module.exports = {
"env": {
"browser": true,
"es2021": true,
"node": true
},
"extends": "eslint:recommended",
"parserOptions": {
"ecmaVersion": "latest",
"sourceType": "module"
},
"rules": {
"indent": ["error", 2],
"linebreak-style": ["error", "unix"],
"quotes": ["error", "single"],
"semi": ["error", "always"]
}
};
// package.json scripts
{
"scripts": {
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"format": "prettier --write .",
"test": "jest",
"build": "webpack --mode=production"
}
}
What are JavaScript coding best practices?
Here are essential best practices for writing maintainable, high-quality JavaScript:
- Use descriptive variable and function names
// Poor
const x = 10;
function calc(a, b) {
return a + b;
}
// Better
const userAge = 10;
function calculateTotal(price, taxRate) {
return price + price * taxRate;
}
- Avoid global variables
// Poor
userId = 123; // Global variable
// Better
const userId = 123; // Local variable
- Use strict mode
'use strict';
// This prevents many common errors and makes code more predictable
- Prefer const, then let, avoid var
// Prefer const when variable won't be reassigned
const API_URL = 'https://api.example.com';
// Use let when variable might be reassigned
let count = 0;
count += 1;
// Avoid var due to its function scoping and hoisting issues
- Handle errors properly
// Poor
function fetchData() {
return fetch('/data').then((res) => res.json());
}
// Better
async function fetchData() {
try {
const response = await fetch('/data');
if (!response.ok) {
throw new Error(`HTTP error ${response.status}`);
}
return await response.json();
} catch (error) {
console.error('Failed to fetch data:', error);
throw error; // Re-throw or handle appropriately
}
}
- Avoid excessive nesting
// Poor - deeply nested code
function processOrder(order) {
if (order) {
if (order.items) {
if (order.items.length > 0) {
// Process items
}
}
}
}
// Better - early returns
function processOrder(order) {
if (!order) return;
if (!order.items) return;
if (order.items.length === 0) return;
// Process items
}
- Minimize side effects
// Poor - function modifies external state
let total = 0;
function addToTotal(value) {
total += value; // Side effect
}
// Better - pure function
function add(a, b) {
return a + b; // No side effects
}
const total = add(5, 10);
- Use meaningful comments, but prefer self-documenting code
// Poor
// Increment i
i++;
// Better - self-documenting (no comment needed)
userCount++;
// Good use of comments - explaining "why", not "what"
// We need to use setTimeout to avoid a race condition with the animation
setTimeout(updateUI, 100);
- Use consistent formatting
// Use a style guide and automatic formatting tools
// Consistent spacing, naming conventions, etc.
- Write testable code
// Poor - difficult to test
function processAndSaveUser() {
const data = thirdPartyApi.fetchData(); // External dependency
database.save(data); // External dependency
}
// Better - injectable dependencies
function processUser(userData, saveFunction) {
const processed = { ...userData, timestamp: Date.now() };
return saveFunction(processed);
}
// Now easily testable with mocks
Final Thoughts and Key Takeaways
JavaScript interviews can be challenging, but understanding these core concepts will help you approach them with confidence. Remember these key points:
-
Focus on fundamentals: Closures, prototypes, and asynchronous programming are fundamental JavaScript concepts that frequently appear in interviews.
-
Practice coding problems: Regular practice with algorithmic problems helps build problem-solving skills and fluency in JavaScript syntax.
-
Understand modern JavaScript: Be comfortable with ES6+ features like arrow functions, destructuring, and Promises/async-await.
-
Know best practices: Understanding clean code principles and common patterns demonstrates professional experience.
-
Ask clarifying questions: During interviews, don't hesitate to ask for clarification if a question is ambiguous.
-
Talk through your approach: Explaining your thought process is often as important as arriving at the correct solution.
Most importantly, remember that JavaScript interviews are not just testing your knowledge, but your ability to solve problems and write maintainable code. Good luck with your interviews! 🚀
I hope this guide helps you prepare for your JavaScript interviews! If you have any questions or want more specific examples, feel free to reach out in the comments below.
What's your experience with JavaScript interviews? Any questions or topics you'd like me to cover in more depth? Let me know!
Complete Interview Preparation Series
If you found this JavaScript guide helpful, check out my other interview preparation resources:
-
Java Interview Questions and Answers - Comprehensive guide covering core Java concepts, collections, multithreading, and more.
-
Essential Coding Patterns for Technical Interviews - A breakdown of the most common algorithmic patterns that appear in coding interviews, with LeetCode examples and implementations.
Together, these guides form a complete preparation path for technical interviews at top tech companies. Good luck with your interview preparation journey!