Implementing classNames: A Deep Dive into JavaScript Utility Functions
Implementing classNames: A Deep Dive into JavaScript Utility Functions
I remember working on a React component where I needed to conditionally apply CSS classes based on state. My code looked something like this:
const className =
`button ${isActive ? 'active' : ''} ${isDisabled ? 'disabled' : ''} ${customClass || ''}`.trim();
It worked, but it was verbose, error-prone, and honestly, it looked messy. What
if customClass was undefined? What if I had multiple conditional classes?
What if I needed to combine classes from an array? The template literal approach
fell apart quickly.
That's when I discovered classNames—a utility function that elegantly handles
conditional CSS class joining. But instead of just using it, I wanted to
understand how it works. So I decided to implement it myself, and in the
process, I learned about rest parameters, type checking, recursion, object
iteration, and many other JavaScript concepts that are essential for writing
robust utility functions.
In this post, we're going to implement classNames from scratch while diving
deep into all the JavaScript concepts you need to understand it. We'll start
with the challenge, identify the concepts needed, learn them deeply, and build
our implementation step by step.
Intended audience: JavaScript developers who want to understand utility
functions, intermediate JavaScript concepts, and how to combine multiple
language features to solve real-world problems. Perfect for React developers who
want to understand how libraries like clsx and classnames work under the
hood.
Table of Contents
- Understanding the Challenge
- The Concepts We'll Need
- Rest Parameters: Handling Variable Arguments
- Type Checking: Knowing What We're Dealing With
- Truthy and Falsy: The Foundation of Conditional Logic
- Object Iteration: Extracting Keys Based on Values
- Array Handling: Processing Nested Structures
- Recursion: Handling Nested Arrays
- String Manipulation: Building the Final Result
- Putting It All Together: The Complete Implementation
- Testing and Edge Cases
- Common Gotchas and Mistakes
- Key Takeaways
- Related Topics and Further Learning
Understanding the Challenge
The classNames function is a utility that conditionally joins CSS class names
together. It's commonly used in React and other frontend frameworks to handle
dynamic class names.
Here's what our function needs to do:
Requirements
- Accept variable arguments: The function should accept any number of arguments
- Handle strings: String arguments should be added directly to the result
- Handle objects: Object keys should be included if their values are truthy
- Handle arrays: Arrays should be recursively flattened and processed
- Handle mixed arguments: Any combination of strings, objects, arrays, etc.
- Ignore falsy values:
null,false,undefined, empty strings should be ignored - Return clean string: No leading or trailing whitespace
Examples
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'
classNames({ foo: true, bar: false, qux: true }); // 'foo qux'
classNames('a', ['b', { c: true, d: false }]); // 'a b c'
classNames(
'foo',
{
bar: true,
duck: false,
},
'baz',
{ quux: true },
); // 'foo bar baz quux'
classNames(null, false, 'bar', undefined, { baz: null }, ''); // 'bar'
This might seem straightforward at first, but as we'll see, implementing it correctly requires understanding several JavaScript concepts deeply.
The Concepts We'll Need
Before we start coding, let's identify all the JavaScript concepts we'll need to master:
- Rest Parameters - To accept variable arguments
- Type Checking - To differentiate between strings, objects, arrays, etc.
- Truthy/Falsy Values - To determine which values to include
- Object Iteration - To process object keys and values
- Array Methods - To handle array arguments
- Recursion - To flatten nested arrays
- String Methods - To build and clean the final result
- Control Flow - To handle different argument types
Let's explore each of these concepts in detail, understanding not just what they are, but why we need them for this challenge and how they work.
Rest Parameters: Handling Variable Arguments
The first thing we notice about classNames is that it accepts a variable
number of arguments. Sometimes we call it with two strings, sometimes with an
object, sometimes with a mix. How do we handle this?
What Are Rest Parameters?
Rest parameters allow us to represent an indefinite number of arguments as an
array. The syntax uses three dots (...) before the parameter name:
function myFunction(...args) {
console.log(args); // args is an array containing all arguments
}
myFunction(1, 2, 3); // [1, 2, 3]
myFunction('a', 'b'); // ['a', 'b']
myFunction(); // []
Why We Need Rest Parameters
In our classNames function, we don't know ahead of time how many arguments
will be passed. We might get:
classNames('foo', 'bar')- 2 argumentsclassNames('foo', { bar: true }, 'baz', { qux: false })- 4 argumentsclassNames(...someArray)- potentially many arguments
Rest parameters solve this by collecting all arguments into an array, which we can then iterate over.
How Rest Parameters Work
Rest parameters must be the last parameter in a function signature:
// ✅ Valid
function example(...args) { }
function example(first, ...rest) { }
// ❌ Invalid - rest parameter must be last
function example(...args, last) { }
When you use rest parameters, all remaining arguments are collected into an array:
function example(first, second, ...rest) {
console.log(first); // 1
console.log(second); // 2
console.log(rest); // [3, 4, 5, 6]
}
example(1, 2, 3, 4, 5, 6);
Common Gotcha: Rest Parameters vs Arguments Object
You might wonder: "Can't we just use the arguments object?" Yes, but rest
parameters are preferred because:
- Rest parameters are real arrays - You can use array methods directly
- Arguments object is array-like - It has length and indices but isn't a true array
- Rest parameters are cleaner - No need for
Array.from()or spread operator
// Old way with arguments object
function oldWay() {
const args = Array.from(arguments); // Convert to real array
return args.join(' ');
}
// New way with rest parameters
function newWay(...args) {
return args.join(' '); // args is already an array
}
In Our Implementation
We'll use rest parameters to collect all arguments:
function classNames(...args) {
// args is an array containing all arguments passed to the function
// We can now iterate over args to process each one
}
This is the foundation of our function - it allows us to accept any number of arguments flexibly.
Type Checking: Knowing What We're Dealing With
Once we have our arguments in an array, we need to know what type each argument is. Is it a string? An object? An array? This determines how we process it.
What Is Type Checking?
Type checking is the process of determining the type of a value at runtime. JavaScript provides several operators and methods for this:
typeofoperator - Returns a string indicating the typeArray.isArray()- Checks if a value is an arrayinstanceofoperator - Checks if an object is an instance of a constructor
Why We Need Type Checking
Different argument types need different processing:
- Strings: Add directly to our result
- Objects: Iterate over keys, include keys with truthy values
- Arrays: Recursively process each element
- Numbers: Convert to string and add
- Falsy values: Skip entirely
Without type checking, we can't determine how to process each argument.
How typeof Works
The typeof operator returns a string indicating the type:
typeof 'hello'; // 'string'
typeof 42; // 'number'
typeof true; // 'boolean'
typeof undefined; // 'undefined'
typeof null; // 'object' ⚠️ This is a bug in JavaScript!
typeof {}; // 'object'
typeof []; // 'object' ⚠️ Arrays are objects!
typeof function () {}; // 'function'
Important Gotcha: typeof null
One of JavaScript's most famous quirks is that typeof null returns 'object'.
This is a bug in the language that's been around since the beginning and can't
be fixed because it would break existing code.
typeof null; // 'object' - but null is not an object!
For our function, this actually works in our favor because we treat null as
falsy anyway, but it's important to be aware of this quirk.
Checking for Arrays
Since typeof [] returns 'object', we need a different way to check for
arrays:
// ❌ This doesn't work
typeof []; // 'object'
// ✅ Use Array.isArray()
Array.isArray([]); // true
Array.isArray({}); // false
Array.isArray('string'); // false
Array.isArray(null); // false
Array.isArray() was introduced in ES5 specifically to solve the problem of
reliably detecting arrays.
Why typeof Returns 'object' for Arrays
Arrays in JavaScript are actually objects with numeric keys and a special
length property. That's why typeof returns 'object' - technically, arrays
are objects, just with special behavior.
const arr = [1, 2, 3];
typeof arr; // 'object'
arr instanceof Array; // true
Array.isArray(arr); // true
// Arrays are objects
arr[0]; // 1 - accessing with numeric key
arr.length; // 3 - special length property
Type Checking Strategy for classNames
Our type checking strategy will be:
- Check for falsy values first - Skip
null,undefined,false,'' - Check for strings and numbers - These can be added directly
- Check for arrays - Using
Array.isArray() - Everything else that's truthy is an object - Iterate over keys
function classNames(...args) {
for (const arg of args) {
if (!arg) continue; // Skip falsy (handles null, undefined, false, '')
if (typeof arg === 'string' || typeof arg === 'number') {
// Add string or number directly
}
if (Array.isArray(arg)) {
// Process array recursively
}
if (typeof arg === 'object') {
// Process object - iterate over keys
}
}
}
Notice that we check for arrays before checking for objects, because
Array.isArray() is more specific than typeof arg === 'object'.
Truthy and Falsy: The Foundation of Conditional Logic
One of the key requirements is that we should ignore falsy values. But what exactly are truthy and falsy values in JavaScript?
What Are Truthy and Falsy Values?
In JavaScript, every value has an inherent boolean value when evaluated in a
boolean context. Values that evaluate to true are called "truthy," and values
that evaluate to false are called "falsy."
The Falsy Values
JavaScript has exactly 8 falsy values:
false- The boolean false0- The number zero-0- Negative zero (same as 0)0n- BigInt zero''- Empty stringnull- Null valueundefined- Undefined valueNaN- Not a Number
Everything else is truthy.
Truthy Values
These might surprise you:
// These are all truthy!
Boolean('false'); // true - it's a non-empty string
Boolean('0'); // true - it's a non-empty string
Boolean(' '); // true - space is a character
Boolean([]); // true - arrays are objects
Boolean({}); // true - objects are truthy
Boolean(function () {}); // true - functions are objects
Boolean(Infinity); // true - infinity is truthy
Why This Matters for classNames
In our function, we want to:
- Include strings like
'foo'(truthy) - Include objects like
{ foo: true }(truthy) - Skip
null,undefined,false,''(falsy) - Skip object keys with falsy values like
{ foo: false }
Boolean Coercion
When you use a value in a boolean context, JavaScript automatically converts it to a boolean:
if (value) {
} // Coerces to boolean
value && 'something'; // Coerces to boolean
!value; // Coerces to boolean, then negates
Boolean(value); // Explicit coercion
!!value; // Double negation (explicit coercion shortcut)
All of these evaluate the truthiness of value.
The ! Operator and Falsy Checking
The ! operator converts a value to boolean and negates it. So !value returns
true for falsy values:
!false; // true
!null; // true
!undefined; // true
!''; // true
!0; // true
!'hello'; // false
!{}; // false
![]; // false
Using ! for Falsy Checks
We can use !arg to check if a value is falsy:
if (!arg) {
// Skip this argument - it's falsy
continue;
}
This is concise and handles all falsy values: null, undefined, false,
'', 0, NaN.
Important Note: Empty Arrays and Objects
Empty arrays and objects are truthy, but in our function, they won't add any classes:
Boolean([]); // true - array is truthy
Boolean({}); // true - object is truthy
// But in our function:
classNames([]); // '' - empty array adds nothing
classNames({}); // '' - empty object adds nothing
This is fine because:
- Empty arrays will be processed but add nothing (no elements)
- Empty objects will be iterated but have no keys with truthy values
Truthy/Falsy in Object Values
When checking object values, we use truthiness to determine if a key should be included:
const obj = {
foo: true, // truthy - include 'foo'
bar: false, // falsy - exclude 'bar'
baz: null, // falsy - exclude 'baz'
qux: 1, // truthy - include 'qux'
quux: 0, // falsy - exclude 'quux'
corge: '', // falsy - exclude 'corge'
};
// Only 'foo' and 'qux' will be included
classNames(obj); // 'foo qux'
In Our Implementation
We'll use falsy checks at multiple points:
- Skip falsy arguments:
if (!arg) continue; - Check object values:
if (arg[key]) { classes.push(key); }
function classNames(...args) {
const classes = [];
for (const arg of args) {
// Skip falsy arguments
if (!arg) continue;
if (typeof arg === 'object') {
for (const key in arg) {
// Only include key if value is truthy
if (arg[key]) {
classes.push(key);
}
}
}
}
return classes.join(' ');
}
Understanding truthy/falsy is crucial because it's the foundation of our conditional logic.
Object Iteration: Extracting Keys Based on Values
When we receive an object argument like { foo: true, bar: false }, we need to
iterate over its keys and include only those keys whose values are truthy.
What Is Object Iteration?
Object iteration means going through all the properties (keys) of an object. JavaScript provides several ways to do this:
for...inloopObject.keys()- Returns an array of keysObject.values()- Returns an array of valuesObject.entries()- Returns an array of [key, value] pairs
Why We Need Object Iteration
For an object like { active: true, disabled: false, loading: true }, we want
to:
- Check each key-value pair
- Include the key if the value is truthy (
active,loading) - Exclude the key if the value is falsy (
disabled)
Result: 'active loading'
How for...in Works
The for...in loop iterates over all enumerable properties of an object:
const obj = { foo: true, bar: false, baz: true };
for (const key in obj) {
console.log(key, obj[key]);
}
// Output:
// foo true
// bar false
// baz true
Important Gotcha: Inherited Properties
The for...in loop iterates over all enumerable properties, including
inherited ones:
const obj = { foo: true };
// Create object with prototype
const child = Object.create(obj);
child.bar = false;
for (const key in child) {
console.log(key); // 'bar', 'foo' - includes inherited 'foo'!
}
This can cause issues if the object has inherited properties we don't want.
hasOwnProperty: Checking Own Properties
To avoid including inherited properties, we use hasOwnProperty():
const obj = { foo: true };
const child = Object.create(obj);
child.bar = false;
for (const key in child) {
if (child.hasOwnProperty(key)) {
console.log(key); // Only 'bar' - excludes inherited 'foo'
}
}
However, hasOwnProperty can be problematic if an object has a custom
hasOwnProperty property. The safer approach is:
Object.prototype.hasOwnProperty.call(obj, key);
Modern Alternative: Object.keys()
A cleaner, more modern approach is to use Object.keys(), which only returns
own (non-inherited) properties:
const obj = { foo: true, bar: false };
Object.keys(obj); // ['foo', 'bar']
Object.keys(obj).forEach((key) => {
if (obj[key]) {
console.log(key); // Only 'foo' (bar is falsy)
}
});
Object.keys() is preferred because:
- It only returns own properties (no inherited ones)
- It returns an array (can use array methods)
- It's more readable and less error-prone
Object.keys() vs for...in
const obj = { foo: true };
const child = Object.create(obj);
child.bar = false;
// for...in includes inherited properties
for (const key in child) {
console.log(key); // 'bar', 'foo'
}
// Object.keys() only includes own properties
Object.keys(child); // ['bar']
In Our Implementation
We'll use for...in with hasOwnProperty check for compatibility, or we can
use Object.keys() for a cleaner approach:
// Option 1: for...in with hasOwnProperty
if (typeof arg === 'object' && !Array.isArray(arg)) {
for (const key in arg) {
if (Object.prototype.hasOwnProperty.call(arg, key)) {
if (arg[key]) {
classes.push(key);
}
}
}
}
// Option 2: Object.keys() (cleaner)
if (typeof arg === 'object' && !Array.isArray(arg)) {
Object.keys(arg).forEach((key) => {
if (arg[key]) {
classes.push(key);
}
});
}
For our use case, Object.keys() is simpler and safer since we're dealing with
plain objects, not objects with custom prototypes.
Handling Edge Cases
What happens with these edge cases?
// Empty object
classNames({}); // '' - no keys, nothing added
// Object with only falsy values
classNames({ foo: false, bar: null }); // '' - no truthy values
// Object with numeric keys
classNames({ 0: true, 1: false, 2: true }); // '0 2' - keys are strings
All handled correctly by our iteration approach!
Array Handling: Processing Nested Structures
Arrays need special handling because they can contain strings, objects, or even nested arrays. We need to process each element recursively.
What Are Arrays in JavaScript?
Arrays are ordered collections of values. They're technically objects with numeric keys and special behavior.
const arr = ['foo', { bar: true }, ['baz', { qux: false }]];
This array contains:
- A string:
'foo' - An object:
{ bar: true } - Another array:
['baz', { qux: false }](which itself contains mixed types)
Why Arrays Need Special Handling
Arrays can contain any type of value, including other arrays. We need to:
- Process each element in the array
- Handle nested arrays recursively
- Handle strings, objects, and other types within the array
Iterating Over Arrays
We can iterate over arrays using:
// for...of loop (preferred)
for (const item of arr) {
// Process item
}
// forEach method
arr.forEach((item) => {
// Process item
});
// Traditional for loop
for (let i = 0; i < arr.length; i++) {
// Process arr[i]
}
For our use case, for...of is clean and readable.
Processing Array Elements
When we encounter an array, we need to process each element:
if (Array.isArray(arg)) {
for (const item of arg) {
// Process each item - but items might be strings, objects, or arrays!
}
}
But here's the challenge: items within the array might also be arrays! That's where recursion comes in.
Recursion: Handling Nested Arrays
The requirement states: "Arrays will be recursively flattened as per the rules above." This means we need recursion to handle nested arrays of any depth.
What Is Recursion?
Recursion is when a function calls itself. It's useful for problems that can be broken down into smaller versions of the same problem.
Why We Need Recursion
Consider this example:
classNames('a', ['b', ['c', { d: true }]]);
The structure is:
- String:
'a' - Array containing:
- String:
'b' - Array containing:
- String:
'c' - Object:
{ d: true }
- String:
- String:
We can't know the depth of nesting ahead of time. Recursion allows us to handle arrays of any depth by processing each element and, if it's an array, processing it recursively.
How Recursion Works
A recursive function has two parts:
- Base case: The condition that stops recursion
- Recursive case: The function calls itself
function factorial(n) {
// Base case: if n is 0 or 1, return 1
if (n <= 1) return 1;
// Recursive case: call factorial with n - 1
return n * factorial(n - 1);
}
factorial(5); // 120
// factorial(5) = 5 * factorial(4)
// factorial(4) = 4 * factorial(3)
// factorial(3) = 3 * factorial(2)
// factorial(2) = 2 * factorial(1)
// factorial(1) = 1 (base case)
Recursion for Arrays
For our classNames function, when we encounter an array, we process it by
calling classNames again on each element:
function classNames(...args) {
const classes = [];
for (const arg of args) {
if (Array.isArray(arg)) {
// Recursive case: process the array by calling classNames on its elements
const nested = classNames(...arg);
if (nested) {
classes.push(nested);
}
}
// ... handle other types
}
return classes.join(' ');
}
The spread operator ...arg expands the array elements into individual
arguments, which are then processed by the same classNames function.
Understanding the Spread Operator in Recursion
When we call classNames(...arg), we're spreading the array elements:
const arr = ['foo', { bar: true }, 'baz'];
classNames(...arr);
// Equivalent to:
classNames('foo', { bar: true }, 'baz');
This is how we handle nested arrays - we flatten them by spreading.
Base Case for Recursion
Our base cases are:
- Empty array: Returns empty string
- String/Number: Added directly (no recursion needed)
- Object: Processed directly (no recursion needed)
Only arrays trigger recursion.
Example: Tracing Recursion
Let's trace through classNames('a', ['b', ['c', { d: true }]]):
1. classNames('a', ['b', ['c', { d: true }]])
- Process 'a': add 'a'
- Process ['b', ['c', { d: true }]]: it's an array, recurse
→ classNames('b', ['c', { d: true }])
- Process 'b': add 'b'
- Process ['c', { d: true }]: it's an array, recurse
→ classNames('c', { d: true })
- Process 'c': add 'c'
- Process { d: true }: add 'd'
- Return 'c d'
- Combine: 'b' + ' ' + 'c d' = 'b c d'
- Return: 'a' + ' ' + 'b c d' = 'a b c d'
Important: Handling Empty Arrays
Empty arrays should return an empty string:
classNames([]); // ''
classNames('foo', []); // 'foo'
When we recurse on an empty array, we get an empty string, which we then check before adding:
if (Array.isArray(arg)) {
const nested = classNames(...arg);
if (nested) {
// Only add if nested result is non-empty
classes.push(nested);
}
}
Avoiding Infinite Recursion
Recursion only works if we eventually hit a base case. With arrays, we're safe because:
- Arrays can only contain values (strings, objects, other arrays)
- Eventually, we'll hit strings or objects (base cases)
- Empty arrays return empty strings (base case)
As long as we don't create circular references (arrays containing themselves), recursion will terminate.
Recursion vs Iteration
Could we handle nested arrays with iteration instead? Yes, but it's more complex:
// Iterative approach (more complex)
function flattenIteratively(arr) {
const result = [];
const stack = [...arr];
while (stack.length > 0) {
const item = stack.pop();
if (Array.isArray(item)) {
stack.push(...item);
} else {
result.push(item);
}
}
return result;
}
// Recursive approach (simpler)
function flattenRecursively(arr) {
const result = [];
for (const item of arr) {
if (Array.isArray(item)) {
result.push(...flattenRecursively(item));
} else {
result.push(item);
}
}
return result;
}
For our use case, recursion is cleaner and easier to understand.
String Manipulation: Building the Final Result
After processing all arguments, we need to combine the collected class names into a single string with spaces between them, and ensure no leading or trailing whitespace.
What Is String Manipulation?
String manipulation involves creating, modifying, and combining strings. For our function, we need to:
- Collect class names in an array
- Join them with spaces
- Remove any leading/trailing whitespace
Why We Use an Array First
Instead of concatenating strings directly, we collect class names in an array:
// ❌ String concatenation (inefficient and error-prone)
let result = '';
if (condition1) result += 'class1 ';
if (condition2) result += 'class2 ';
return result.trim(); // Need to trim trailing space
// ✅ Array collection (clean and efficient)
const classes = [];
if (condition1) classes.push('class1');
if (condition2) classes.push('class2');
return classes.join(' '); // Clean join, no extra spaces
Using an array is better because:
- No trailing spaces: We don't need to worry about trailing spaces
- Efficient: Arrays are faster for collecting values
- Clean: Easy to add/remove items
- No trimming needed:
join()handles spacing automatically
The join() Method
The Array.prototype.join() method combines array elements into a string:
['foo', 'bar', 'baz'].join(' '); // 'foo bar baz'
['foo', 'bar', 'baz'].join('-'); // 'foo-bar-baz'
['foo'].join(' '); // 'foo'
[].join(' '); // '' (empty string)
Handling Empty Arrays
If we have no classes collected, join() returns an empty string:
[].join(' '); // ''
This is perfect for our edge case where all arguments are falsy:
classNames(null, false, undefined); // [] → '' → ''
The trim() Method
The trim() method removes leading and trailing whitespace from a string:
' foo bar '.trim(); // 'foo bar'
'foo bar'.trim(); // 'foo bar' (no change)
' '.trim(); // '' (empty string)
Do We Need trim()?
In our implementation, we shouldn't need trim() if we're careful, but it's a
good safety measure. Let's see why:
const classes = [];
classes.push('foo');
classes.push('bar');
classes.join(' '); // 'foo bar' - no leading/trailing spaces
However, if we accidentally add empty strings or handle edge cases incorrectly,
trim() protects us:
// What if we accidentally added empty strings?
classes.push('');
classes.push('foo');
classes.join(' '); // ' foo' - leading space!
// With trim():
classes.join(' ').trim(); // 'foo' - fixed!
Edge Case: Multiple Spaces
What if we somehow end up with multiple spaces? join() only adds one space
between elements:
['foo', '', 'bar'].join(' '); // 'foo bar' - two spaces if empty string is included
But in our implementation, we skip empty strings (they're falsy), so this
shouldn't happen. Still, trim() is good defense-in-depth.
In Our Implementation
We'll collect classes in an array and join them:
function classNames(...args) {
const classes = [];
// ... process arguments and push to classes ...
return classes.join(' ').trim(); // Join with spaces and trim for safety
}
The trim() call ensures we never have leading or trailing whitespace, even if
we handle edge cases incorrectly.
Performance Consideration
Using join() is more efficient than string concatenation:
// ❌ Slow: Creates new string each time
let result = '';
for (const cls of classes) {
result += cls + ' ';
}
// ✅ Fast: Single join operation
classes.join(' ');
join() is optimized in JavaScript engines and is the recommended way to
combine array elements into a string.
Putting It All Together: The Complete Implementation
Now that we understand all the concepts, let's build the complete implementation step by step.
Step 1: Function Signature with Rest Parameters
function classNames(...args) {
// args is an array of all arguments
}
Step 2: Initialize Result Array
function classNames(...args) {
const classes = []; // Array to collect class names
}
Step 3: Iterate Over Arguments
function classNames(...args) {
const classes = [];
for (const arg of args) {
// Process each argument
}
}
Step 4: Skip Falsy Values
function classNames(...args) {
const classes = [];
for (const arg of args) {
if (!arg) continue; // Skip null, undefined, false, '', 0, NaN
}
}
Step 5: Handle Strings and Numbers
function classNames(...args) {
const classes = [];
for (const arg of args) {
if (!arg) continue;
if (typeof arg === 'string' || typeof arg === 'number') {
classes.push(String(arg)); // Convert to string and add
continue;
}
}
}
Step 6: Handle Arrays (Recursion)
function classNames(...args) {
const classes = [];
for (const arg of args) {
if (!arg) continue;
if (typeof arg === 'string' || typeof arg === 'number') {
classes.push(String(arg));
continue;
}
if (Array.isArray(arg)) {
// Recursively process array
const nested = classNames(...arg);
if (nested) {
classes.push(nested);
}
continue;
}
}
}
Step 7: Handle Objects
function classNames(...args) {
const classes = [];
for (const arg of args) {
if (!arg) continue;
if (typeof arg === 'string' || typeof arg === 'number') {
classes.push(String(arg));
continue;
}
if (Array.isArray(arg)) {
const nested = classNames(...arg);
if (nested) {
classes.push(nested);
}
continue;
}
// Handle objects
if (typeof arg === 'object') {
for (const key in arg) {
if (Object.prototype.hasOwnProperty.call(arg, key)) {
if (arg[key]) {
// Only include if value is truthy
classes.push(key);
}
}
}
}
}
}
Step 8: Return Joined String
function classNames(...args) {
const classes = [];
for (const arg of args) {
if (!arg) continue;
if (typeof arg === 'string' || typeof arg === 'number') {
classes.push(String(arg));
continue;
}
if (Array.isArray(arg)) {
const nested = classNames(...arg);
if (nested) {
classes.push(nested);
}
continue;
}
if (typeof arg === 'object') {
for (const key in arg) {
if (Object.prototype.hasOwnProperty.call(arg, key)) {
if (arg[key]) {
classes.push(key);
}
}
}
}
}
return classes.join(' ').trim();
}
Complete Implementation
Here's our final, complete implementation with comments:
/**
* classnames - Conditionally join CSS class names together
*
* @param {...(string|Object|Array|null|undefined|boolean)} args - Variable arguments
* @returns {string} - Space-separated class names
*/
function classNames(...args) {
const classes = [];
for (const arg of args) {
// Skip falsy values (null, undefined, false, '', 0, NaN)
if (!arg) continue;
// Handle strings and numbers - add directly
if (typeof arg === 'string' || typeof arg === 'number') {
classes.push(String(arg));
continue;
}
// Handle arrays - recursively process
if (Array.isArray(arg)) {
const nested = classNames(...arg);
if (nested) {
classes.push(nested);
}
continue;
}
// Handle objects - iterate over keys, include if value is truthy
if (typeof arg === 'object') {
for (const key in arg) {
// Only process own properties (not inherited)
if (Object.prototype.hasOwnProperty.call(arg, key)) {
// Only include key if value is truthy
if (arg[key]) {
classes.push(key);
}
}
}
}
}
// Join with spaces and trim for safety
return classes.join(' ').trim();
}
Alternative: Using Object.keys()
We could also use Object.keys() for a cleaner object iteration:
if (typeof arg === 'object') {
Object.keys(arg).forEach((key) => {
if (arg[key]) {
classes.push(key);
}
});
}
Both approaches work; Object.keys() is more modern and cleaner, but for...in
with hasOwnProperty is more compatible with older codebases.
Testing and Edge Cases
Let's verify our implementation handles all the requirements and edge cases correctly.
Basic Test Cases
classNames('foo', 'bar'); // 'foo bar' ✅
classNames('foo', { bar: true }); // 'foo bar' ✅
classNames({ 'foo-bar': true }); // 'foo-bar' ✅
classNames({ 'foo-bar': false }); // '' ✅
Object Test Cases
classNames({ foo: true }, { bar: true }); // 'foo bar' ✅
classNames({ foo: true, bar: true }); // 'foo bar' ✅
classNames({ foo: true, bar: false, qux: true }); // 'foo qux' ✅
Array Test Cases
classNames('a', ['b', { c: true, d: false }]); // 'a b c' ✅
classNames(['foo', 'bar']); // 'foo bar' ✅
classNames([['foo'], 'bar']); // 'foo bar' ✅ (nested array)
Mixed Arguments Test Cases
classNames(
'foo',
{
bar: true,
duck: false,
},
'baz',
{ quux: true },
); // 'foo bar baz quux' ✅
Falsy Values Test Cases
classNames(null, false, 'bar', undefined, { baz: null }, ''); // 'bar' ✅
classNames(null); // '' ✅
classNames(false); // '' ✅
classNames(undefined); // '' ✅
classNames(''); // '' ✅
Edge Cases
// Empty array
classNames([]); // '' ✅
// Array with falsy values
classNames(['foo', null, 'bar']); // 'foo bar' ✅
// Number as class name
classNames('foo', 123); // 'foo 123' ✅
// Deeply nested arrays
classNames('a', ['b', ['c', { d: true }]]); // 'a b c d' ✅
// Empty object
classNames({}); // '' ✅
// Object with only falsy values
classNames({ foo: false, bar: null }); // '' ✅
// Mixed with empty values
classNames('foo', null, { bar: true }, '', { baz: false }); // 'foo bar' ✅
All test cases pass! Our implementation handles all requirements correctly.
Common Gotchas and Mistakes
Let's look at common mistakes developers make when implementing this function and how to avoid them.
Mistake 1: Not Checking for Empty Arrays
// ❌ Wrong: Empty arrays might cause issues
if (Array.isArray(arg)) {
classes.push(...arg); // What if arg is empty?
}
// ✅ Correct: Check if recursive call returns something
if (Array.isArray(arg)) {
const nested = classNames(...arg);
if (nested) {
classes.push(nested);
}
}
Mistake 2: Not Using hasOwnProperty
// ❌ Wrong: Might include inherited properties
if (typeof arg === 'object') {
for (const key in arg) {
if (arg[key]) {
classes.push(key);
}
}
}
// ✅ Correct: Only process own properties
if (typeof arg === 'object') {
for (const key in arg) {
if (Object.prototype.hasOwnProperty.call(arg, key)) {
if (arg[key]) {
classes.push(key);
}
}
}
}
Mistake 3: Forgetting About Number Types
// ❌ Wrong: Numbers are not strings
if (typeof arg === 'string') {
classes.push(arg);
}
// ✅ Correct: Handle both strings and numbers
if (typeof arg === 'string' || typeof arg === 'number') {
classes.push(String(arg));
}
Mistake 4: Not Handling Nested Arrays
// ❌ Wrong: Doesn't handle nested arrays
if (Array.isArray(arg)) {
arg.forEach((item) => {
classes.push(item); // What if item is an array?
});
}
// ✅ Correct: Recursively process arrays
if (Array.isArray(arg)) {
const nested = classNames(...arg);
if (nested) {
classes.push(nested);
}
}
Mistake 5: String Concatenation Instead of Array Join
// ❌ Wrong: String concatenation with trailing spaces
let result = '';
for (const cls of classes) {
result += cls + ' ';
}
return result.trim(); // Need to trim
// ✅ Correct: Array join is cleaner
return classes.join(' ').trim();
Mistake 6: Not Trimming the Result
// ❌ Wrong: Might have extra whitespace
return classes.join(' ');
// ✅ Correct: Always trim for safety
return classes.join(' ').trim();
Mistake 7: Checking Type Before Falsy Check
// ❌ Wrong: typeof null is 'object', but null is falsy
if (typeof arg === 'object') {
// This would try to process null!
}
// ✅ Correct: Check falsy first
if (!arg) continue;
if (typeof arg === 'object') {
// Now we know arg is truthy
}
Mistake 8: Not Handling the typeof null Quirk
// typeof null returns 'object', but we want to skip null
// That's why we check falsy first, before checking typeof
if (!arg) continue; // This catches null
// Now if we reach typeof arg === 'object', we know it's not null
Key Takeaways
Let's summarize what we learned:
JavaScript Concepts Covered
-
Rest Parameters (
...args)- Accept variable number of arguments
- Collect arguments into an array
- More convenient than
argumentsobject
-
Type Checking
typeofoperator for primitive typesArray.isArray()for arrays (sincetypeof []is'object')- Important quirk:
typeof nullis'object'
-
Truthy and Falsy Values
- 8 falsy values:
false,0,-0,0n,'',null,undefined,NaN - Everything else is truthy
- Use
!valueto check for falsy values
- 8 falsy values:
-
Object Iteration
for...inloop iterates over enumerable properties- Use
hasOwnPropertyto exclude inherited properties Object.keys()is a cleaner alternative
-
Array Handling
Array.isArray()to detect arrays- Iterate with
for...oforforEach - Arrays can contain any type, including nested arrays
-
Recursion
- Function calls itself to handle nested structures
- Base case: strings, objects (no recursion)
- Recursive case: arrays (call function on elements)
- Use spread operator to flatten arrays
-
String Manipulation
- Use arrays to collect values (better than concatenation)
Array.prototype.join()to combine with separatorString.prototype.trim()to remove leading/trailing whitespace
Best Practices
- Check falsy values first - Handles
null,undefined,false,''early - Use specific checks before general ones - Check arrays before objects
- Use recursion for nested structures - Cleaner than iterative flattening
- Collect in arrays, then join - More efficient and cleaner
- Always trim the result - Defense-in-depth against edge cases
- Handle edge cases explicitly - Empty arrays, empty objects, etc.
How to Apply These Concepts
These concepts aren't just for classNames - they're fundamental JavaScript
skills:
- Rest parameters: Useful for variadic functions (math utilities, logging, etc.)
- Type checking: Essential for robust functions that handle multiple types
- Truthy/falsy: Foundation of conditional logic in JavaScript
- Object iteration: Needed for transforming, filtering, or extracting data from objects
- Recursion: Powerful for processing tree-like structures (JSON, DOM, file systems)
- String manipulation: Essential for text processing, templating, formatting
Thinking Like a Utility Function Writer
When writing utility functions, consider:
- Edge cases: Empty values, null, undefined, different types
- Performance: Use efficient methods (
join()vs concatenation) - Compatibility: Handle different input types gracefully
- Safety: Defensive coding (trim, hasOwnProperty checks)
- Readability: Clear logic flow, good comments
- Testability: Easy to test with various inputs
Related Topics and Further Learning
Now that you understand how classNames works, here are related topics to
explore:
JavaScript Concepts
- Spread Operator (
...): Learn more about spreading arrays and objects - Optional Chaining (
?.): Modern way to safely access nested properties - Nullish Coalescing (
??): Handlenullandundefinedspecifically - Template Literals: Advanced string formatting
- Functional Programming: Map, filter, reduce patterns
Related Utility Functions
clsx: A faster, smaller alternative toclassnamescnutility: Often combinesclsxwithtailwind-mergefor Tailwind CSS- Object utilities:
Object.keys(),Object.values(),Object.entries() - Array utilities: Flattening, filtering, mapping
Real-World Applications
- React: Using
classNamesin components for conditional styling - Vue: Similar patterns for
:classbindings - CSS-in-JS: Libraries like styled-components, emotion
- Utility-first CSS: Tailwind CSS, where conditional classes are common
Practice Challenges
- Implement
classNamesfrom scratch (you just did this! 🎉) - Create a similar utility for data transformation
- Build a validation utility using similar patterns
- Create a query string builder using object iteration
Further Reading
- MDN: Rest Parameters
- MDN: typeof
- MDN: Truthy and Falsy
- MDN: Recursion
- JavaScript.info: Rest Parameters and Spread Syntax
Conclusion
Implementing classNames taught us more than just how to join CSS class names.
We explored:
- How to handle variable arguments with rest parameters
- The importance of type checking in JavaScript
- How truthy/falsy values work (and their gotchas)
- Different ways to iterate over objects
- How recursion simplifies nested structure processing
- Best practices for string manipulation
These concepts are fundamental to writing robust, maintainable JavaScript. Whether you're building utility functions, working with React, or just writing better JavaScript, understanding these concepts deeply will make you a better developer.
The next time you use a utility function, try implementing it yourself. You'll learn more than you expect, and you'll gain confidence in your JavaScript skills.
Test Your Understanding
Happy coding! 🚀