JavaScript and TypeScript interview questions

13 questions on JavaScript and TypeScript: closures, the event loop, promises, this binding, equality, optional chaining, type versus interface, unions and narrowing, and generics.

01JS / TSFoundation

let, const, var

Answer

  • var is function-scoped and hoisted. Redeclaration in the same function is allowed. Temporal dead zone does not apply the same way as let.
  • let and const are block-scoped. They are hoisted but stay in the temporal dead zone until initialization.
  • const cannot be reassigned. The binding is fixed. Object and array contents can still mutate unless you freeze them.
  • Prefer const by default. Use let when reassignment is required. Avoid var in modern application code.
  • Loop bindings: let creates a new binding per iteration, which fixes classic closure-in-loop bugs that var shows.
  • Pitfall: const user = {}; user.name = 'Ada' is allowed. const only protects the binding, not deep immutability.
  • Shadowing inner block lets can hide outer bindings and confuse readers. Prefer clear names over reuse.
  • In modules and strict mode, accidental globals from missing declarations throw. Always declare.
  • Interview tip: contrast function scope versus block scope with a short for-loop example.
Flow diagram: let const var
TS
const rates = { usd: 1 }; rates.usd = 1.1; // allowed let attempts = 0; attempts += 1;
02JS / TSFoundation

JavaScript Closures and Lexical Scoping

Answer

  • A closure is the combination of a function bundled together with references to its surrounding lexical environment. A closure gives an inner function access to an outer function's scope even after the outer function has finished executing and returned.
  • Lexical Scoping: JavaScript resolves variable names based on the physical nesting of functions in source code. When a function executes, it holds a hidden reference to its outer Environment Record, forming a Scope Chain.
  • Memory Retention: Variables captured by a closure are not garbage-collected when the parent function exits. They remain allocated on the heap for as long as any active function retains a reference to that closure.
  • Primary Use Cases: Data encapsulation and private state (Module Pattern, factory functions), function currying and partial application, and event callback handling.
Flow diagram: Closure scope chain retaining outer variables on the heap
JavaScript (Private Encapsulation via Closure)
function createBankAccount(initialBalance) { let balance = initialBalance; // Private state, inaccessible from outside return { deposit(amount) { if (amount <= 0) throw new Error("Deposit must be positive"); balance += amount; return balance; }, withdraw(amount) { if (amount > balance) throw new Error("Insufficient funds"); balance -= amount; return balance; }, getBalance() { return balance; } }; } const account = createBankAccount(100); account.deposit(50); // 150 console.log(account.getBalance()); // 150 console.log(account.balance); // undefined (Private state safely protected)
03JS / TSFoundation

JavaScript Event Loop: Call Stack, Microtasks, and Macrotasks

Answer

  • JavaScript has a single-threaded runtime model based on an Event Loop. The execution engine runs code sequentially on a single Call Stack and delegates asynchronous tasks (such as network calls and timers) to the browser or host environment.
  • Call Stack: Synchronous code executes immediately by pushing and popping execution frames.
  • Microtask Queue: Contains Promise callbacks (.then, .catch, .finally), queueMicrotask, and MutationObserver callbacks. Microtasks have higher priority than macrotasks; once the Call Stack empties, the engine exhausts the entire Microtask Queue before moving forward.
  • Macrotask Queue (Task Queue): Contains callbacks from setTimeout, setInterval, setImmediate, and I/O events. The Event Loop dequeues one macrotask per tick, exhausts all microtasks spawned by it, allows the browser to re-render the DOM if needed, and moves to the next tick.
Flow diagram: Event Loop priority: Call Stack to Microtasks to Render to Macrotasks
JavaScript
console.log("1. Synchronous"); setTimeout(() => { console.log("4. Macrotask (setTimeout)"); }, 0); Promise.resolve().then(() => { console.log("2. Microtask (Promise)"); }).then(() => { console.log("3. Chained Microtask"); }); console.log("Synchronous End"); // Output Order: // 1. Synchronous // Synchronous End // 2. Microtask (Promise) // 3. Chained Microtask // 4. Macrotask (setTimeout)
04JS / TSFoundation

this and arrow functions

Answer

  • In ordinary functions, this depends on how the function is called (object method, call/apply/bind, new, or bare call).
  • Arrow functions do not have their own this. They capture this from the surrounding lexical scope.
  • Class methods passed as bare callbacks lose the instance unless you bind them or use a class field arrow.
  • In strict mode, a bare call leaves this as undefined. Reading this.prop then throws.
  • call, apply, and bind set this explicitly for non-arrow functions.
  • Pitfall: using an arrow when you intentionally want this from the call site (for example some dynamic method patterns).
  • Prefer arrows for inline callbacks that should see the outer this. Prefer ordinary methods when the receiver should be the object.
  • React class components historically used bind or arrows for handlers. Function components avoid most this issues.
  • Interview tip: predict this for obj.method(), const f = obj.method; f(), and an arrow property.
Flow diagram: this binding
TS
class Timer { seconds = 0; start() { setInterval(() => { this.seconds += 1; }, 1000); } }
05JS / TSFoundation

== vs ===

Answer

  • === and !== compare without converting types. Values of different types are not equal.
  • == and != coerce types with abstract equality rules that include surprising cases ('' == 0 is true, null == undefined is true).
  • Use === and !== in application code unless you intentionally want coercion and document why.
  • Object.is handles NaN and +0/-0 more precisely than === for those edge cases.
  • Objects and arrays compare by reference with ===. Two different objects with the same fields are not equal.
  • Pitfall: sorting or filtering with == and mixed string/number ids that coerce accidentally.
  • Prefer Number.isNaN for NaN checks. NaN === NaN is false.
  • TypeScript's strict equality lint rules and eslint eqeqeq reinforce the same habit.
  • Interview tip: list two == surprises and say you use === by default.
Flow diagram: == vs ===
TS
0 === false; // false 0 == false; // true (avoid)
06JS / TSFoundation

Optional chaining and ??

Answer

  • Optional chaining (?.) short-circuits and returns undefined when it hits null or undefined mid-path.
  • obj?.prop, obj?.[key], and fn?.() are the common forms. They do not catch other runtime errors.
  • Nullish coalescing (??) uses the right side only when the left is null or undefined. It keeps 0, false, and '' as valid values.
  • || also treats 0, false, and '' as missing, which is wrong when those are legitimate data.
  • Combine carefully: a?.b ?? default reads b when a exists, otherwise default when the left of ?? is nullish.
  • Pitfall: overusing ?. and hiding programming errors that should throw when a value must exist.
  • TypeScript narrows after null checks. Optional chaining still types the result as possibly undefined.
  • Prefer ?? for defaulting configuration numbers and strings that may be empty only when null means missing.
  • Interview tip: contrast ?? with || using 0 as a real value.
Flow diagram: Optional and nullish
TS
const label = user?.profile?.displayName ?? "Anonymous"; const count = storedCount ?? 0;
07JS / TSFoundation

type vs interface

Answer

  • interface describes object shapes. It can extend other interfaces and supports declaration merging across files.
  • type aliases can name unions, intersections, primitives, tuples, mapped types, and object shapes.
  • For plain object shapes, either works. Many teams use interface for objects and type for unions and utility compositions.
  • Declaration merging applies to interfaces, not to type aliases. Duplicate interface blocks combine.
  • extends on interfaces and intersections on types both compose shapes. Prefer the form your team standardizes.
  • Pitfall: expecting type aliases to merge like interfaces when augmenting third-party types.
  • Use readonly and optional (?) properties to document mutation and presence rules.
  • Prefer unknown over any at boundaries. Narrow before use.
  • Interview tip: give one case that requires type (a union) and one case where interface merging helps.
Flow diagram: type vs interface
TS
interface User { id: string; email: string; } type Result<T> = { ok: true; value: T } | { ok: false; error: string };
08JS / TSIntermediate

Promises and async/await

Answer

  • A Promise represents a future result with states pending, fulfilled, or rejected. Settlement is one-way.
  • async functions always return Promises. await pauses the async function until the awaited promise settles, then continues with the value or throws on rejection.
  • Always handle rejection with try/catch around await, or with .catch on the promise chain.
  • Promise.all fails fast on the first rejection. Promise.allSettled waits for every input to settle.
  • Promise.race settles with the first success or failure. Promise.any fulfills with the first fulfillment and rejects only if all reject.
  • Map async work carefully. forEach does not await. Use for...of with await or Promise.all on a mapped array.
  • Pitfall: floating promises without catch become unhandled rejections.
  • Prefer async/await for linear control flow. Use combinators when you need concurrency patterns.
  • Do not wrap an existing promise in new Promise unless you are adapting a callback API.
State diagram: Promise states
TS
async function loadUser(id: string) { const res = await fetch("/api/users/" + id); if (!res.ok) throw new Error("HTTP " + res.status); return res.json(); }
09JS / TSIntermediate

Union types and narrowing

Answer

  • Union types allow a value to be one of several shapes. Narrow before you access members that are not shared.
  • Narrow with typeof, instanceof, in, equality checks, and user-defined type predicates (value is Type).
  • Discriminated unions share a tag field (for example kind: 'ok' | 'err'). Switch on the tag for safe field access.
  • unknown is safer than any. You must narrow unknown before calling or reading properties.
  • Control flow analysis follows returns, throws, and early continues. After if (!x) return, x is narrowed in the rest of the block.
  • Pitfall: using as Type assertions to silence errors without proving the value matches.
  • never appears when a switch is exhaustive. Use it to catch missing union members at compile time.
  • Optional properties and nullability interact with strictNullChecks. Enable that flag for real narrowing benefits.
  • Interview tip: write a small Result union and show a switch that TypeScript accepts as exhaustive.
Flow diagram: Narrowing
TS
type Shape = | { kind: "circle"; radius: number } | { kind: "square"; size: number }; function area(shape: Shape) { switch (shape.kind) { case "circle": return Math.PI * shape.radius ** 2; case "square": return shape.size ** 2; } }
10JS / TSIntermediate

TypeScript generics

Answer

  • Generics parameterize types so functions, classes, and containers stay reusable without losing type safety.
  • Constraints (T extends SomeShape) limit what type arguments are allowed and unlock property access on T.
  • Infer type arguments from usage when possible instead of repeating annotations at every call site.
  • Common patterns: identity helpers, Array wrappers, React component props that depend on a data type, and API client return types.
  • Defaults (T = string) set a fallback when callers omit the argument.
  • Pitfall: over-generalizing until constraints become any and the generic stops helping.
  • keyof, mapped types, and conditional types build on generics for advanced utilities (Pick, Omit, and custom helpers).
  • Prefer generic functions over writing the same function once per concrete type.
  • Interview tip: implement a typed map or wrap that preserves the element type through the call.
Flow diagram: Generics
TS
function first<T>(items: T[]): T | undefined { return items[0]; } const name = first(["a", "b"]); // string | undefined
11JS / TSIntermediate

Debounce

Answer

  • Debounce delays running a function until calls stop for a wait period. Each new call resets the timer.
  • Useful for search-as-you-type, resize handlers, and autosave where only the quiet moment matters.
  • Throttle runs at most once per interval and is a different tool for scroll metrics or pointer tracking.
  • Implement with setTimeout and clearTimeout. Return a cancel function or flush method when the UI needs them.
  • In React, store the timer id in a ref and clear it on unmount so late calls do not set state after unmount.
  • Pitfall: creating a new debounced function every render, which breaks cancellation and multiplies timers.
  • Leading versus trailing edge variants exist. Trailing edge is the usual search-box behavior.
  • Prefer requestAnimationFrame for visual frame-aligned work rather than debounce alone.
  • Interview tip: contrast debounce and throttle with one sentence each and a concrete UI example.
Sequence diagram: Debounce
TS
function debounce<T extends (...args: never[]) => void>(fn: T, waitMs: number) { let timer: ReturnType<typeof setTimeout> | undefined; return (...args: Parameters<T>) => { clearTimeout(timer); timer = setTimeout(() => fn(...args), waitMs); }; }
12JS / TSAdvanced

Advanced: Promise gotchas

Answer

  • The Promise executor runs synchronously as soon as you call new Promise. Do not wrap an existing promise unless you are adapting callbacks.
  • A rejection becomes an unhandled rejection if nothing attaches .catch and nothing awaits it inside try/catch.
  • A then handler returns a new promise. Throw inside then to reject it. Return a value to fulfill it. Return a promise to adopt its state.
  • Promise.all rejects when the first input rejects. Promise.allSettled waits for every input. Promise.race settles with the first success or failure.
  • Swallowing errors with empty catch blocks hides failures. Log or rethrow when you cannot recover.
  • Pitfall: mixing await in a loop when Promise.all was intended, or the reverse when order and concurrency matter.
  • Floating async IIFEs without catch are a common source of unhandled rejections in scripts.
  • Always settle or cancel in-flight work on teardown when the host supports AbortController.
  • Interview tip: explain why new Promise((resolve) => resolve(existingPromise)) is usually unnecessary.
Flow diagram: all vs allSettled vs race
JavaScript
// Unnecessary wrap (anti-pattern) const bad = new Promise((resolve) => resolve(fetch(url))); // Prefer const ok = fetch(url); try { const results = await Promise.all([p1, p2]); } catch (e) { // one failure rejects the whole all() } const settled = await Promise.allSettled([p1, p2]);
13JS / TSAdvanced

Advanced: losing this

Answer

  • Taking obj.method off the object and calling it later drops the receiver. Inside the function, this is no longer obj unless you bound it.
  • Fix with () => obj.method(), obj.method.bind(obj), or a class field arrow when you own the class.
  • Arrow functions use lexical this. Skip them when you intentionally want this from the call site.
  • In strict mode, a loose call leaves this as undefined, so reading this.n throws instead of reading a global.
  • Passing methods to addEventListener, setTimeout, or array map is the usual way this is lost.
  • Pitfall: binding in render repeatedly in React classes, which creates new function identities every time.
  • const { method } = obj; method() is the same failure mode as assigning to a temporary.
  • TypeScript this parameters can document the expected receiver for helpers, but runtime still needs bind or arrows.
  • Interview tip: show the broken extract, then one correct bind or arrow fix.
Flow diagram: Method extracted from object
JavaScript
const counter = { n: 0, inc() { this.n += 1; }, }; const loose = counter.inc; // loose(); // TypeError in strict mode (this undefined) const bound = counter.inc.bind(counter); bound(); // ok setTimeout(() => counter.inc(), 0); // ok
Sources and further reading