Full-Stack Questions

JavaScript Core Interview Questions

Types and coercion, prototypes, classes, this, copying, Map and Set, iterators, modules, newer array methods and “what does this print” puzzles.

Lesson 1539 min read#javascript

On this page

  1. Words used in this lesson
  2. Types and coercion
  3. Objects and property descriptors
  4. Prototypes and the prototype chain
  5. Classes
  6. this, call, apply, bind and arrow functions
  7. Copying and immutability
  8. Map, Set, WeakMap, WeakSet, WeakRef
  9. Iterators and generators
  10. Destructuring, defaults, rest, optional chaining, nullish
  11. Proxy and Reflect
  12. Tagged templates
  13. Intl, Date and Temporal
  14. ES modules versus CommonJS
  15. Modern methods: edition and support
  16. Error handling
  17. Memory and performance basics
  18. Interview questions

In one line: at 4+ years, JavaScript questions are not about syntax. They test whether you know the rules behind the language: how values are compared and converted, how this and prototypes are decided, what a copy really copies, and which modern features you can use today. This lesson gives you those rules, with outputs that were run, and 20 questions with spoken answers.

The earlier JavaScript lessons cover how the engine runs your code: the execution context, the call stack, let, const and the temporal dead zone, the scope chain, closures and the event loop. For the browser side of the event loop read Browser, Lesson 8. This lesson does not repeat them. It covers the other half of a JavaScript interview: types, objects, prototypes, classes, this, copying, collections, iterators, modules, errors and the newer built-in methods.

All outputs below were run on Node 22 unless a line says otherwise. Browser-only facts were checked in Chromium.

Words used in this lesson

  • A primitive is a simple value that is not an object and cannot be changed: string, number, boolean, null, undefined, bigint and symbol. Everything else is an object, which is a bag of named properties.
  • Coercion is the automatic conversion of a value from one type to another, such as "5" - 2 turning the string into a number.
  • A prototype is the object that another object falls back to when a property is not found on it. It works like a "see also" note: if I do not have it, ask my prototype.

Types and coercion

The seven primitive types, and typeof

ValuetypeofNote
"a", 1, truestring, number, boolean
undefinedundefined"no value was given"
nullobjectan old bug, kept so old code does not break
10nbigintintegers of any size (ES2020)
Symbol()symbola unique key (ES2015)
() => 1, class {}functionfunctions and classes are objects, but typeof says function
NaNnumber"not a number" is still a number
[], {}objectuse Array.isArray for arrays

Equality: ==, ===, Object.is

  • === (strict equality) compares type and value. No conversion.
  • == (loose equality) converts types first, using a long rule list. The rules that matter: null == undefined is true and equals nothing else; if one side is a number and the other a string, the string becomes a number; an object becomes a primitive first.
  • Object.is is like ===, with two differences: Object.is(NaN, NaN) is true, and Object.is(0, -0) is false.
JavaScript
NaN === NaN;            // falseObject.is(NaN, NaN);    // true0 === -0;               // trueObject.is(0, -0);       // falsenull == undefined;      // truenull == 0;              // falsenull >= 0;              // true   (>= converts null to 0; == does not)[] == ![];              // true   (![] is false -> 0, [] -> "" -> 0)

My rule: I use === everywhere. The one place I allow == is x == null, which means "null or undefined" in one check. Number.isNaN(x) and [NaN].includes(NaN) (true) handle NaN. [NaN].indexOf(NaN) gives -1, because indexOf uses ===.

Conversion rules worth knowing

JavaScript
"5" - 2        // 3      - minus always means numbers"5" + 2        // "52"   - plus joins if either side is a string+""  +[]  +null   // 0 0 0+undefined  +{}   // NaN NaN[] + {}        // "[object Object]"

There are only eight falsy values: false, 0, -0, 0n, "", null, undefined, NaN. Everything else is truthy, including [], {}, "0" and new Boolean(false).

Numbers, -0, NaN, BigInt

A JavaScript number is a 64-bit floating-point value (IEEE 754). Many decimals cannot be stored exactly.

JavaScript
0.1 + 0.2;                          // 0.30000000000000004Math.abs(0.1 + 0.2 - 0.3) < Number.EPSILON;   // true - compare with a toleranceNumber.MAX_SAFE_INTEGER;            // 9007199254740991 (2**53 - 1)2 ** 53 === 2 ** 53 + 1;            // true - integers lose precision above this1.005.toFixed(2);                   // "1.00"  - the stored value is slightly below 1.005

-0 is a real value: 1 / -0 is -Infinity, and Math.round(-0.4) gives -0. Only Object.is and 1/x can see it.

BigInt (ES2020, a bigint primitive) holds integers of any size: 9007199254740993n + 1n is 9007199254740994n. Rules: you cannot mix it with numbers (1n + 1 throws TypeError: Cannot mix BigInt and other types), JSON.stringify throws on it, and Math functions do not accept it. 2n == 2 is true but 2n === 2 is false.

Symbols

A symbol is a primitive that is always unique, used mainly as a property key that cannot clash with another key. Symbol("a") === Symbol("a") is false. Symbol.for("a") reads a shared global registry, so Symbol.for("a") === Symbol.for("a") is true. Symbol keys are skipped by Object.keys, for...in and JSON.stringify, but Reflect.ownKeys lists them. The language uses well-known symbols as hooks: Symbol.iterator, Symbol.asyncIterator, Symbol.toPrimitive, Symbol.toStringTag.

Objects and property descriptors

Every property has a descriptor. A data property has value, writable, enumerable, configurable. An accessor property has get, set, enumerable, configurable.

JavaScript
const o = { a: 1 };Object.defineProperty(o, "hidden", { value: 42 });   // other flags default to falseObject.getOwnPropertyDescriptor(o, "hidden");// { value: 42, writable: false, enumerable: false, configurable: false }Object.keys(o);          // ["a"]   - not enumerable, so hidden from keys and JSONo.hidden = 7;            // silently ignored in sloppy mode                         // TypeError in strict mode: Cannot assign to read only property
  • enumerable: shows up in Object.keys, for...in, spread and JSON.stringify.
  • writable: the value can be changed.
  • configurable: the property can be deleted or its descriptor changed.

Sloppy mode versus strict mode. Sloppy mode is the old, forgiving behaviour that ignores some errors. Strict mode ("use strict") turns them into real errors. ES modules and class bodies are always strict, which is why the same code can fail silently in a script and throw in a module.

Key order. Integer-like keys come first in ascending order, then string keys in the order they were added, then symbols. Object.keys({ b: 1, 2: 1, a: 1, 1: 1 }) gives ["1", "2", "b", "a"].

Prototypes and the prototype chain

Every object has an internal link to its prototype (read it with Object.getPrototypeOf(obj); the old __proto__ accessor does the same). When you read obj.x and x is missing, the engine looks at the prototype, then the prototype's prototype, up to null. This list of links is the prototype chain.

JavaScript
function Animal(n) { this.n = n }Animal.prototype.speak = function () { return this.n + " speaks" };const d = new Animal("Rex");d.speak();                                       // "Rex speaks"Object.getPrototypeOf(d) === Animal.prototype;   // trueObject.hasOwn(d, "speak");                       // false - it lives on the prototypeObject.getPrototypeOf(Animal.prototype) === Object.prototype;  // trueObject.getPrototypeOf(Object.prototype);         // null - end of the chain

Three names are easy to mix up. Difference in one line: obj.__proto__ (or Object.getPrototypeOf(obj)) is the link an object has; Fn.prototype is the object a function gives to the things it creates with new.

  • new Fn(args) does four steps: make an empty object whose prototype is Fn.prototype; call Fn with this set to it; if Fn returns an object, use that; otherwise use the new object.
  • Object.create(proto) makes an object with a chosen prototype. Object.create(null) makes one with no prototype, a safe dictionary. instanceof walks the left side's chain looking for Right.prototype.
  • Writes do not use the chain. Reading finds an inherited value; writing always creates an own property (shadowing). child.count++ reads the parent's 0, then creates child.count = 1; the parent stays 0. But child.list.push(1) changes the shared parent array.
  • for...in also walks the chain and lists inherited enumerable keys. Object.keys and Object.entries list own keys only. Use Object.hasOwn(obj, key) (ES2022), not obj.hasOwnProperty(key).

Prototype pollution is an attack where input like {"__proto__": {...}} or constructor.prototype writes into Object.prototype, so every object gets a new property. JSON.parse is safe (it creates an own property named __proto__), but a careless recursive merge function is not. Block the keys __proto__, constructor and prototype, or store dictionaries with Object.create(null) or a Map.

Classes

A class is a cleaner way to write a constructor function plus prototype methods. It is not a new object model. typeof class {} is "function", and methods live on Class.prototype.

Differences from a plain constructor function: a class cannot be called without new (TypeError: Class constructor A cannot be invoked without 'new'), its body is always strict, class declarations are in the temporal dead zone before they run, and methods are not enumerable.

JavaScript
class Account {  #balance = 0;                       // private instance field (ES2022)  static #count = 0;                  // private static field  static ready;  static { Account.ready = true; }    // static block (ES2022)  get balance() { return this.#balance }          // a getter runs a function on read...  set balance(v) { if (v < 0) throw new RangeError("negative"); this.#balance = v }  // ...and a setter  #log() { return "private method" }  static isAccount(o) { return #balance in o }    // brand check (ES2022)}
  • A private field #x is visible only inside the class body. It is a real language rule, not a naming habit. Reading obj.#x outside gives a SyntaxError at parse time. It is not an own property: Object.keys and JSON.stringify do not see it, and a Proxy around the object cannot reach it (Cannot read private member #x from an object whose class did not declare it).
  • A static block runs once when the class is created. #x in obj is a safe check that an object came from this class.
  • A class field with an arrow function (handle = () => {}) creates a new function per instance, but keeps this fixed. A normal method is shared on the prototype.
  • extends and super. super(...) in a constructor must run before you touch this; otherwise: ReferenceError: Must call super constructor in derived class before accessing 'this'. super.method() calls the parent's version. static methods are inherited too.
  • Field timing. A subclass field is set after super() returns. So if the base constructor calls an overridden method, that method sees the subclass field as undefined.
JavaScript
class Par { constructor() { this.say() } say() { console.log("Par") } }class Chi extends Par { name = "chi"; say() { console.log("Chi says", this.name) } }new Chi();   // "Chi says undefined"

The accessor keyword you may see in tutorials belongs to the decorators proposal (Stage 2.7). It is not part of JavaScript today.

this, call, apply, bind and arrow functions

this is decided by how a function is called, not where it is written. Arrow functions are the exception: they take this from where they are written.

How it is calledthis is
obj.method()obj
fn() plain callundefined in strict mode; the global object in sloppy mode
fn.call(x, ...), fn.apply(x, [...])x
fn.bind(x)a new function, locked to x
new Fn()the new object
arrow functionthe this of the surrounding code, always
DOM event handler (el.onclick = function(){})the element
  • call runs the function now with a chosen this and separate arguments. apply is the same with an array of arguments (today I would use spread instead). bind does not run it; it returns a new function with this (and optionally the first arguments) fixed.
  • Losing this. const f = obj.method; f() is a plain call. (obj.method)() still works (parentheses do not detach), but (0, obj.method)() does not.
  • Arrow functions have no own this, arguments or prototype, and cannot be called with new. call, apply and bind cannot change their this. Do not use an arrow as an object method if you need this to be the object.
JavaScript
const obj = {  name: "obj",  regular() { return this.name },  arrow: () => typeof this,  nested() { return [1].map(() => this.name) },       // ["obj"] - arrow keeps this};const f = obj.regular;obj.regular();   // "obj"f();             // undefined in strict mode (a module or class); the global's name in sloppy mode

At the top of a CommonJS file this is module.exports (an empty object). At the top of an ES module it is undefined. In a browser script it is window. Inside a setTimeout function callback, this is window in a browser but a Timeout object in Node.

Copying and immutability

Assignment never copies an object. const b = a copies the reference (the address), so both names point at the same object. JavaScript is always pass-by-value. For objects, the value that is passed is the reference. That is why changing obj.x inside a function is seen outside, but obj = {} inside the function is not.

A shallow copy copies only the top level, so inner objects stay shared. A deep copy copies every level.

Way to copyDepthKeepsLoses or fails on
{ ...o }, Object.assign({}, o)shallowown enumerable keys, symbol keysinner objects are shared; getters are run and copied as plain values; prototype is lost
[...a], a.slice(), Array.from(a)shallowelementsinner objects are shared
JSON.parse(JSON.stringify(o))deepplain dataDate becomes a string; Map, Set, RegExp become {}; undefined, functions and symbols are dropped (in arrays they become null); NaN and Infinity become null; BigInt throws; cycles throw
structuredClone(o)deepDate, Map, Set, RegExp, typed arrays, Error, cyclesfunctions and DOM nodes throw DataCloneError; class prototypes, getters and private fields are lost (you get a plain object)
a hand-written recursive cloneas you decideanythingyou must handle cycles

structuredClone is a global function that makes a deep copy using the browser's structured clone algorithm. It is not part of the ECMAScript spec; it comes from the HTML standard and Node (Node 17+, Chrome 98, Firefox 94, Safari 15.4). It can also move a buffer: structuredClone(buf, { transfer: [buf] }) leaves the original with byteLength 0.

Spread pitfalls: { ...obj } is shallow, so copy.address.city = "X" also changes the original. { ...null } and { ...undefined } are fine (empty), but { ..."hi" } gives {0: "h", 1: "i"}. Array(3).fill([]) puts the same array in all three slots. For state updates (React, reducers), copy each level you change.

Immutability means a value is never changed after creation. JavaScript gives three levels:

CallAddChange valueDeleteCheck
Object.preventExtensions(o)noyesyesObject.isExtensible
Object.seal(o)noyesnoObject.isSealed
Object.freeze(o)nononoObject.isFrozen

freeze is shallow: Object.freeze({ n: { b: 2 } }).n.b = 3 works. It also does not protect a Map: Object.freeze(new Map()).set(1, 2) still works, because the data is in internal slots. In sloppy mode, writes to a frozen object are silently ignored; in strict mode they throw. A frozen array rejects push with TypeError: Cannot add property 2, object is not extensible.

const is different: it stops re-assigning the name, not changing the object.

Map, Set, WeakMap, WeakSet, WeakRef

TypeWhat it isKey point
Mapkey-value pairs, any type of key, keeps insertion ordermap.size, direct iteration, fast adds and deletes; NaN works as a key and -0 is treated as 0
Seta collection of unique valuesuniqueness uses "SameValueZero": NaN equals NaN, but two {} are different
WeakMaplike Map, but keys must be objects (or non-registered symbols) and are held weaklyno size, no iteration, no clear
WeakSetlike Set, held weaklyonly add, has, delete
WeakRefholds one object weakly; ref.deref() returns it or undefinedES2021
FinalizationRegistryasks for a callback after an object is collectedES2021, not reliable

A weak reference is a link that does not keep an object alive. If nothing else points at the object, the garbage collector may free it.

Map versus plain object. Use a Map when keys are not known in advance, are not strings, or the table changes a lot. A plain object has inherited keys ("toString" in {} is true), turns keys into strings, and has no size. Use an object for fixed, known shapes and for JSON.

Typical WeakMap use: attach metadata to an object (such as a DOM element) without leaking it, because the entry disappears with the key.

WeakRef and FinalizationRegistry are a last resort. When an object is collected is up to the engine, deref() stays valid for the whole current job, and the cleanup callback may run late or never (in my test with a forced GC, it did not run). Never use them for logic that must happen, like closing a file. Use try/finally or explicit close(). They are fine for optional caches.

Set methods (ES2025)

union, intersection, difference, symmetricDifference, isSubsetOf, isSupersetOf, isDisjointFrom:

JavaScript
const A = new Set([1, 2, 3]), B = new Set([3, 4]);A.union(B);          // Set {1, 2, 3, 4}A.intersection(B);   // Set {3}A.difference(B);     // Set {1, 2}

Iterators and generators

An iterable is an object with a [Symbol.iterator]() method that returns an iterator, an object with next() returning { value, done }. for...of, spread, destructuring, Array.from, Map, Set and Promise.all all use this protocol. A plain object is not iterable (for (const x of {a:1}) throws TypeError); use Object.entries(obj).

JavaScript
const range = {  from: 1, to: 3,  [Symbol.iterator]() {    let c = this.from, t = this.to;    return {      next: () => (c <= t ? { value: c++, done: false } : { value: undefined, done: true }),      return() { console.log("closed"); return { done: true } },   // runs on break/throw/destructuring    };  },};[...range];                    // [1, 2, 3]for (const x of range) { if (x === 2) break }   // prints "closed"const [first] = range;         // also prints "closed" - destructuring stops early

A generator function (function*) is a function that can pause with yield and continue later, so it returns an iterator you do not write by hand. yield pauses and gives a value out; the value passed into next(v) becomes the result of the yield. return() ends it early and still runs finally blocks. yield* hands over to another iterable. Because generators are lazy, they can be infinite:

JavaScript
function* naturals() { let i = 0; while (true) yield i++ }naturals().filter(x => x % 2).map(x => x * x).take(4).toArray();   // [1, 9, 25, 49]

The chain above uses iterator helpers (ES2025): map, filter, take, drop, flatMap, reduce, toArray, some, every, find, forEach, plus Iterator.from. They work lazily, one item at a time, unlike array methods that build a full array at each step.

Async iterators use for await...of; async function* is the generator form. Array.fromAsync(asyncIterable) (ES2026) collects one into an array.

Destructuring, defaults, rest, optional chaining, nullish

Destructuring unpacks values from arrays or objects into variables.

JavaScript
const { a = 10, b: { c = 5 } = {}, ...rest } = { a: undefined, x: 1, y: 2 };// a = 10, c = 5, rest = { x: 1, y: 2 }const [x, , y = 3, ...zs] = [1, 2, undefined, 4, 5];   // x=1, y=3, zs=[4,5][p, q] = [q, p];                                        // swap, no temp variable
  • A default applies only when the value is undefined, not null or 0. A default is evaluated only when needed.
  • const { nope } = null throws TypeError: Cannot destructure property 'nope' of 'null'. Add = {} on the pattern when the value may be missing.
  • Rest (...rest) collects the leftover items. In a function it replaces arguments. fn.length counts parameters before the first default or rest.
  • Optional chaining ?. (ES2020) stops and gives undefined if the left side is null or undefined: user?.address?.city, fn?.(), arr?.[0]. It short-circuits the whole rest of the chain.
  • Nullish coalescing ?? (ES2020) picks the right side only for null/undefined. 0 ?? 5 is 0; 0 || 5 is 5. ??, ??=, ||=, &&= (ES2021) are the assignment forms. You cannot mix ?? with || or && without parentheses (SyntaxError).

Proxy and Reflect

A Proxy is an object that wraps another object and lets you intercept actions on it (reading, writing, in, delete) with functions called traps. Reflect has one function for each default action, so a trap can call the normal behaviour: Reflect.get(target, key, receiver).

JavaScript
const guarded = new Proxy({ a: 1 }, {  set(t, k, v, r) {    if (typeof v !== "number") throw new TypeError("number only");    return Reflect.set(t, k, v, r);  },  has: (t, k) => k.startsWith("a"),});

Real uses: validation, logging, negative array indexes, lazy objects, and the reactivity in frameworks such as Vue 3. Limits: a proxy cannot lie about a frozen target (it throws a TypeError), a proxy around a Map breaks because Map methods need the real object as this (Method Map.prototype.set called on incompatible receiver), private fields are not reachable, and traps add cost to every access. Proxy.revocable returns a proxy you can switch off.

Tagged templates

A tagged template calls a function with the template pieces: tag\a${1}b`. The function gets an array of the literal strings (with a .rawcopy that keeps\n as typed) and then the values. The strings array is frozen and the **same object every time the same code location runs**. Uses: safe HTML escaping, SQL parameters (sql`... ${id}`), CSS-in-JS, String.raw`.

Intl, Date and Temporal

Intl is the built-in internationalisation API (its own standard, ECMA-402). It formats numbers, dates, lists and plurals the way a locale expects, with no library:

JavaScript
new Intl.NumberFormat("en-IN", { style: "currency", currency: "INR" }).format(1234567.891); // "₹12,34,567.89"new Intl.NumberFormat("en", { notation: "compact" }).format(1234567);                        // "1.2M"["ä", "z", "a"].sort(new Intl.Collator("de").compare);                                       // ["a", "ä", "z"]

The Date object is weak. It is mutable (setMonth changes the object); months start at 0; new Date(2024, 0, 31).setMonth(1) overflows into March; a date-only string like "2024-03-10" is read as UTC, but "2024-03-10T00:00:00" is read as local time; it has no time zone support besides "UTC or the user's"; and parsing of non-ISO strings differs between engines.

Temporal is the replacement. It has separate immutable types: Temporal.PlainDate (a calendar date), Temporal.Instant (an exact moment), Temporal.ZonedDateTime (a moment with a time zone), and Temporal.Duration. Status: it reached Stage 4 and is listed for ES2027. It ships in Firefox 139, Chrome 144 and Node 26. Safari has it only in preview. It was not present in Node 22 or Chromium 141 in my tests. In Node 26:

JavaScript
Temporal.PlainDate.from("2024-01-31").add({ months: 1 }).toString();   // "2024-02-29" (clamped)Temporal.Instant.from("2024-03-10T00:00:00Z").toZonedDateTimeISO("Asia/Kolkata").toString();// "2024-03-10T05:30:00+05:30[Asia/Kolkata]"

Today I store dates as UTC ISO strings and use a library or a polyfill.

ES modules versus CommonJS

A module is a file with its own scope that shares values with other files. JavaScript has two systems.

ES modules (ESM)CommonJS (CJS)
Syntaximport / exportrequire() / module.exports
Loadingstatic: the file graph is known before running; asyncdynamic: require runs the file right then, sync
What you getlive bindings (a read-only view of the exporter's variable)a copy of the value at the time of require
Top-level awaityes (ES2022)no
this at topundefinedmodule.exports
Strict modealwaysoptional
__dirname, requirenot defined (use import.meta.url, import.meta.dirname)defined

A live binding means the importer sees the exporter's current value. With export let count = 0; export function inc() { count++ }, the importer reads count as 0, calls inc(), then reads 1. The importer cannot assign to it: count = 5 throws TypeError: Assignment to constant variable. In CommonJS, const { count, inc } = require("./cnt") copies the number once, so it stays 0.

Cycles. In CommonJS, a file in a cycle gets the partly filled exports so far. In my test, c2 saw c1.loaded === false. In ESM, bindings exist but may not be initialised: using an export const too early throws ReferenceError: Cannot access 'a' before initialization. Fix cycles by moving shared code to a third file.

Top-level await (ES2022; Chrome 89, Firefox 89, Node 14.8; Safari 15 with a bug that is fixed in 27) lets a module wait for a promise. Modules that import it wait too, so one slow await can delay start-up of everything that depends on it.

Dynamic import() (ES2020) loads a module on demand and returns a promise: const { default: x } = await import("./x.mjs"). It works in both CJS and ESM, and bundlers use it to split code. Import attributes (ES2025) are written import data from "./a.json" with { type: "json" }.

In Node, a file is ESM if it ends in .mjs or the nearest package.json has "type": "module". Since Node 22.12, require() can load an ES module that has no top-level await. For the server side of this, see Full-Stack Q&A, Lesson 6 (Node.js).

Modern methods: edition and support

"Support" below is the first version of Chrome / Firefox / Safari / Node that has it (MDN browser-compat data).

FeatureEditionChrome / Firefox / Safari / Node
at() on arrays and stringsES202292 / 90 / 15.4 / 16.6
Object.hasOwnES202293 / 92 / 15.4 / 16.9
Error option cause, AggregateErrorES2022, ES202193 / 91 / 15 / 16.9 (cause)
findLast, findLastIndexES202397 / 104 / 15.4 / 18
toSorted, toReversed, toSpliced, withES2023110 / 115 / 16 / 20
Object.groupBy, Map.groupByES2024117 / 119 / 17.4 / 21
Promise.withResolversES2024119 / 121 / 17.4 / 22
Set methods (union, ...)ES2025122 / 127 / 17 / 22
Iterator helpers (Iterator.from, .map, .take...)ES2025122 / 131 / 18.4 / 22
Promise.tryES2025128 / 134 / 18.2 / 23
RegExp.escapeES2025136 / 134 / 18.2 / 24
Array.fromAsyncES2026121 / 115 / 16.4 / 22
Error.isErrorES2026134 / 138 / 18.4 / 24.3
TemporalStage 4, ES2027144 / 139 / preview / 26
using (explicit resource management)Stage 4, ES2027134 / 141 / preview / 24

The four "copy" methods return a new array and leave the original alone, unlike sort, reverse, splice and index assignment, which change the array in place:

JavaScript
const arr = [3, 1, 2];arr.toSorted();      // [1, 2, 3]       arr is still [3, 1, 2]arr.with(0, 9);      // [9, 1, 2]       out of range throws RangeErrorarr.toSpliced(1, 1); // [3, 2]arr.at(-1);          // 2arr.findLast(x => x > 1);   // 2Object.groupBy([1, 2, 3, 4, 5], n => (n % 2 ? "odd" : "even"));// { odd: [1, 3, 5], even: [2, 4] }  - an object with no prototypeMap.groupBy([1, 2, 3], n => n > 1);   // Map { false => [1], true => [2, 3] }const { promise, resolve, reject } = Promise.withResolvers();   // resolve it from outside

Older articles show Array.prototype.group or groupBy. That name was dropped. The shipped API is Object.groupBy and Map.groupBy. In Node 22, typeof [].groupBy is "undefined". Also, features below Stage 3 (pipeline operator, decorators, Iterator.range) are not JavaScript yet. Do not use them in answers as if they were.

For features your target browsers lack, check the table against your browserslist, then transpile or polyfill.

Error handling

An Error is an object with name, message and stack. You can throw anything, but always throw an Error so you get a stack.

  • cause (ES2022) links the original error when you wrap it: throw new Error("config bad", { cause: err }). Keeps the full story in logs.
  • AggregateError has an errors array. Promise.any rejects with it when every promise fails. Promise.allSettled never rejects; it reports {status, value | reason} for each.
  • Custom errors extend Error. Set name (use new.target.name) and add useful fields:
JavaScript
class HttpError extends Error {  constructor(status, msg, opts) { super(msg, opts); this.name = new.target.name; this.status = status }}class NotFound extends HttpError { constructor(m) { super(404, m) } }const e = new NotFound("gone");e instanceof HttpError;   // trueJSON.stringify(e);        // {"name":"NotFound","status":404} - message and stack are NOT enumerable
  • instanceof fails across realms (an iframe); Error.isError(x) (ES2026) is the cross-realm check.
  • try/catch does not catch the rejection of a promise you forgot to await. try { af() } catch {} where af is async does nothing; the rejection becomes an unhandled rejection. In Node 15+, an unhandled rejection ends the process with exit code 1 by default.
  • finally runs even after return or throw. A return inside finally overrides the earlier result, which is usually a bug.

Memory and performance basics

  • Garbage collection frees memory of objects nothing can reach any more. Reachability decides what is collected. An object lives while a chain of references reaches it from a root (global variables, the current stack, active closures, timers, event listeners).
  • Common leaks: a global array or Map that only grows (caches with no limit); a listener or setInterval that was never removed; a closure that holds a large variable; detached DOM nodes still in a variable. Fix: remove listeners (AbortController signal), clear timers, use a WeakMap or a size-limited cache.
  • Cheap wins: do not search an array inside a loop (build a Map or Set once); use a lazy iterator instead of building big arrays; and measure with the profiler before tuning.

Interview questions

(Must know) What are the data types in JavaScript, and why is typeof null equal to "object"?

There are seven primitive types: string, number, boolean, null, undefined, bigint and symbol. Everything else is an object, including arrays, functions, dates and maps. typeof null is "object" because of a bug in the first version of the language, and it was never fixed because fixing it would break existing sites. I check null with x === null and arrays with Array.isArray. The follow-up is "do primitives have methods?". In effect yes: the engine wraps the primitive in a temporary object for the call, so "abc".toUpperCase() works, but I cannot store my own property on a string.

(Must know) What is the difference between ==, === and Object.is?

=== compares type and value with no conversion. == converts types first with a long rule list, so [] == false and "0" == false are true, but null == 0 is false. Object.is is almost ===, except NaN equals itself and 0 and -0 are different. I use === always. The only == I accept is x == null, which checks null and undefined together. A good extra point: [NaN].includes(NaN) is true, but [NaN].indexOf(NaN) is -1.

(Trap) What does this print?
JavaScript
console.log(0.1 + 0.2 === 0.3, [10, 9, 1].sort(), ["1", "2", "3"].map(parseInt), "b" + "a" + +"a" + "a");

It prints false [ 1, 10, 9 ] [ 1, NaN, NaN ] baNaNa. Numbers are binary floats, so 0.1 + 0.2 is 0.30000000000000004; I compare with a tolerance and keep money as integers. The default sort turns items into strings, so "10" comes before "9"; I always pass (a, b) => a - b. map gives parseInt the index as the second argument (the base), and parseInt("2", 1) is NaN. And +"a" is NaN, so the string becomes "b" + "a" + "NaN" + "a".

(Senior signal) How do -0, NaN and BigInt behave, and where do they cause bugs?

-0 equals 0 with ===, but 1 / -0 is -Infinity and Object.is(0, -0) is false. It comes from Math.round(-0.4) or from multiplying zero by a negative number. String(-0) and JSON.stringify(-0) both give "0", so it hides in logs. NaN is the only value not equal to itself; I check it with Number.isNaN, not the global isNaN, which converts first. BigInt is for integers above 2**53 - 1, such as 64-bit database IDs. The traps: it cannot mix with numbers, JSON.stringify throws on it, and Math functions reject it. So when an API sends a 64-bit ID, I keep it as a string.

(Must know) Explain the prototype chain. What is the difference between proto and prototype?

Every object has a hidden link to a prototype. When a property is missing, the engine follows these links until it finds it or reaches null. obj.__proto__ (better: Object.getPrototypeOf(obj)) is the link an object has. Fn.prototype is a property of a function, and it becomes the prototype of everything made with new Fn(). Writes do not use the chain: child.count++ creates an own property on the child, but child.list.push(x) changes the shared parent array. For a dictionary I use Object.create(null) or a Map. If asked what new does: it makes an object linked to Fn.prototype, runs Fn with this set to it, and returns the object unless Fn returned another object.

(Must know) Are JavaScript classes real classes? What do private fields change?

A class is syntax over constructor functions and prototypes: typeof class {} is "function" and methods sit on Class.prototype. It adds real rules: no call without new, a strict body, non-enumerable methods. Private fields (#x, ES2022) differ from the old _x habit because the engine enforces them. Access from outside is a syntax error, they are invisible to Object.keys and JSON.stringify, and a Proxy cannot reach them. #x in obj is a safe brand check, and static { } runs setup code once. The trade-off: private fields make mocking and cloning harder, because structuredClone drops them.

(Trap) What does this print, and why?
JavaScript
class Par { constructor() { this.say() } say() { console.log("Par") } }class Chi extends Par { name = "chi"; say() { console.log("Chi says", this.name) } }new Chi();

It prints Chi says undefined. new Chi() runs the Par constructor first. It calls this.say(), and this is a Chi, so the overridden method runs. But the field name is set only after super() returns, so it is still undefined. The rule: never call overridable methods from a constructor.

(Must know) How is this decided? What are call, apply and bind, and how do arrow functions differ?

this is set by the call: obj.m() gives obj; a plain call gives undefined in strict code; new F() gives the new object; call and apply set it directly; bind returns a new function with it fixed. call takes arguments one by one, apply takes an array, and bind does not run the function. An arrow function has no this of its own and uses the one from where it was written, and nothing can change that. So I use arrows for callbacks inside methods and normal methods for object methods. Traps: const f = obj.method; f() loses this, and so does (0, obj.method)(). Binding twice does nothing, since the first bind wins.

(Trap) What does this print?
JavaScript
const a = {}, b = { key: "b" }, c = { key: "c" };a[b] = 123;a[c] = 456;console.log(a[b], Object.keys(a));

It prints 456 [ '[object Object]' ]. Object keys are strings or symbols, so both b and c become "[object Object]" and the second write replaces the first. With a Map, map.get(b) would still give 123.

(Must know) What is the difference between a shallow and a deep copy? How do you deep copy today?

A shallow copy copies the top level, and inner objects are still shared. { ...o }, Object.assign, [...a] and slice are all shallow. A deep copy copies every level. Today I use structuredClone(x): it keeps Date, Map, Set, RegExp, typed arrays and cycles. Its limits: it throws DataCloneError for functions and DOM nodes, and it returns plain objects, so prototypes, getters and private fields are lost. JSON.parse(JSON.stringify(x)) is worse: dates become strings, Map and Set become {}, undefined and functions vanish, NaN becomes null, and BigInt and cycles throw. In React the shallow trap is real: with const next = { ...state }; next.user.name = "X", the write also changes the old state, and Object.is sees the same user reference, so the UI does not update. The fix is to copy each level on the path: { ...state, user: { ...state.user, name: "X" } } (see Full-Stack Q&A, Lesson 4).

(Senior signal) Does Object.freeze make an object immutable? When do you use seal or preventExtensions?

freeze stops adding, deleting and changing own properties, but it is shallow: inner objects can still change. It also does not stop a frozen Map from changing, because map data lives in internal slots. In sloppy mode a failed write is silent; in strict mode it throws. seal allows changing values but not adding or deleting, and preventExtensions only blocks adding. const is not immutability: it only blocks re-assigning the name. I use freeze for config constants and tests, and for app state I rely on copy-on-change (toSorted, with, spread), because freezing large objects at runtime costs time.

(Must know) When do you choose Map over an object, and what are WeakMap and WeakRef for?

I choose Map when keys are not known ahead, are not strings, or the data changes often. It keeps insertion order, has size, and has no inherited keys. I choose an object for fixed shapes and JSON. A WeakMap holds keys weakly, so when the key object is gone, the entry can be collected. I use it to attach metadata to objects I do not own, such as DOM elements, without a leak. WeakRef and FinalizationRegistry are unreliable: timing is up to the engine and the callback may never run. I never use them for logic that must happen, only for optional caches.

(Must know) What is an iterator? What is a generator, and when do you use one?

An iterator is an object with next() that returns { value, done }. An iterable has a [Symbol.iterator]() method that returns an iterator, which for...of, spread and destructuring use. A generator function (function*) writes an iterator the easy way: it pauses at each yield and continues on the next next(). I use generators for lazy or infinite sequences, paging through an API, and tree walking with yield*. A detail: if a loop ends early (break, throw, or array destructuring that takes only some items), the engine calls the iterator's return() so it can clean up. With iterator helpers I can write naturals().filter(...).map(...).take(4).toArray() without building arrays between steps.

(Trap) What does this print?
JavaScript
const { a = 10, b: { c = 5 } = {}, ...rest } = { a: undefined, x: 1, y: 2 };console.log(a, c, rest);console.log(0 ?? "d", "" ?? "d", null ?? "d", 0 || "d");

The first line is 10 5 { x: 1, y: 2 }. a is undefined, so its default applies; b is missing, so = {} gives an empty object and c takes 5; rest gets the other keys. The second line is 0, an empty string, d, d. ?? replaces only null and undefined, while || replaces every falsy value. The rule: a default applies only to undefined, never to null.

(Senior signal) What is a Proxy? Give a real use and a limit.

A Proxy wraps an object and lets me intercept operations such as get, set, in and delete through trap functions. Inside a trap I call the matching Reflect function to keep the normal behaviour. Real uses: validation on write, logging, default values, and reactivity (Vue 3 tracks reads and writes this way). Limits: every access is slower; a proxy over a Map or a class with private fields breaks, because the real methods need the real object as this; and a proxy cannot report a value that disagrees with a frozen target, or the engine throws. I keep proxies at boundaries, not in hot loops.

(Must know) What is the difference between ES modules and CommonJS? What is a live binding? What does top-level await cost?

ESM uses import and export, is analysed before running, loads asynchronously and is always strict. CommonJS uses require and module.exports, runs the file at the moment of the call, and gives back a copy. With ESM, an import is a read-only live view of the exporter's variable: if the exporter changes count, the importer sees the new value. With CommonJS, const { count } = require(...) copies the number once. Cycles differ too: CommonJS returns a half-filled exports, while ESM can throw ReferenceError: Cannot access 'a' before initialization. Top-level await pauses that module, and every module that imports it waits too, so one slow network call there delays the whole import graph. I keep it for small start-up tasks. For lazy loading I use import(), which bundlers turn into separate files. In Node, .mjs or "type": "module" selects ESM, and since Node 22.12 require() can load an ES module that has no top-level await.

(Must know) Which newer array and object methods do you use, and which would you not use yet without checking?

I use at(-1), findLast, Object.hasOwn, and the copying methods toSorted, toReversed, toSpliced and with, because they leave the original alone, which fits React state. I use Object.groupBy and Map.groupBy for grouping, and Promise.withResolvers to resolve a promise from outside. All are in current browsers and Node 22. Set methods and iterator helpers are also in Node 22, but Safari got the helpers only in 18.4. Promise.try and RegExp.escape need Node 23 and 24 and recent browsers. Temporal is Stage 4 but only in new Chrome, Firefox and Node 26, with Safari in preview, so I use a polyfill. Before any of them I check the table against our browserslist and the Node version in production.

(Senior signal) How do you handle errors well? What are cause, AggregateError and custom errors for?

I throw Error objects, never strings, so there is a stack. When I catch and re-throw, I add context and keep the original: new Error("could not load config", { cause: err }). For custom errors I extend Error, set name, and add fields like status or code, so callers can branch with instanceof. message and stack are not enumerable, so JSON.stringify(err) hides them; I log them on purpose. Promise.any rejects with an AggregateError that has an errors array, and Promise.allSettled never rejects. Two traps: try/catch around an async call without await catches nothing, and in Node 15+ an unhandled rejection ends the process with exit code 1. In a browser I add unhandledrejection and error listeners that report to monitoring.

(Senior signal) A page gets slower the longer it stays open. How do you find a memory leak?

First I confirm it: in DevTools the heap size keeps rising after I repeat an action and force a GC. Then I take a heap snapshot, repeat the action several times, take another, and compare. The retainers view shows what still holds the objects. Usual causes: a listener or setInterval never removed, a cache or global array that only grows, a closure holding a big object, and detached DOM nodes held by a variable. Fixes: remove listeners with an AbortController signal, clear timers on unmount, bound the cache with a limit, or use a WeakMap when data should die with its key. I do not reach for WeakRef first, because I cannot control when it clears.

(Must know) Implement new and bind yourself, then a deep clone that handles cycles. When would you not write the clone yourself?

new makes an object linked to the constructor's prototype, runs the constructor with this set to it, and keeps the constructor's return value only if it is an object or function:

JavaScript
function myNew(Ctor, ...args) {  const o = Object.create(Ctor.prototype);  const r = Ctor.apply(o, args);  return r !== null && (typeof r === "object" || typeof r === "function") ? r : o;}Function.prototype.myBind = function (ctx, ...pre) {  const fn = this;  function bound(...rest) { return fn.apply(new.target ? this : ctx, [...pre, ...rest]) }  if (fn.prototype) bound.prototype = Object.create(fn.prototype);  return bound;};

I check new.target because a bound function used as a constructor must ignore the bound this. For the clone, the key idea is a WeakMap called seen that maps each original to its copy, so cycles stop and shared references stay shared:

JavaScript
function deepClone(v, seen = new WeakMap()) {  if (v === null || typeof v !== "object") return v;  if (seen.has(v)) return seen.get(v);  if (v instanceof Date) return new Date(v);  if (v instanceof Map) {    const m = new Map(); seen.set(v, m);    v.forEach((val, k) => m.set(deepClone(k, seen), deepClone(val, seen)));    return m;  }  const out = Array.isArray(v) ? [] : Object.create(Object.getPrototypeOf(v));  seen.set(v, out);  for (const k of Reflect.ownKeys(v)) out[k] = deepClone(v[k], seen);  return out;}

I tested these on a self-referencing object with a date and a map. This version keeps prototypes but runs getters and skips Set and RegExp; I would add them if I needed them. In a real project I use structuredClone, because it is faster and tested on edge cases (typed arrays, errors, transfer). I write my own only when I must keep class instances.

Published by Jainik · Last updated · How these lessons are made

Keep learning

Part of the study guide

Related series