Test your JavaScript knowledge with these output-based puzzles, organized by topic.
Each problem shows a code snippet β try to predict the output before revealing the answer!
- Type Coercion & the
+Operator β Q1βQ10 - Equality:
==vs===β Q11βQ18 var,let,const& Hoisting β Q19βQ28- Closures & Scope β Q29βQ36
- Promises & Async/Await β Q37βQ46
thisKeyword β Q47βQ54- Prototypes & Classes β Q55βQ62
- Arrays β Tricky Behaviors β Q63βQ70
- Objects β Tricky Behaviors β Q71βQ78
- Event Loop & setTimeout β Q79βQ86
- Spread, Rest & Destructuring β Q87βQ93
- Miscellaneous Mind-Benders β Q94βQ100
JavaScript silently converts types. These questions expose exactly how and when.
console.log(1 + "2" + 3)π‘ Answer
"123"
Why? Left-to-right evaluation: 1 + "2" β "12" (string), then "12" + 3 β "123".
console.log(1 + 2 + "3" + 4 + 5)π‘ Answer
"3345"
Why? 1 + 2 β 3 (both numbers), then 3 + "3" β "33" (string from here), "33" + 4 β "334", "334" + 5 β "3345".
console.log(+"")
console.log(+" ")
console.log(+null)
console.log(+undefined)
console.log(+true)
console.log(+false)π‘ Answer
0
0
0
NaN
1
0
Why? The unary + converts to Number. "" and " " coerce to 0, null β 0, undefined β NaN, true β 1, false β 0.
console.log([] + [])
console.log([] + {})
console.log({} + [])π‘ Answer
""
"[object Object]"
0
Why?
[] + []β"" + ""β""[] + {}β"" + "[object Object]"β"[object Object]"{} + []β when{}is at the start of a statement, JS parses it as an empty block, not an object. So it becomes+[]β+""β0.
console.log(true + true)
console.log(true + false)
console.log(false + false)π‘ Answer
2
1
0
Why? Booleans coerce to numbers: true β 1, false β 0.
console.log("5" - 3)
console.log("5" * "3")
console.log("5" - true)
console.log("5" + - "3")π‘ Answer
2
15
4
"5-3"
Why?
-,*,/always coerce to numbers. So"5" - 3β2,"5" * "3"β15,"5" - trueβ5 - 1β4."5" + - "3": unary-converts"3"to-3, so"5" + (-3)β"5" + -3β"5-3"(string concat because of+).
console.log(null + 1)
console.log(undefined + 1)
console.log(null + undefined)π‘ Answer
1
NaN
NaN
Why? null β 0, undefined β NaN. 0 + 1 β 1, NaN + 1 β NaN, 0 + NaN β NaN.
console.log(0.1 + 0.2 === 0.3)
console.log(0.1 + 0.2)π‘ Answer
false
0.30000000000000004
Why? Floating-point arithmetic in IEEE 754 binary representation causes precision loss. 0.1 + 0.2 is not exactly 0.3. Use Math.abs(a - b) < Number.EPSILON for comparisons.
console.log(+"3" + +"4")
console.log(+"3" + "4")π‘ Answer
7
"34"
Why? +"3" β 3 (number), +"4" β 4 (number) β 3 + 4 β 7. But 3 + "4" β "34" (string concat).
console.log([] == false)
console.log([] == 0)
console.log([] == "")
console.log([] == ![])π‘ Answer
true
true
true
true
Why? [] converts to "" (via .toString()), then "" converts to 0. false β 0. So all compare 0 == 0. For [] == ![]: ![] β false β 0, and [] β 0, so 0 == 0 β true.
Abstract equality (
==) applies type coercion. Strict equality (===) does not.
console.log(null == undefined)
console.log(null === undefined)
console.log(null == 0)
console.log(null == false)π‘ Answer
true
false
false
false
Why? null == undefined is a special rule in JS β they are equal only to each other with ==. null does not coerce to 0 or false with ==.
console.log(NaN == NaN)
console.log(NaN === NaN)
console.log(NaN != NaN)π‘ Answer
false
false
true
Why? NaN is the only value in JS not equal to itself. Use Number.isNaN(val) to check.
console.log(0 == "0")
console.log(0 == "")
console.log("0" == "")π‘ Answer
true
true
false
Why? 0 == "0": "0" coerces to 0 β true. 0 == "": "" coerces to 0 β true. "0" == "": both are strings, compared directly β false (no coercion needed).
console.log(false == "false")
console.log(false == "0")
console.log(false == 0)π‘ Answer
false
true
true
Why? false == "false": false β 0, "false" β NaN β 0 == NaN β false. false == "0": false β 0, "0" β 0 β true. false == 0: false β 0 β true.
console.log(1 < 2 < 3)
console.log(3 > 2 > 1)π‘ Answer
true
false
Why? Left-to-right: 1 < 2 β true, then true < 3 β 1 < 3 β true. 3 > 2 β true, then true > 1 β 1 > 1 β false.
console.log(typeof null)
console.log(null instanceof Object)π‘ Answer
"object"
false
Why? typeof null === "object" is a historic bug in JS that was never fixed (it would break the web). But null instanceof Object correctly returns false since null has no prototype chain.
console.log(typeof NaN)
console.log(typeof undefined)
console.log(typeof function(){})
console.log(typeof class {})π‘ Answer
"number"
"undefined"
"function"
"function"
Why? NaN is of type "number" (it's "Not a Number" but still a number type). Classes are syntactic sugar over functions, so typeof class {} β "function".
console.log([] == ![])
console.log({} == !{})π‘ Answer
true
false
Why? ![] β false β 0. [] β "" β 0. So 0 == 0 β true. !{} β false β 0. {} β "[object Object]" β NaN. NaN == 0 β false.
Hoisting moves declarations to the top of their scope. But only
varis initialized withundefined.
console.log(a)
var a = 5
console.log(a)π‘ Answer
undefined
5
Why? var declarations are hoisted and initialized to undefined. So by the time console.log(a) runs, a exists but has no value yet.
console.log(b)
let b = 5π‘ Answer
ReferenceError: Cannot access 'b' before initialization
Why? let (and const) are hoisted but not initialized. Accessing them before their declaration is in the Temporal Dead Zone (TDZ).
var x = 1
function foo() {
console.log(x)
var x = 2
console.log(x)
}
foo()
console.log(x)π‘ Answer
undefined
2
1
Why? Inside foo, var x is hoisted to the top of the function scope. So the first console.log sees the local x (hoisted, undefined), not the global x = 1.
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 0)
}π‘ Answer
3
3
3
Why? var is function-scoped (not block-scoped). By the time the callbacks run, the loop is done and i === 3. All three closures share the same i.
for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 0)
}π‘ Answer
0
1
2
Why? let is block-scoped. Each iteration creates a new binding of i, so each closure captures its own copy.
function test() {
console.log(typeof foo)
console.log(typeof bar)
var foo = "hello"
function bar() {}
}
test()π‘ Answer
"undefined"
"function"
Why? var foo is hoisted but initialized as undefined. function bar() is fully hoisted β both its declaration AND its body.
const obj = { a: 1 }
obj.a = 2
obj.b = 3
console.log(obj)
obj = {} // What happens?π‘ Answer
{ a: 2, b: 3 }
TypeError: Assignment to constant variable.
Why? const prevents reassignment of the binding, but the object it points to is still mutable. You can change properties, but not the reference itself.
var a = 1
{
var a = 2
console.log(a)
}
console.log(a)π‘ Answer
2
2
Why? var ignores blocks β it's function-scoped. The inner var a = 2 overwrites the outer a. Both logs see the same a.
let a = 1
{
let a = 2
console.log(a)
}
console.log(a)π‘ Answer
2
1
Why? let is block-scoped. The inner a is a separate variable that only exists inside the {}. The outer a is untouched.
console.log(foo())
console.log(bar())
function foo() { return "foo" }
var bar = function() { return "bar" }π‘ Answer
"foo"
TypeError: bar is not a function
Why? Function declarations are fully hoisted. bar is a var β hoisted as undefined, so calling undefined() throws a TypeError.
A closure is a function that remembers the variables from its outer scope even after that scope has exited.
function makeCounter() {
let count = 0
return function() {
return ++count
}
}
const counter1 = makeCounter()
const counter2 = makeCounter()
console.log(counter1())
console.log(counter1())
console.log(counter2())
console.log(counter1())π‘ Answer
1
2
1
3
Why? Each call to makeCounter() creates a new closure with its own count. counter1 and counter2 are independent.
function outer() {
let x = 10
function inner() {
let x = 20
console.log(x)
}
inner()
console.log(x)
}
outer()π‘ Answer
20
10
Why? inner has its own x = 20 in its local scope. The outer x = 10 is unchanged β each function has its own scope.
function createFunctions() {
const funcs = []
for (var i = 0; i < 3; i++) {
funcs.push(function() { return i })
}
return funcs
}
const fns = createFunctions()
console.log(fns[0]())
console.log(fns[1]())
console.log(fns[2]())π‘ Answer
3
3
3
Why? Classic closure-in-loop trap. var i is shared across all iterations. By the time any function runs, i is 3.
// Fix for Q31 using IIFE
function createFunctions() {
const funcs = []
for (var i = 0; i < 3; i++) {
funcs.push((function(j) {
return function() { return j }
})(i))
}
return funcs
}
const fns = createFunctions()
console.log(fns[0]())
console.log(fns[1]())
console.log(fns[2]())π‘ Answer
0
1
2
Why? The IIFE immediately captures the current value of i as j. Each function closes over its own j.
let x = "global"
function foo() {
console.log(x)
}
function bar() {
let x = "local"
foo()
}
bar()π‘ Answer
"global"
Why? JavaScript uses lexical scoping (where the function is defined, not called). foo is defined in the global scope, so it sees the global x, not bar's local x.
function a() {
let n = 0
function b() { n++ }
function c() { console.log(n) }
b(); b(); b()
c()
}
a()π‘ Answer
3
Why? b and c both close over the same n. Calling b() three times increments the shared n to 3.
const add = (function() {
let total = 0
return function(n) {
total += n
return total
}
})()
console.log(add(5))
console.log(add(3))
console.log(add(2))π‘ Answer
5
8
10
Why? The IIFE creates a private total that persists across calls via closure. This is the module pattern.
function foo(x) {
return function(y) {
return function(z) {
return x + y + z
}
}
}
console.log(foo(1)(2)(3))π‘ Answer
6
Why? Currying β each function closes over the previous arguments. x=1, y=2, z=3 β 1+2+3 β 6.
Async code runs in the event loop. Understanding microtasks vs macrotasks is key.
console.log("1")
setTimeout(() => console.log("2"), 0)
Promise.resolve().then(() => console.log("3"))
console.log("4")π‘ Answer
1
4
3
2
Why? Synchronous code runs first (1, 4). Then microtasks (Promise .then) run before macrotasks (setTimeout). So 3 before 2.
Promise.resolve(1)
.then(x => x + 1)
.then(x => { throw new Error("oops") })
.catch(e => e.message)
.then(x => console.log(x))π‘ Answer
"oops"
Why? The error thrown in .then is caught by .catch. .catch returns the error message "oops", and the final .then logs it.
async function foo() {
return 1
}
foo().then(console.log)π‘ Answer
1
Why? An async function always returns a Promise. return 1 is equivalent to Promise.resolve(1).
async function foo() {
console.log("A")
await Promise.resolve()
console.log("B")
}
console.log("C")
foo()
console.log("D")π‘ Answer
C
A
D
B
Why? C runs first (sync). foo() starts: logs A, then hits await β suspends and returns control. D logs (sync). Then the microtask queue runs: B logs.
async function foo() {
const p = new Promise((resolve) => {
setTimeout(resolve, 100)
})
await p
return "done"
}
foo().then(console.log)
console.log("sync")π‘ Answer
"sync"
"done"
Why? foo() awaits a 100ms timer. "sync" logs immediately. After 100ms, "done" is logged.
async function fail() {
throw new Error("async error")
}
fail()
.catch(e => console.log("caught:", e.message))π‘ Answer
caught: async error
Why? throw inside an async function causes the returned promise to reject. .catch handles it.
Promise.all([
Promise.resolve(1),
Promise.reject("error"),
Promise.resolve(3),
]).then(console.log).catch(console.error)π‘ Answer
"error"
Why? Promise.all fails fast β if any promise rejects, the whole thing rejects with that reason. The resolved values are discarded.
async function fetchAll() {
const a = await Promise.resolve("A")
const b = await Promise.resolve("B")
const c = await Promise.resolve("C")
return [a, b, c]
}
fetchAll().then(console.log)π‘ Answer
["A", "B", "C"]
Why? Each await resolves sequentially. The final return resolves the async function's promise with the array.
const p = new Promise((resolve) => {
resolve(1)
resolve(2)
resolve(3)
})
p.then(console.log)π‘ Answer
1
Why? A Promise can only settle once. The first resolve(1) locks it in. The subsequent resolves are ignored.
async function main() {
try {
const result = await Promise.reject(new Error("fail"))
} catch (e) {
console.log("caught:", e.message)
} finally {
console.log("finally")
}
}
main()π‘ Answer
caught: fail
finally
Why? await unwraps the rejected promise and throws the error, which is caught by try/catch. finally always runs.
thisdepends on how a function is called, not where it's defined β except in arrow functions.
const obj = {
name: "Alice",
greet: function() {
console.log(this.name)
}
}
obj.greet()
const fn = obj.greet
fn()π‘ Answer
"Alice"
undefined
Why? obj.greet() β this is obj. fn() β called without context, this is undefined (strict mode) or globalThis (sloppy mode), which has no name.
const obj = {
name: "Bob",
greet: () => {
console.log(this.name)
}
}
obj.greet()π‘ Answer
undefined
Why? Arrow functions don't have their own this. They inherit this from the enclosing lexical scope. Here that's the module/global scope, which has no name.
function Person(name) {
this.name = name
this.sayHi = function() {
console.log("Hi, I'm " + this.name)
}
this.sayHiArrow = () => {
console.log("Hi, I'm " + this.name)
}
}
const p = new Person("Carol")
const hi = p.sayHi
const hiArrow = p.sayHiArrow
hi()
hiArrow()π‘ Answer
"Hi, I'm undefined"
"Hi, I'm Carol"
Why? hi() loses context β this is global/undefined. hiArrow is an arrow function capturing this from the constructor (the Person instance), so it always knows this.name.
const obj = {
x: 10,
getX: function() {
const inner = function() {
return this.x
}
return inner()
}
}
console.log(obj.getX())π‘ Answer
undefined
Why? inner() is a regular function called without context. Inside inner, this is globalThis (or undefined in strict mode), not obj. Fix: use arrow function or const self = this.
const obj = {
x: 10,
getX: function() {
const inner = () => this.x
return inner()
}
}
console.log(obj.getX())π‘ Answer
10
Why? Arrow function inherits this from getX, where this is obj. So this.x β 10.
function greet() {
console.log(this.name)
}
const user = { name: "Dave" }
const bound = greet.bind(user)
bound()
bound.call({ name: "Eve" })π‘ Answer
"Dave"
"Dave"
Why? .bind() permanently locks this to user. Even calling .call() with a different context cannot override a bound function's this.
class Timer {
constructor() {
this.seconds = 0
}
start() {
setInterval(function() {
this.seconds++
console.log(this.seconds)
}, 1000)
}
}
new Timer().start()π‘ Answer
NaN (repeatedly)
Why? The callback passed to setInterval is a regular function. this inside it is globalThis (not the Timer instance). globalThis.seconds is undefined, and undefined++ is NaN.
const obj = { val: 42 }
function logVal() {
console.log(this.val)
}
logVal.call(obj)
logVal.apply(obj)
logVal.bind(obj)()π‘ Answer
42
42
42
Why? call, apply, and bind all set this to obj. call/apply invoke immediately. bind returns a new function that you must call ().
function Animal(name) {
this.name = name
}
Animal.prototype.speak = function() {
return `${this.name} makes a sound`
}
const dog = new Animal("Rex")
console.log(dog.speak())
console.log(dog.hasOwnProperty("name"))
console.log(dog.hasOwnProperty("speak"))π‘ Answer
"Rex makes a sound"
true
false
Why? name is set directly on the instance (own property). speak is on the prototype, not the instance.
class A {
constructor() {
this.x = 1
}
}
class B extends A {
constructor() {
super()
this.y = 2
}
}
const b = new B()
console.log(b.x, b.y)
console.log(b instanceof B)
console.log(b instanceof A)π‘ Answer
1 2
true
true
Why? super() calls the parent constructor, setting this.x = 1. instanceof checks the prototype chain β B extends A, so b is an instance of both.
class Counter {
#count = 0
increment() { this.#count++ }
get value() { return this.#count }
}
const c = new Counter()
c.increment()
c.increment()
console.log(c.value)
console.log(c.#count)π‘ Answer
2
SyntaxError: Private field '#count' must be declared in an enclosing class
Why? #count is a private class field β accessible only inside the class body. Trying to access it from outside throws a SyntaxError.
function Foo() {}
const f = new Foo()
console.log(f.__proto__ === Foo.prototype)
console.log(Foo.prototype.constructor === Foo)
console.log(f.constructor === Foo)π‘ Answer
true
true
true
Why? f.__proto__ points to Foo.prototype. Every prototype has a constructor property pointing back to its constructor function.
class Animal {
constructor(name) {
this.name = name
}
speak() {
return `${this.name} speaks`
}
}
class Dog extends Animal {
speak() {
return super.speak() + " (woof!)"
}
}
const d = new Dog("Rex")
console.log(d.speak())π‘ Answer
"Rex speaks (woof!)"
Why? super.speak() calls the parent's speak method. The result is concatenated.
const obj = Object.create(null)
console.log(obj.toString)
console.log(typeof obj)π‘ Answer
undefined
"object"
Why? Object.create(null) creates an object with no prototype β it has none of the built-in methods like toString, hasOwnProperty, etc.
class MyClass {
static count = 0
constructor() {
MyClass.count++
}
}
new MyClass()
new MyClass()
new MyClass()
console.log(MyClass.count)π‘ Answer
3
Why? static count belongs to the class itself, not instances. Each new MyClass() increments the shared MyClass.count.
const proto = {
greet() { return `Hi, I'm ${this.name}` }
}
const obj = Object.create(proto)
obj.name = "Frank"
console.log(obj.greet())
console.log(obj.hasOwnProperty("greet"))
console.log(obj.hasOwnProperty("name"))π‘ Answer
"Hi, I'm Frank"
false
true
Why? greet is on the prototype, not obj itself. name was directly set on obj.
console.log([1, 2, 3].map(parseInt))π‘ Answer
[1, NaN, NaN]
Why? map passes three args: (element, index, array). parseInt takes (string, radix). So: parseInt(1, 0) β 1 (radix 0 treated as 10), parseInt(2, 1) β NaN (radix 1 invalid), parseInt(3, 2) β NaN (3 is not a valid base-2 number).
const arr = [1, 2, 3]
arr[10] = 11
console.log(arr.length)
console.log(arr[5])π‘ Answer
11
undefined
Why? Setting index 10 creates a sparse array with length of 11. The slots in between ([3] to [9]) are empty holes, returning undefined when accessed.
const a = [1, 2, 3]
const b = [1, 2, 3]
console.log(a == b)
console.log(a === b)
console.log(a.toString() === b.toString())π‘ Answer
false
false
true
Why? Arrays are objects β compared by reference, not value. a and b are different objects in memory. But .toString() converts both to "1,2,3" β same string.
console.log([1, [2, [3]]].flat())
console.log([1, [2, [3]]].flat(Infinity))π‘ Answer
[1, 2, [3]]
[1, 2, 3]
Why? .flat() with no argument defaults to depth 1. .flat(Infinity) flattens all levels.
const arr = [3, 1, 2, 10, 20]
console.log(arr.sort())π‘ Answer
[1, 10, 2, 20, 3]
Why? .sort() with no comparator converts elements to strings and sorts lexicographically. "10" < "2" because "1" < "2". Always use arr.sort((a, b) => a - b) for numbers.
const arr = [1, 2, 3, 4, 5]
const result = arr.reduce((acc, curr) => {
if (curr % 2 === 0) acc.push(curr * 2)
return acc
}, [])
console.log(result)π‘ Answer
[4, 8]
Why? Only even numbers (2, 4) pass the condition. Each is doubled. Equivalent to .filter().map() in one pass.
const arr = [1, 2, 3]
arr.forEach((item, i) => {
if (item === 2) arr.splice(i, 1)
})
console.log(arr)π‘ Answer
[1, 3]
But β mutating an array during forEach is dangerous. If you splice element at index 1, the remaining items shift, and the iterator can skip elements depending on the case.
console.log(typeof [])
console.log(Array.isArray([]))
console.log([] instanceof Array)π‘ Answer
"object"
true
true
Why? typeof [] returns "object" (arrays are objects). Use Array.isArray() for reliable array detection, especially across iframes.
const key = "name"
const obj = { [key]: "Alice" }
console.log(obj.name)
console.log(obj[key])
console.log(obj["name"])π‘ Answer
"Alice"
"Alice"
"Alice"
Why? Computed property names [key] use the variable's value as the key. All three accesses are equivalent.
const obj = { a: 1, b: 2, c: 3 }
const { a, ...rest } = obj
console.log(a)
console.log(rest)π‘ Answer
1
{ b: 2, c: 3 }
Why? Rest syntax in destructuring collects all remaining own enumerable properties into a new object.
const a = { x: 1 }
const b = { ...a }
b.x = 99
console.log(a.x)
console.log(b.x)π‘ Answer
1
99
Why? Spread creates a shallow copy. Primitive values are copied by value. Changing b.x doesn't affect a.
const a = { nested: { val: 1 } }
const b = { ...a }
b.nested.val = 99
console.log(a.nested.val)π‘ Answer
99
Why? Spread is shallow. b.nested points to the same object as a.nested. Mutating it affects both.
const obj = {}
obj[true] = "boolean"
obj[1] = "number"
obj["1"] = "string"
console.log(Object.keys(obj))
console.log(obj[1])π‘ Answer
["true", "1"]
"string"
Why? Object keys are always strings (or Symbols). true β "true". 1 and "1" are the same key β the last write ("string") wins.
const obj = { a: 1 }
Object.freeze(obj)
obj.a = 999
obj.b = 2
console.log(obj.a)
console.log(obj.b)π‘ Answer
1
undefined
Why? Object.freeze() prevents adding, deleting, or modifying properties. In non-strict mode, these operations silently fail.
function Person(name) {
this.name = name
}
const p = new Person("Grace")
console.log(p.name)
// What if we forget `new`?
const q = Person("Henry")
console.log(q)
console.log(globalThis.name) // in browser: window.nameπ‘ Answer
"Grace"
undefined
"Henry"
Why? Without new, Person runs as a regular function. this is globalThis, so name is set globally. The function returns undefined by default.
const obj = {
0: "zero",
1: "one",
length: 2
}
console.log(Array.from(obj))
console.log([...obj])π‘ Answer
["zero", "one"]
TypeError: obj is not iterable
Why? Array.from() works on array-like objects (has length + numeric keys). But spread [...] requires the object to be iterable (have [Symbol.iterator]). Plain objects are not iterable.
console.log("start")
setTimeout(() => console.log("timeout 1"), 0)
setTimeout(() => console.log("timeout 2"), 0)
Promise.resolve().then(() => console.log("promise 1"))
Promise.resolve().then(() => console.log("promise 2"))
console.log("end")π‘ Answer
start
end
promise 1
promise 2
timeout 1
timeout 2
Why? Order: synchronous β microtasks (Promises) β macrotasks (setTimeout). Both promises resolve before either timeout callback runs.
setTimeout(() => console.log("A"), 0)
setTimeout(() => console.log("B"), 100)
setTimeout(() => console.log("C"), 0)π‘ Answer
A
C
B
Why? A and C both have 0ms delay β they run in order of registration. B runs after ~100ms.
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), i * 100)
}π‘ Answer
3
3
3
Why? var i is shared. All three callbacks run after the loop completes, reading the final i = 3.
console.log("A")
setTimeout(function() {
console.log("B")
Promise.resolve().then(() => console.log("C"))
}, 0)
Promise.resolve().then(function() {
console.log("D")
setTimeout(() => console.log("E"), 0)
})
console.log("F")π‘ Answer
A
F
D
B
C
E
Why?
- Sync:
A,F - Microtasks:
D(queuesEas a macrotask) - Macrotasks:
B(queuesCas microtask), then drain microtasks βC, then next macrotask βE
async function main() {
console.log("1")
await null
console.log("2")
await null
console.log("3")
}
console.log("before")
main()
console.log("after")π‘ Answer
before
1
after
2
3
Why? main() starts synchronously (logs 1). Each await yields to the microtask queue. "after" logs before the awaited continuations.
const p = new Promise((resolve) => {
console.log("executor")
resolve("done")
console.log("after resolve")
})
p.then(console.log)
console.log("sync")π‘ Answer
executor
after resolve
sync
done
Why? The Promise executor runs synchronously (immediately). "after resolve" runs even after resolve() is called. The .then callback is a microtask β runs after all sync code.
setTimeout(() => console.log("macro"), 0)
queueMicrotask(() => console.log("micro 1"))
Promise.resolve().then(() => {
console.log("micro 2")
queueMicrotask(() => console.log("micro 3"))
})
queueMicrotask(() => console.log("micro 4"))π‘ Answer
micro 1
micro 2
micro 4
micro 3
macro
Why? All microtasks (queueMicrotask and Promise .then) run before macrotasks. micro 3 is queued during microtask processing β it still runs before the macrotask.
function delay(ms) {
return new Promise(res => setTimeout(res, ms))
}
async function run() {
console.log("start")
await delay(100)
console.log("after 100ms")
await delay(200)
console.log("after 300ms total")
}
run()π‘ Answer
start
(100ms later) after 100ms
(200ms later) after 300ms total
Why? Each await delay(ms) pauses execution for that duration. Total time β 300ms. This is the correct way to use sequential async delays.
function sum(...args) {
return args.reduce((a, b) => a + b, 0)
}
console.log(sum(1, 2, 3))
console.log(sum(...[1, 2, 3]))
console.log(sum(...[1, 2], 3))π‘ Answer
6
6
6
Why? Rest (...args) collects all arguments into an array. Spread (...[...]) expands an array into individual arguments. All three calls pass 1, 2, 3.
const [a, , b, c = 99] = [1, 2, 3]
console.log(a, b, c)π‘ Answer
1 3 99
Why? The second element is skipped (, ,). b gets the third element 3. c has a default of 99 β since the array has no 4th element, the default is used.
const obj = { a: 1, b: { c: 2 } }
const { a, b: { c } } = obj
console.log(a)
console.log(c)
console.log(b)π‘ Answer
1
2
ReferenceError: b is not defined
Why? b: { c } is nested destructuring β b is used as a pattern, not bound as a variable. Only c is extracted. a and c exist; b does not.
const arr1 = [1, 2, 3]
const arr2 = [4, 5, 6]
const combined = [...arr1, ...arr2]
console.log(combined)
const [first, ...rest] = combined
console.log(first, rest)π‘ Answer
[1, 2, 3, 4, 5, 6]
1 [2, 3, 4, 5, 6]
Why? Spread combines arrays. Rest in destructuring captures everything after the first element.
function greet({ name = "World", greeting = "Hello" } = {}) {
return `${greeting}, ${name}!`
}
console.log(greet({ name: "Alice" }))
console.log(greet())
console.log(greet({ greeting: "Hey", name: "Bob" }))π‘ Answer
"Hello, Alice!"
"Hello, World!"
"Hey, Bob!"
Why? Destructured parameters with defaults. The = {} default allows calling with no argument at all.
let a = 1, b = 2
;[a, b] = [b, a]
console.log(a, b)π‘ Answer
2 1
Why? Destructuring swap β no temp variable needed. The right side [b, a] creates a new array [2, 1], then destructures back into a and b.
const matrix = [[1, 2], [3, 4], [5, 6]]
const [[a, b], [c], [, f]] = matrix
console.log(a, b, c, f)π‘ Answer
1 2 3 6
Why? Nested array destructuring. [c] only captures the first element of [3, 4]. [, f] skips first and takes second (6).
console.log(typeof typeof 42)π‘ Answer
"string"
Why? typeof 42 β "number" (a string). typeof "number" β "string".
console.log(0.1 + 0.2 == 0.3)
console.log(9007199254740992 === 9007199254740993)π‘ Answer
false
true
Why? Floating-point precision. And 9007199254740992 is Number.MAX_SAFE_INTEGER. Beyond this, integers can't be precisely represented β both values map to the same float.
console.log(!!"")
console.log(!!0)
console.log(!!" ")
console.log(!!"false")
console.log(!!null)
console.log(!![])
console.log(!!{})π‘ Answer
false
false
true
true
false
true
true
Why? Falsy values: "", 0, null, undefined, NaN, false. Everything else is truthy β including " " (non-empty), "false" (non-empty string), [], {}.
const fn = (a = b, b = 1) => [a, b]
console.log(fn(undefined, 2))
console.log(fn(undefined, undefined))π‘ Answer
[2, 2]
ReferenceError: Cannot access 'b' before initialization
Why? Default parameters are evaluated left to right. In fn(undefined, 2), a defaults to b which is 2. In fn(), a defaults to b, but b hasn't been initialized yet β TDZ error.
console.log(1..toString())
console.log((1).toString())
console.log(1 .toString())π‘ Answer
"1"
"1"
"1"
Why? You can't write 1.toString() directly β the . is parsed as the decimal point. 1..toString() uses the second dot for method access. Parens or a space also resolve the ambiguity.
function* gen() {
yield 1
yield 2
yield 3
}
const g = gen()
console.log(g.next())
console.log(g.next())
console.log(g.next())
console.log(g.next())π‘ Answer
{ value: 1, done: false }
{ value: 2, done: false }
{ value: 3, done: false }
{ value: undefined, done: true }
Why? Generators pause at each yield. .next() resumes until the next yield or return. After the last yield, done: true and value: undefined.
const obj = {
[Symbol.toPrimitive](hint) {
if (hint === "number") return 42
if (hint === "string") return "hello"
return true
}
}
console.log(+obj)
console.log(`${obj}`)
console.log(obj + "")π‘ Answer
42
"hello"
"true"
Why? [Symbol.toPrimitive] lets you control type conversion. +obj (unary) β hint "number" β 42. Template literal β hint "string" β "hello". obj + "" β hint "default" β true, then "true" + "" β "true".
| Trap | Always Remember |
|---|---|
+ with strings |
Any string β concatenation |
== coercion |
Use === unless you know why you need == |
var hoisting |
Use let/const exclusively in modern JS |
| Closure in loops | let creates per-iteration scope; var does not |
this in callbacks |
Use arrow functions or .bind() |
Promise.all |
Fails fast β use allSettled for resilience |
Array .sort() |
Always pass a comparator for numbers |
| Shallow copy | Spread and Object.assign are shallow |
| Microtasks vs macrotasks | Promises before setTimeout, always |
| Floating point | Never compare floats with === directly |