classNames
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?
- Is it an own property (using
- 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
- Check
- 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'
- Recursively call
- 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);
}