beginnerCommon5 min read · Updated Jul 18, 2026

Implement classnames(): Conditional Class Strings

Implement the classnames utility in JavaScript — type dispatch over strings, objects, and nested arrays, the empty-string join bug, and the clsx comparison.


The problem

Implement classNames(...) — the npm utility with ~500M weekly downloads combined with clsx: accept strings, numbers, objects ({ name: condition }), and arbitrarily nested arrays; join the truthy results with single spaces.

classNames("btn", { "btn-active": isActive, "btn-disabled": !enabled }, ["m-2", extra && "shadow"]);

It's a beginner-friendly question with real grading criteria: clean type dispatch (the same skill as custom typeof and JSON.stringify, pocket-sized), recursion for nesting, and one specific output bug most implementations ship.

Implementation

function classNames(...args) {
  const classes = [];
 
  for (const arg of args) {
    if (!arg) continue; // false, null, undefined, 0, "" — all skipped
 
    if (typeof arg === "string" || typeof arg === "number") {
      classes.push(String(arg));
    } else if (Array.isArray(arg)) {
      const nested = classNames(...arg); // recurse — arrays flatten
      if (nested) classes.push(nested);  // ← guard: don't push ""
    } else if (typeof arg === "object") {
      for (const key of Object.keys(arg)) {
        if (arg[key]) classes.push(key);  // key in, value decides
      }
    }
    // functions, symbols, etc.: silently ignored — matching the real library
  }
 
  return classes.join(" ");
}

Verified behavior

classNames("btn", "btn-primary");                          // "btn btn-primary"
classNames("btn", { active: true, disabled: false });      // "btn active"
classNames(["btn", ["nested", { deep: true }]]);           // "btn nested deep"
classNames("a", null, undefined, false, 0, "", "b");       // "a b" — one space
classNames({ [`col-${size}`]: true });                     // computed keys work
classNames();                                              // ""

The planted bug: the empty-string push

The recursive array branch returns a string — and if the whole sub-array was falsy (["x", [false, null]]), that string is "". Push it unguarded and join(" ") happily writes a double space:

brokenClassNames("a", [false], "b"); // "a  b" ← two spaces

Two spaces in className is usually harmless to the browser (class parsing splits on whitespace runs) — which is exactly why the bug ships: nothing visibly breaks until a test does exact string comparison, or something downstream splits on " " and gets a phantom "" entry. The one-line guard (if (nested)) fixes it; naming why the bug survives in the wild is the senior touch. (Alternative structure: have the recursion push into a shared array instead of returning strings — no join-of-joins, no bug by construction. Offering the restructure is even better than the guard.)

Design notes worth saying aloud

  • Object.keys, not for...in — own keys only; a polluted or prototyped object won't leak inherited "classes" (the same discipline as Object.assign).
  • Falsy filtering is the entire API contractextra && "shadow" works because false/undefined/"" are skipped; 0 being skipped is specified library behavior too (classNames(0)""), while classNames(1)"1". Numbers are in the contract because people do col-${n} variants.
  • Order is preserved — later CSS classes don't override earlier ones by class order (specificity doesn't work that way), but the output being deterministic matters for testing and SSR hydration string-matching. Deduplication is deliberately not done (costs time, browsers ignore duplicates) — the real library skips it; knowing that's a considered omission beats "adding" it.
  • clsx — the modern, smaller drop-in (it's what shadcn/ui pairs with tailwind-merge in cn()); same semantics minus some legacy input types. If the interviewer's follow-up is "what does tailwind-merge add?" — conflict resolution (p-2 p-4p-4), which is a genuinely different, harder problem (parsing class semantics, not just joining strings). Placing the three tools correctly is 2026 frontend literacy.

Edge cases interviewers probe

  • The ""-push double space — the bug above.
  • Deep nesting[[["a"]]]"a"; recursion handles arbitrary depth (it's deep flatten fused with a filter — same skeleton).
  • Objects inside arrays inside objects' values? — values are only ever booleans-in-spirit (conditions); keys are the class names. Mixing that up inverts the API.
  • Computed/dynamic keys{ [`theme-${mode}`]: true } must work; it does for free via Object.keys, but interviewers check you know why (the object was built before your function ever saw it).
  • Trailing/leading spaces inside inputsclassNames(" a ", "b")" a b" — the library does not trim; garbage in, garbage out is the documented contract. State it rather than silently "fixing" it.

Common mistakes

  • The unguarded recursive push (double spaces).
  • for...in over the object branch.
  • args.filter(Boolean).join(" ") one-liners that ignore objects and arrays entirely — handles a third of the API.
  • Deduplicating or sorting output — changes behavior the ecosystem relies on (and sorting breaks nothing visually, which makes it a sneaky test failure later).
  • Building the result with string concatenation + trailing-space trimming instead of collect-then-join — works, but reads as not knowing the idiom.

Follow-up questions

  • "Add TypeScript types." — the input type is recursive: type Arg = string | number | null | undefined | false | Record<string, unknown> | Arg[] — a nice small exercise in recursive type aliases.
  • "Implement tailwind-merge's core idea." — last-wins per utility group (p-2 p-4 conflict): requires mapping class → group (parse prefixes), then a Map keyed by group — a real parsing/design escalation from string joining.
  • "Why not template literals?"`btn ${isActive ? "active" : ""}` leaves stray spaces and scales miserably past two conditions; the utility exists because the literal version becomes unreadable — a taste answer, and the honest origin story.
  • "Performance?" — it runs per render in hot component trees; the real libraries are micro-optimized (manual loops, no recursion via stack arrays). Knowing it's a hot-path utility justifies their code style — and connects to memoizeLast if inputs are stable.
  • "Where else does this dispatch-on-type pattern appear?" — serializers, cloners, schema validators — the tiny-interpreter shape; this is the smallest member of the family that includes JSON.stringify.

  • A mini Redux store with Immer-style draft mutations — reducers, subscriptions, and immutability.

  • Diff two virtual trees and apply minimal DOM mutations — the reconciliation step that makes the virtual DOM worth having.

  • Turn real DOM into a plain-object tree — the first half of understanding how React represents UI.