← Daily Logs
GreatFrontEnd

Implement classNames II

HardJul 1, 2026recursionutilitysetdedupe

The sequel to `classNames` adds three things the original couldn't do: de-duplicate repeated classes, *turn off* a class that was switched on earlier, and accept a function as a value. The cleanest extension keeps the exact shape of the original loop and swaps the results array for a `Set` — `push` becomes `add`, and the one genuinely new idea, turning a class off, becomes `delete`.

The problem

Extend `classNames(...args)` so a class named twice appears once (`classNames('foo', 'foo')` → `'foo'`), a later `{ foo: false }` cancels an earlier `'foo'` (`classNames('foo', 'bar', { foo: false })` → `'bar'`), and a function value is called and its result processed (`classNames('foo', () => 'bar')` → `'foo bar'`).

The original array-and-join approach can't express any of these — pushing strings gives no way to dedupe or retract. A `Set` fixes both at once: adds are idempotent (free dedupe) and it supports `delete` (the turn-off). The one trap is the recursion — you can't recurse into `classNames(...arg)` and re-join a string, or a nested `{ foo: false }` loses the ability to cancel a `foo` added by the outer call.

The approach

Swap the results array for a `Set`. Every place the original did `result.push(x)` becomes `classes.add(x)` — that alone gives dedupe, since adding a name that's already present is a no-op. The object branch gains the whole new behavior in one `else`: `arg[key]` truthy → `add(key)`, falsy → `delete(key)`, which is exactly 'turn it off'.

The recursion has to *share* one Set instead of joining strings back together. Pull the loop body into an inner `process(arg)` that closes over `classes`; arrays recurse with `arg.forEach(process)` and functions recurse with `process(arg())`. Because every level writes into the same Set, a nested `{ foo: false }` can still delete a `foo` that an outer argument added.

Finish with `[...classes].join(' ')`. A Set preserves insertion order, so the output reads in the order classes first appeared. The single honest caveat: `delete` then a later `add` re-inserts the class at the *end* rather than its original slot — `classNames('foo', 'a', { foo: false }, 'foo')` yields `'a foo'`. None of the spec's cases depend on that ordering, so the Set version is fully correct here.

The solution

js
/**
 * @typedef {Record<string, unknown>} ClassDictionary
 * @typedef {Array<ClassValue>} ClassArray
 * @typedef {string | number | null | boolean | undefined | (() => unknown) | ClassDictionary | ClassArray} ClassValue
 */

/**
 * @param {...ClassValue} args
 * @returns {string}
 */
export default function classNames(...args) {
  const classes = new Set();

  function process(arg) {
    if (!arg) return;

    if (Array.isArray(arg)) {
      arg.forEach(process);
    } else if (typeof arg === "function") {
      process(arg());
    } else if (typeof arg === "object") {
      for (const key in arg) {
        if (arg[key]) classes.add(key);
        else classes.delete(key);
      }
    } else {
      classes.add(String(arg));
    }
  }

  args.forEach(process);

  return [...classes].join(" ");
}

Time O(n)Space O(n)

All entries