Implement classNames II
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
/**
* @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)