Implement a small utility, conventionally named classnames, that takes any number of arguments — strings, numbers, objects, arrays — and folds them down to a single space-separated string of CSS class names. It's the library a million React components reach for to write className={cn('btn', { active, disabled })} without a .filter(Boolean).join(' ') salad. See the popular classnames package on npm for the canonical behavior you're recreating.
// Accepts any number of args of mixed types and returns a single string.
function classnames(...args: ClassValue[]): string;
type ClassValue =
| string // included as-is
| number // included as-is (yes, including 0)
| null // skipped
| undefined // skipped
| boolean // skipped (true and false alike)
| { [key: string]: unknown } // key included when value is truthy
| ClassValue[]; // recursively flattened
classnames('btn', 'btn-primary');
// → 'btn btn-primary'
classnames('btn', { active: true, disabled: false }, ['lg', 'primary']);
// → 'btn active lg primary'
// Falsy values are dropped; nested arrays are flattened.
classnames('a', null, undefined, false, ['b', ['c', { d: true, e: 0 }]]);
// → 'a b c d'
// Numbers (including 0) are kept; empty strings are skipped.
classnames(0, 1, '', 'x');
// → '0 1 x'
null, undefined, false, and the empty string '' are all dropped from the output. They never appear, and they never produce stray spaces.0, is converted to its string form and included. This matches the real classnames library.{ active: true, disabled: false, hidden: 0 } contributes only active.['a', ['b', ['c']]] contributes a b c. The order of names in the output must match the left-to-right reading order of the input.classnames('a', 'a') is 'a a', not 'a'. Output order mirrors input order.You'll build a small recursive walker that flattens any mix of strings, numbers, objects, and arrays into a single space-separated string of class names.
You're writing a button component and the class list depends on three pieces of state: a base class, a couple of boolean flags, and an array of size modifiers passed by the parent. You don't want to write (['btn', active && 'active', disabled && 'disabled'].filter(Boolean).join(' ')) ten times a day. classnames is the one-liner that absorbs all that ceremony — feed it anything, get back a clean string ready for className=.
The function has to swallow five different input shapes: strings, numbers, plain objects (truthy key → emit key), arrays (flatten recursively), and falsy values (drop). Output is the surviving names joined by a single space — no leading, trailing, or doubled spaces, ever.
Think of the function as a depth-first walker over a small tree. The top level is the argument list. Each argument is a "node" that the walker classifies by type and either pushes a token, recurses, or drops the value. After the walk, the collected tokens are joined.
The key insight: every input shape collapses to "either push a string, or recurse." There is no need to allocate intermediate arrays or call .flat(). A single output list, written to as we walk, is enough.
The obvious first try just maps each argument to a string and joins:
function classnamesBroken(...args) {
return args
.map((arg) => {
if (typeof arg === 'string' || typeof arg === 'number') return arg;
if (Array.isArray(arg)) return arg.join(' ');
if (typeof arg === 'object' && arg !== null) {
return Object.keys(arg).filter((k) => arg[k]).join(' ');
}
return '';
})
.filter(Boolean)
.join(' ');
}
This passes the simplest example and feels close, but it breaks the moment you nest. classnames('a', ['b', ['c']]) returns 'a b ,c' because ['c'].join(' ') is fine, but the outer ['b', ['c']].join(' ') coerces the inner array to its toString() form — which is 'c' here but 'b,c'-style for longer lists. Even where the output looks right, you've stopped being recursive: ['a', [{ b: true }]] becomes 'a [object Object]'.
The mistake was treating arrays like leaves. Arrays are branches; only strings, numbers, and object keys are leaves.
function classnames(...args) {
const out = [];
// Walks one value of any supported type and pushes tokens into `out`.
const visit = (value) => {
// Drop null, undefined, false, and ''. Keep 0 (it's a valid class name).
if (!value && value !== 0) return;
const type = typeof value;
if (type === 'string' || type === 'number') {
// Leaf: stringify and push. String(0) === '0', which we want.
out.push(String(value));
return;
}
if (Array.isArray(value)) {
// Branch: recurse over each element. Arrays can nest arbitrarily.
for (const item of value) visit(item);
return;
}
if (type === 'object') {
// Plain object: include each key whose value is truthy.
for (const key in value) {
if (Object.prototype.hasOwnProperty.call(value, key) && value[key]) {
out.push(key);
}
}
}
};
for (const arg of args) visit(arg);
return out.join(' ');
}
module.exports = { classnames };
Three details earn their lines:
!value && value !== 0 — the truthy short-circuit handles null, undefined, false, and '' in one expression. The && value !== 0 clause rescues numeric zero, which is falsy in JS but a valid CSS class name (.0 is uncommon, but real classnames keeps it for parity).for...in + hasOwnProperty — iterates the object's own string keys. hasOwnProperty guards against an inherited property leaking in if someone passes a non-plain object (Object.create(parent)).out array — the walker writes into a single list. No intermediate .flat(), no spread, no concat. The final out.join(' ') produces exactly the spacing we want — one space between tokens, no leading/trailing whitespace, no doubled spaces.The shift from the naive version is recognising that arrays are branches, not leaves. Once visit recurses on arrays, the same function handles ['a'], [['a']], and [[[['a']]]] without any depth limit.
Trace classnames('btn', 0, { active: true, disabled: false }, ['lg', ['xl']]):
visit('btn') — string. out = ['btn'].visit(0) — !0 && 0 !== 0 is true && false → false, so we don't return early. Number branch: out = ['btn', '0'].visit({ active: true, disabled: false }) — object. Iterate keys: active has truthy value, push. disabled has falsy value, skip. out = ['btn', '0', 'active'].visit(['lg', ['xl']]) — array. Recurse over each item.
visit('lg') — string. out = ['btn', '0', 'active', 'lg'].visit(['xl']) — array. Recurse.
visit('xl') — string. out = ['btn', '0', 'active', 'lg', 'xl'].out.join(' ') → 'btn 0 active lg xl'.Notice how 0 survived (it's a valid class name even if odd), disabled: false was dropped at the object step, and the nested array required no special handling — the recursion absorbed it.
A toggleable class is the most common use case: { active: isActive }. Whatever lives in isActive (a boolean, a number, a string, an undefined) decides whether the key lands in the output. That's a one-cell truth table per key.
The library's design choice: the value's truthiness gates the key. That single rule unifies booleans ({ active: true }), conditional expressions ({ active: count > 0 }), and even computed strings ({ ['size-' + size]: true }).
0 as falsy and dropping it — a naive guard like if (!value) return drops 0 along with null/false. The fix is the value !== 0 rescue clause: classnames(0, 'x') must return '0 x', not 'x'..join(' ') on arrays instead of recursing — turns ['b', ['c']] into 'b,c' because Array.prototype.join coerces nested arrays via toString(). The walker must visit each element, not flatten with join.for...in without hasOwnProperty — if a caller passes an object inheriting from a polluted prototype (Object.prototype.evil = 'oops'), for...in will visit evil too. Guard with hasOwnProperty so you only see the caller's keys.result += name + ' ' and trimming at the end works but is fragile (one missed branch and you ship 'a b'). Push into an array and join(' ') — it can't produce doubled or trailing spaces by construction.String(value) for numbers — out.push(value) works because join will coerce, but explicit String(0) makes intent obvious and avoids surprise when someone later writes out.includes('0') and gets false because the array still holds the number 0.The real classnames library and its faster sibling clsx layer on a few extras worth knowing about:
classnames/bind lets you pre-bind a CSS Modules style map so cx({ active: true }) resolves to the hashed class name from the module. Useful in CSS Modules projects; about 15 lines on top of what you have.classnames/dedupe filters out duplicate names before joining. Trades a Set allocation for the guarantee that cn('btn', 'btn') returns 'btn'. Real classnames deliberately ships without this in its hot path because the DOM happily accepts duplicates and the dedupe overhead isn't worth it.tailwind-merge does the opposite of dedupe: it understands which Tailwind classes conflict (p-2 vs p-4) and keeps only the last one. That's a much bigger project — it ships a parsed schema of every Tailwind utility — but it's the same general shape: walker plus a final reconciliation pass.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.
Implement a small utility, conventionally named classnames, that takes any number of arguments — strings, numbers, objects, arrays — and folds them down to a single space-separated string of CSS class names. It's the library a million React components reach for to write className={cn('btn', { active, disabled })} without a .filter(Boolean).join(' ') salad. See the popular classnames package on npm for the canonical behavior you're recreating.
// Accepts any number of args of mixed types and returns a single string.
function classnames(...args: ClassValue[]): string;
type ClassValue =
| string // included as-is
| number // included as-is (yes, including 0)
| null // skipped
| undefined // skipped
| boolean // skipped (true and false alike)
| { [key: string]: unknown } // key included when value is truthy
| ClassValue[]; // recursively flattened
classnames('btn', 'btn-primary');
// → 'btn btn-primary'
classnames('btn', { active: true, disabled: false }, ['lg', 'primary']);
// → 'btn active lg primary'
// Falsy values are dropped; nested arrays are flattened.
classnames('a', null, undefined, false, ['b', ['c', { d: true, e: 0 }]]);
// → 'a b c d'
// Numbers (including 0) are kept; empty strings are skipped.
classnames(0, 1, '', 'x');
// → '0 1 x'
null, undefined, false, and the empty string '' are all dropped from the output. They never appear, and they never produce stray spaces.0, is converted to its string form and included. This matches the real classnames library.{ active: true, disabled: false, hidden: 0 } contributes only active.['a', ['b', ['c']]] contributes a b c. The order of names in the output must match the left-to-right reading order of the input.classnames('a', 'a') is 'a a', not 'a'. Output order mirrors input order.You'll build a small recursive walker that flattens any mix of strings, numbers, objects, and arrays into a single space-separated string of class names.
You're writing a button component and the class list depends on three pieces of state: a base class, a couple of boolean flags, and an array of size modifiers passed by the parent. You don't want to write (['btn', active && 'active', disabled && 'disabled'].filter(Boolean).join(' ')) ten times a day. classnames is the one-liner that absorbs all that ceremony — feed it anything, get back a clean string ready for className=.
The function has to swallow five different input shapes: strings, numbers, plain objects (truthy key → emit key), arrays (flatten recursively), and falsy values (drop). Output is the surviving names joined by a single space — no leading, trailing, or doubled spaces, ever.
Think of the function as a depth-first walker over a small tree. The top level is the argument list. Each argument is a "node" that the walker classifies by type and either pushes a token, recurses, or drops the value. After the walk, the collected tokens are joined.
The key insight: every input shape collapses to "either push a string, or recurse." There is no need to allocate intermediate arrays or call .flat(). A single output list, written to as we walk, is enough.
The obvious first try just maps each argument to a string and joins:
function classnamesBroken(...args) {
return args
.map((arg) => {
if (typeof arg === 'string' || typeof arg === 'number') return arg;
if (Array.isArray(arg)) return arg.join(' ');
if (typeof arg === 'object' && arg !== null) {
return Object.keys(arg).filter((k) => arg[k]).join(' ');
}
return '';
})
.filter(Boolean)
.join(' ');
}
This passes the simplest example and feels close, but it breaks the moment you nest. classnames('a', ['b', ['c']]) returns 'a b ,c' because ['c'].join(' ') is fine, but the outer ['b', ['c']].join(' ') coerces the inner array to its toString() form — which is 'c' here but 'b,c'-style for longer lists. Even where the output looks right, you've stopped being recursive: ['a', [{ b: true }]] becomes 'a [object Object]'.
The mistake was treating arrays like leaves. Arrays are branches; only strings, numbers, and object keys are leaves.
function classnames(...args) {
const out = [];
// Walks one value of any supported type and pushes tokens into `out`.
const visit = (value) => {
// Drop null, undefined, false, and ''. Keep 0 (it's a valid class name).
if (!value && value !== 0) return;
const type = typeof value;
if (type === 'string' || type === 'number') {
// Leaf: stringify and push. String(0) === '0', which we want.
out.push(String(value));
return;
}
if (Array.isArray(value)) {
// Branch: recurse over each element. Arrays can nest arbitrarily.
for (const item of value) visit(item);
return;
}
if (type === 'object') {
// Plain object: include each key whose value is truthy.
for (const key in value) {
if (Object.prototype.hasOwnProperty.call(value, key) && value[key]) {
out.push(key);
}
}
}
};
for (const arg of args) visit(arg);
return out.join(' ');
}
module.exports = { classnames };
Three details earn their lines:
!value && value !== 0 — the truthy short-circuit handles null, undefined, false, and '' in one expression. The && value !== 0 clause rescues numeric zero, which is falsy in JS but a valid CSS class name (.0 is uncommon, but real classnames keeps it for parity).for...in + hasOwnProperty — iterates the object's own string keys. hasOwnProperty guards against an inherited property leaking in if someone passes a non-plain object (Object.create(parent)).out array — the walker writes into a single list. No intermediate .flat(), no spread, no concat. The final out.join(' ') produces exactly the spacing we want — one space between tokens, no leading/trailing whitespace, no doubled spaces.The shift from the naive version is recognising that arrays are branches, not leaves. Once visit recurses on arrays, the same function handles ['a'], [['a']], and [[[['a']]]] without any depth limit.
Trace classnames('btn', 0, { active: true, disabled: false }, ['lg', ['xl']]):
visit('btn') — string. out = ['btn'].visit(0) — !0 && 0 !== 0 is true && false → false, so we don't return early. Number branch: out = ['btn', '0'].visit({ active: true, disabled: false }) — object. Iterate keys: active has truthy value, push. disabled has falsy value, skip. out = ['btn', '0', 'active'].visit(['lg', ['xl']]) — array. Recurse over each item.
visit('lg') — string. out = ['btn', '0', 'active', 'lg'].visit(['xl']) — array. Recurse.
visit('xl') — string. out = ['btn', '0', 'active', 'lg', 'xl'].out.join(' ') → 'btn 0 active lg xl'.Notice how 0 survived (it's a valid class name even if odd), disabled: false was dropped at the object step, and the nested array required no special handling — the recursion absorbed it.
A toggleable class is the most common use case: { active: isActive }. Whatever lives in isActive (a boolean, a number, a string, an undefined) decides whether the key lands in the output. That's a one-cell truth table per key.
The library's design choice: the value's truthiness gates the key. That single rule unifies booleans ({ active: true }), conditional expressions ({ active: count > 0 }), and even computed strings ({ ['size-' + size]: true }).
0 as falsy and dropping it — a naive guard like if (!value) return drops 0 along with null/false. The fix is the value !== 0 rescue clause: classnames(0, 'x') must return '0 x', not 'x'..join(' ') on arrays instead of recursing — turns ['b', ['c']] into 'b,c' because Array.prototype.join coerces nested arrays via toString(). The walker must visit each element, not flatten with join.for...in without hasOwnProperty — if a caller passes an object inheriting from a polluted prototype (Object.prototype.evil = 'oops'), for...in will visit evil too. Guard with hasOwnProperty so you only see the caller's keys.result += name + ' ' and trimming at the end works but is fragile (one missed branch and you ship 'a b'). Push into an array and join(' ') — it can't produce doubled or trailing spaces by construction.String(value) for numbers — out.push(value) works because join will coerce, but explicit String(0) makes intent obvious and avoids surprise when someone later writes out.includes('0') and gets false because the array still holds the number 0.The real classnames library and its faster sibling clsx layer on a few extras worth knowing about:
classnames/bind lets you pre-bind a CSS Modules style map so cx({ active: true }) resolves to the hashed class name from the module. Useful in CSS Modules projects; about 15 lines on top of what you have.classnames/dedupe filters out duplicate names before joining. Trades a Set allocation for the guarantee that cn('btn', 'btn') returns 'btn'. Real classnames deliberately ships without this in its hot path because the DOM happily accepts duplicates and the dedupe overhead isn't worth it.tailwind-merge does the opposite of dedupe: it understands which Tailwind classes conflict (p-2 vs p-4) and keeps only the last one. That's a much bigger project — it ships a parsed schema of every Tailwind utility — but it's the same general shape: walker plus a final reconciliation pass.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.