javascript Coursejavascriptutility-functionsreactcsstutorialcoding-challengeintermediate

Implementing classNames: A Deep Dive into JavaScript Utility Functions

33 min read

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 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

  1. Accept variable arguments: The function should accept any number of arguments
  2. Handle strings: String arguments should be added directly to the result
  3. Handle objects: Object keys should be included if their values are truthy
  4. Handle arrays: Arrays should be recursively flattened and processed
  5. Handle mixed arguments: Any combination of strings, objects, arrays, etc.
  6. Ignore falsy values: null, false, undefined, empty strings should be ignored
  7. 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:

  1. Rest Parameters - To accept variable arguments
  2. Type Checking - To differentiate between strings, objects, arrays, etc.
  3. Truthy/Falsy Values - To determine which values to include
  4. Object Iteration - To process object keys and values
  5. Array Methods - To handle array arguments
  6. Recursion - To flatten nested arrays
  7. String Methods - To build and clean the final result
  8. 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 arguments
  • classNames('foo', { bar: true }, 'baz', { qux: false }) - 4 arguments
  • classNames(...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:

  1. Rest parameters are real arrays - You can use array methods directly
  2. Arguments object is array-like - It has length and indices but isn't a true array
  3. 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:

  • typeof operator - Returns a string indicating the type
  • Array.isArray() - Checks if a value is an array
  • instanceof operator - 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:

  1. Check for falsy values first - Skip null, undefined, false, ''
  2. Check for strings and numbers - These can be added directly
  3. Check for arrays - Using Array.isArray()
  4. 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:

  1. false - The boolean false
  2. 0 - The number zero
  3. -0 - Negative zero (same as 0)
  4. 0n - BigInt zero
  5. '' - Empty string
  6. null - Null value
  7. undefined - Undefined value
  8. NaN - 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:

  1. Skip falsy arguments: if (!arg) continue;
  2. 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...in loop
  • Object.keys() - Returns an array of keys
  • Object.values() - Returns an array of values
  • Object.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:

  1. Check each key-value pair
  2. Include the key if the value is truthy (active, loading)
  3. 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:

  1. It only returns own properties (no inherited ones)
  2. It returns an array (can use array methods)
  3. 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:

  1. Process each element in the array
  2. Handle nested arrays recursively
  3. 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 }

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:

  1. Base case: The condition that stops recursion
  2. 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:

  1. Empty array: Returns empty string
  2. String/Number: Added directly (no recursion needed)
  3. 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:

  1. Collect class names in an array
  2. Join them with spaces
  3. 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:

  1. No trailing spaces: We don't need to worry about trailing spaces
  2. Efficient: Arrays are faster for collecting values
  3. Clean: Easy to add/remove items
  4. 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

  1. Rest Parameters (...args)

    • Accept variable number of arguments
    • Collect arguments into an array
    • More convenient than arguments object
  2. Type Checking

    • typeof operator for primitive types
    • Array.isArray() for arrays (since typeof [] is 'object')
    • Important quirk: typeof null is 'object'
  3. Truthy and Falsy Values

    • 8 falsy values: false, 0, -0, 0n, '', null, undefined, NaN
    • Everything else is truthy
    • Use !value to check for falsy values
  4. Object Iteration

    • for...in loop iterates over enumerable properties
    • Use hasOwnProperty to exclude inherited properties
    • Object.keys() is a cleaner alternative
  5. Array Handling

    • Array.isArray() to detect arrays
    • Iterate with for...of or forEach
    • Arrays can contain any type, including nested arrays
  6. 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
  7. String Manipulation

    • Use arrays to collect values (better than concatenation)
    • Array.prototype.join() to combine with separator
    • String.prototype.trim() to remove leading/trailing whitespace

Best Practices

  1. Check falsy values first - Handles null, undefined, false, '' early
  2. Use specific checks before general ones - Check arrays before objects
  3. Use recursion for nested structures - Cleaner than iterative flattening
  4. Collect in arrays, then join - More efficient and cleaner
  5. Always trim the result - Defense-in-depth against edge cases
  6. 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:

  1. Edge cases: Empty values, null, undefined, different types
  2. Performance: Use efficient methods (join() vs concatenation)
  3. Compatibility: Handle different input types gracefully
  4. Safety: Defensive coding (trim, hasOwnProperty checks)
  5. Readability: Clear logic flow, good comments
  6. Testability: Easy to test with various inputs

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 (??): Handle null and undefined specifically
  • Template Literals: Advanced string formatting
  • Functional Programming: Map, filter, reduce patterns
  • clsx: A faster, smaller alternative to classnames
  • cn utility: Often combines clsx with tailwind-merge for Tailwind CSS
  • Object utilities: Object.keys(), Object.values(), Object.entries()
  • Array utilities: Flattening, filtering, mapping

Real-World Applications

  • React: Using classNames in components for conditional styling
  • Vue: Similar patterns for :class bindings
  • CSS-in-JS: Libraries like styled-components, emotion
  • Utility-first CSS: Tailwind CSS, where conditional classes are common

Practice Challenges

  1. Implement classNames from scratch (you just did this! 🎉)
  2. Create a similar utility for data transformation
  3. Build a validation utility using similar patterns
  4. Create a query string builder using object iteration

Further Reading

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

🧩 Initializing quiz...
Quiz ID: implementing-classnames-javascript-utility-function

Happy coding! 🚀

Written by Sandeep Reddy Alalla

Share your thoughts and feedback!