Practice Questions

1. Show how to create a symbol with Symbol() and with Symbol.for(). What is the key difference between the two approaches? How do you retrieve the registry key for a globally registered symbol?

Solution

Symbol() creates a unique symbol every time it is called, even with the same description. Symbol.for() creates or retrieves a symbol from the global symbol registry, so using the same key always returns the same symbol.

// Symbol() — always unique
const a = Symbol("id");
const b = Symbol("id");
console.log(a === b); // false

// Symbol.for() — shared via global registry
const c = Symbol.for("app.id");
const d = Symbol.for("app.id");
console.log(c === d); // true

// Retrieve the registry key
console.log(Symbol.keyFor(c)); // "app.id"
console.log(Symbol.keyFor(a)); // undefined (not in the registry)

2. Given the object below, implement Symbol.toPrimitive so that it returns the balance when used as a number, "Account: <name>" when used as a string, and the balance as the default.

const account = {
  name: "Savings",
  balance: 5000,
};

// After your implementation:
// +account → 5000
// `${account}` → "Account: Savings"
// account + 100 → 5100
Solution
const account = {
  name: "Savings",
  balance: 5000,
  [Symbol.toPrimitive](hint) {
    if (hint === "number") {
      return this.balance;
    }
    if (hint === "string") {
      return `Account: ${this.name}`;
    }
    return this.balance; // default
  },
};

console.log(+account); // 5000
console.log(`${account}`); // "Account: Savings"
console.log(account + 100); // 5100

3. Explain the two protocols that make up JavaScript’s iteration system. What method must an object have to be iterable, and what must the object returned by that method provide?

Solution

JavaScript’s iteration system is based on two protocols:

  1. Iterable protocol: An object is iterable if it has a [Symbol.iterator]() method that returns an iterator.
  2. Iterator protocol: An iterator is an object with a next() method that returns an object with two properties: value (the current value) and done (a boolean indicating whether the sequence is finished).
// Example: manually using an array's iterator
const arr = ["a", "b", "c"];
const iterator = arr[Symbol.iterator]();

console.log(iterator.next()); // { value: 'a', done: false }
console.log(iterator.next()); // { value: 'b', done: false }
console.log(iterator.next()); // { value: 'c', done: false }
console.log(iterator.next()); // { value: undefined, done: true }

Iterable objects can be used with for...of, the spread operator (...), destructuring, and Array.from().

4. Write a TodoList class that stores tasks internally and is iterable using for...of. Each task is an object with text and done properties. The iterator should yield only the incomplete tasks.

Solution
class TodoList {
  constructor() {
    this.tasks = [];
  }

  add(text) {
    this.tasks.push({ text, done: false });
  }

  complete(text) {
    const task = this.tasks.find((t) => t.text === text);
    if (task) task.done = true;
  }

  [Symbol.iterator]() {
    const incomplete = this.tasks.filter((t) => !t.done);
    let index = 0;

    return {
      next() {
        if (index < incomplete.length) {
          return { value: incomplete[index++], done: false };
        }
        return { done: true };
      },
    };
  }
}

const todos = new TodoList();
todos.add("Buy groceries");
todos.add("Write code");
todos.add("Read book");
todos.complete("Write code");

for (const task of todos) {
  console.log(task.text);
}
// "Buy groceries"
// "Read book"

5. Write a generator function chunk(array, size) that yields sub-arrays of the given size from the input array. Then show how yield* works by writing a generator that delegates to two other generators.

Solution
function* chunk(array, size) {
  for (let i = 0; i < array.length; i += size) {
    yield array.slice(i, i + size);
  }
}

console.log([...chunk([1, 2, 3, 4, 5], 2)]);
// [[1, 2], [3, 4], [5]]

// yield* delegation example
function* letters() {
  yield "a";
  yield "b";
}

function* digits() {
  yield 1;
  yield 2;
}

function* combined() {
  yield* letters(); // delegates to letters
  yield* digits(); // delegates to digits
  yield* [true, false]; // delegates to an array
}

console.log([...combined()]); // ["a", "b", 1, 2, true, false]

yield* delegates iteration to another iterable, which can be a generator or any other iterable such as an array. It yields each of that iterable’s values in order.

6. Show the two ways to create a regular expression in JavaScript. Then write regex patterns for each of the following:

  • Match a string that contains only lowercase letters
  • Match a US phone number in the format 555-123-4567
  • Match a word that starts with a capital letter
Solution

Two ways to create a regex:

// Literal syntax (preferred when pattern is constant)
const regex1 = /hello/gi;

// Constructor syntax (useful when pattern is dynamic)
const regex2 = new RegExp("hello", "gi");

Patterns:

// Only lowercase letters
const lowercase = /^[a-z]+$/;
console.log(lowercase.test("hello")); // true
console.log(lowercase.test("Hello")); // false

// US phone number: 555-123-4567
const phone = /^\d{3}-\d{3}-\d{4}$/;
console.log(phone.test("555-123-4567")); // true
console.log(phone.test("55-123-4567")); // false

// Word starting with a capital letter
const capitalWord = /\b[A-Z][a-z]*\b/;
console.log(capitalWord.test("Hello")); // true
console.log(capitalWord.test("hello")); // false

7. Using capturing groups and named groups, write code that parses a date string in "YYYY-MM-DD" format and extracts the year, month, and day. Then use replace() with group references to reformat "John Smith" into "Smith, John".

Solution
// Named groups to parse a date
const dateRegex = /(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})/;
const match = "2026-02-22".match(dateRegex);

console.log(match.groups.year); // "2026"
console.log(match.groups.month); // "02"
console.log(match.groups.day); // "22"

// Using replace() with group references
const name = "John Smith";
const reversed = name.replace(/(\w+) (\w+)/, "$2, $1");
console.log(reversed); // "Smith, John"

You can reference captured groups in a replacement string using $1, $2, etc. (numbered by their position).

8. A developer writes the following code and is surprised that the second call returns false. Explain why this happens and how to fix it.

const regex = /cat/g;

console.log(regex.test("catdog")); // true
console.log(regex.test("catdog")); // false  ← unexpected!
Solution

Regex objects with the g (global) flag maintain internal state via the lastIndex property. After a successful match, lastIndex advances to the position after the match. On the next call, the search starts from lastIndex, not from the beginning of the string.

After the first test(), lastIndex is 3 (past "cat"). The second call starts searching from index 3 and finds no match, so it returns false and resets lastIndex to 0.

Two ways to fix this:

// Option 1: Reset lastIndex before each test
const regex = /cat/g;
regex.lastIndex = 0;
console.log(regex.test("catdog")); // true
regex.lastIndex = 0;
console.log(regex.test("catdog")); // true

// Option 2: Don't use the g flag if you only need to test for existence
const regex2 = /cat/;
console.log(regex2.test("catdog")); // true
console.log(regex2.test("catdog")); // true

9. Create a Proxy-based function createTypedObject(schema) that enforces type constraints on property assignments. The schema maps property names to expected types (as strings like "string" or "number"). Use Reflect to forward valid operations.

// Expected usage:
const point = createTypedObject({ x: "number", y: "number" });
point.x = 10; // OK
point.y = 20; // OK
point.x = "hi"; // TypeError: x must be of type number
Solution
function createTypedObject(schema) {
  return new Proxy(
    {},
    {
      set(target, property, value, receiver) {
        if (property in schema) {
          const expectedType = schema[property];
          if (typeof value !== expectedType) {
            throw new TypeError(`${property} must be of type ${expectedType}`);
          }
        }
        return Reflect.set(target, property, value, receiver);
      },
    },
  );
}

const point = createTypedObject({ x: "number", y: "number" });
point.x = 10; // OK
point.y = 20; // OK
// point.x = "hi"; // TypeError: x must be of type number

Use Reflect.set() rather than direct assignment (target[property] = value). It handles the receiver correctly, and it returns a boolean that tells you whether the assignment succeeded.

10. When would you use a WeakMap instead of a regular Map? Describe two practical use cases and explain why weak references matter in each case.

Solution

Use WeakMap when you need to associate data with objects without preventing those objects from being garbage collected. WeakMap keys are held weakly. Once the key object has no other references, it and its associated value are automatically cleaned up.

Use case 1: Private data

const privateData = new WeakMap();

class User {
  constructor(name, token) {
    this.name = name;
    privateData.set(this, { token });
  }

  getToken() {
    return privateData.get(this).token;
  }
}

When a User instance is garbage collected, its private data is automatically cleaned up. With a regular Map, the entries would stay in the map forever, which is a memory leak.

Use case 2: Caching expensive computations

const cache = new WeakMap();

function computeLayout(element) {
  if (cache.has(element)) return cache.get(element);
  const layout = {
    /* expensive computation */
  };
  cache.set(element, layout);
  return layout;
}

If element is removed from the DOM and no longer referenced, the cached result is automatically cleaned up. A regular Map would keep the element and its cached data in memory indefinitely.

11. If you want to produce a potentially large or infinite sequence of values, would you use a generator or build and return an array? Justify your decision and show a brief example.

Solution

Use a generator because generators evaluate lazily. They produce values one at a time, only when requested. This avoids allocating a large array in memory upfront, and it is the only option for infinite sequences (you cannot store infinity in an array).

// Generator: lazy, memory-efficient
function* evenNumbers() {
  let n = 0;
  while (true) {
    yield n;
    n += 2;
  }
}

// Only compute what we need
const iter = evenNumbers();
console.log(iter.next().value); // 0
console.log(iter.next().value); // 2
console.log(iter.next().value); // 4

// Collect just the first 5
function* take(iterable, count) {
  let i = 0;
  for (const item of iterable) {
    if (i++ >= count) return;
    yield item;
  }
}

console.log([...take(evenNumbers(), 5)]); // [0, 2, 4, 6, 8]

An array-based approach would require knowing the size upfront and allocating all the values at once. That wastes memory for a large sequence, and it does not work at all for an infinite one.