classNames

Medium

Prompt

The task is to create a simple javascript utility for conditionally joining classNames together. We will call this function as classNames.

The classNames function takes any number of arguments which can be a string or object. The argument 'foo' is short for { foo: true }. If the value associated with a given key is falsy, that key won't be included in the output.

Example

The following examples are from the official classnames package.

classNames('foo', 'bar'); // => 'foo bar'
classNames('foo', { bar: true }); // => 'foo bar'
classNames({ 'foo-bar': true }); // => 'foo-bar'
classNames({ 'foo-bar': false }); // => ''
classNames({ foo: true }, { bar: true }); // => 'foo bar'
classNames({ foo: true, bar: true }); // => 'foo bar'

// lots of arguments of various types
classNames('foo', { bar: true, duck: false }, 'baz', {
quux: true,
}); // => 'foo bar baz quux'

// other falsy values are just ignored
classNames(
null,
false,
'bar',
undefined,
0,
{ baz: null },
''
); // => 'bar'

Playground

Solution

Explanation

Let's look into how our classnames function works step by step.

Collection Initialization

  • We use rest parameters (...args) to accept any number of arguments
  • We initialize an empty array classes to collect all valid class names
function classNames(...args) {
const classes = [];
// ...

Argument Processing

  • We iterate through each argument using forEach
  • Immediately skip any falsy values (null, undefined, false, 0, '')
  • Determine the argument's type for specialized handling
args.forEach(arg => {
// Skip falsy values
if (!arg) return;

const argType = typeof arg;
// ...

Type-Specific Handling

Strings and Numbers

  • String values like 'btn' or 'btn-primary' are added directly to the collection
  • Numbers are automatically converted to strings when added to the array
if (argType === 'string' || argType === 'number') {
// If argument is a string or number, add it directly
classes.push(arg);
}

Arrays

  • When encountering an array like ['btn-sm', 'rounded'], we use recursion
  • We call classNames again with the array elements spread as arguments
  • The resulting string from this recursive call is added to our collection

typeof [] gives 'object', so you need to handle arrays before objects.

else if (Array.isArray(arg)) {
// If argument is an array, recursively process it
const innerClasses = classNames(...arg);
if (innerClasses) {
classes.push(innerClasses);
}
}

Objects

For objects like { 'active': isActive, 'disabled': isDisabled }:

  • We get all enumerable property keys
  • For each key, we check:
    • Is it an own property (using Object.hasOwn)?
    • Is its value truthy?
  • If both conditions are met, we add the key to our collection
else if (argType === 'object') {
// If argument is an object, add keys where value is truthy
Object.keys(arg).forEach(key => {
if (Object.hasOwn(arg, key) && arg[key]) {
classes.push(key);
}
});
}

I have added a note in the solution section to explain the use of Object.hasOwn in more detail.

Result Composition

  • Finally, we join all collected class names with a space character
  • This creates a string suitable for HTML class attributes
// Join all classes with a space and return
return classes.join(' ');

Dry Run

We will dry run the code given below to understand how the code works.

classNames('btn', { primary: true, large: false }, [
'rounded',
null,
]);
  • Initialize classes = []
  • Process 'btn' (string):
    • Add to classes → classes = ['btn']
  • Process { 'primary': true, 'large': false } (object):
    • Check 'primary': value is true → add to classes → classes = [btn, primary]
    • Check 'large': value is false → skip
  • Process ['rounded', null] (array):
    • Recursively call classNames('rounded', null)
    • Initialize new classes = []
    • Process 'rounded' (string): add to classes → classes = [rounded]
    • Process null (falsy): skip
    • Return 'rounded'
  • Add result to classes → classes = [btn, primary, rounded]
  • Join with spaces and return: btn primary rounded

The Role of Object.hasOwn()

The use of Object.hasOwn(arg, key) is a crucial security feature, without the check given below, if someone had modified Object.prototype and added a key property, it would have been included in the output.

Object.prototype.maliciousClass = true;

Due to this reason we use Object.hasOwn(arg, key) to check if the property exists directly on the object, not through the prototype chain.

if (Object.hasOwn(arg, key) && arg[key]) {
classes.push(key);
}
00:00