Implement classnames in JavaScript
The classnames utility joins any mix of strings, arrays and objects into one space-separated class string. Strings are kept if truthy, arrays are recursed into, and for objects each key is kept when its value is truthy. Everything falsy is dropped.
The question
Common at companies that use React, because everyone has used the library. The question is really about handling a union of argument types cleanly, and the follow-up is nesting: an array may itself contain objects and further arrays.
The solution
Recursing through the same function for nested arrays is what keeps this short. Note the guard that drops a nested result only when it’s empty.
function classNames(...args) {
const classes = [];
for (const arg of args) {
if (!arg) continue;
if (typeof arg === "string" || typeof arg === "number") {
classes.push(String(arg));
} else if (Array.isArray(arg)) {
const nested = classNames(...arg);
if (nested) classes.push(nested);
} else if (typeof arg === "object") {
for (const key of Object.keys(arg)) {
if (arg[key]) classes.push(key);
}
}
}
return classes.join(" ");
}
How it works
The falsy guard runs first, so null, undefined, false,
0 and "" are gone before any type check happens. Arrays recurse
through the same function, spread as arguments, which handles any depth and any mix inside
them. Object keys are kept by value truthiness, which is what makes
{ active: isActive } read so well at the call site. Numbers get stringified rather
than dropped, though 0 is still falsy and still skipped.
The tests it has to pass
This is the part most explanations leave out. "It works" is not a claim you can check by reading. These are the 3 cases Practice Pad runs against your solution, and the implementation above passes all of them.
✓ joins several string arguments with single spaces
expectEqual(classNames("btn", "btn-primary", "large"), "btn btn-primary large");
✓ includes only the truthy keys of an object
expectEqual(classNames({ active: true, disabled: false, visible: true }), "active visible");
✓ joins strings, nested arrays, and truthy object keys
expectEqual(classNames("button", ["large", { active: true, disabled: false }]), "button large active");
Edge cases to watch out for
Checking typeof before checking falsiness
typeof null === "object", so a null argument reaches the object branch and Object.keys(null) throws. Guard for falsy first.
Only handling one level of array nesting
A flat loop over an array works until the array contains another array or an object. Recursing through the same function costs one line and handles everything.
Joining with a separator that produces double spaces
Concatenating strings with a space and then trimming leaves internal double spaces when a value is skipped. Collect into an array and join(" ") at the end.
Dropping numeric class names
The real library stringifies numbers, so classNames("col", 12) produces "col 12". Only 0 is skipped, and only because it’s falsy.
Follow up questions
What’s the runtime cost of calling this on every render?
Small but not nothing: it allocates an array and a string on every call, and in a list of a thousand rows that’s a thousand of each per render. It’s almost never the bottleneck, and it’s worth knowing the answer is "measure it" rather than assuming. Hoisting the static parts out of the call is the easy win if it ever matters.
How do you handle nested arrays?
Recurse through the same function, spreading the nested array as arguments. That way an array containing objects, strings and further arrays all works with no additional branches.
Why are falsy values dropped?
Because that’s what makes the conditional syntax useful: classNames(isActive && "active") yields "" rather than "false". Dropping every falsy value up front is what makes the call site read cleanly.
Is classnames still needed with Tailwind?
The same problem exists, which is why clsx and tailwind-merge are so widely used. The joining logic is identical; tailwind-merge adds conflict resolution so a later utility class overrides an earlier one.
Practise this with the tests running
Practice Pad runs these 3 cases against your own solution, in a real editor, on your Mac. Twenty-four questions, and an optional AI panel that explains why a test failed.
Download for macOS Free. Requires macOS 12 or later on Apple Silicon.