classnames
is a commonly-used utility in modern front end applications to conditionally join CSS class names together. If you've written React applications, you likely have used a similar library.
Implement the classnames
function.
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'
Arrays will be recursively flattened as per the rules above.
classNames('a', ['b', { c: true, d: false }]); // 'a b c'
Values can be mixed.
classNames('foo',{bar: true,duck: false,},'baz',{ quux: true },); // 'foo bar baz quux'
Falsey values are ignored.
classNames(null, false, 'bar', undefined, { baz: null }, ''); // 'bar'
In addition, the returned string should not have any leading or trailing whitespace.
classnames
library on GitHubclsx
library on GitHub: A newer version which serves as a faster and smaller drop-in replacement for classnames
.classnames
is a commonly-used utility in modern front end applications to conditionally join CSS class names together. If you've written React applications, you likely have used a similar library.
Implement the classnames
function.
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'
Arrays will be recursively flattened as per the rules above.
classNames('a', ['b', { c: true, d: false }]); // 'a b c'
Values can be mixed.
classNames('foo',{bar: true,duck: false,},'baz',{ quux: true },); // 'foo bar baz quux'
Falsey values are ignored.
classNames(null, false, 'bar', undefined, { baz: null }, ''); // 'bar'
In addition, the returned string should not have any leading or trailing whitespace.
classnames
library on GitHubclsx
library on GitHub: A newer version which serves as a faster and smaller drop-in replacement for classnames
.The following are good questions to ask the interviewer to demonstrate your thoughtfulness. Depending on their response, you might need to adjust the implementation accordingly.
Can there be duplicated classes in the input? Should the output contain duplicated classes?
Yes, there can be. In this case the output will contain duplicated classes. However, we will not test for this case.
What if a class was added and then later turned off? E.g.
classNames('foo', { foo: false })
?
In the library implementations, the final result will be 'foo'
. However, we will not test for this case.
The tricky part of this solution is the recursive nature of the function. Hence we can separate out the solution into two parts:
We will need a data structure, classes
to collect all the classes for the lifetime of the function that the recursive calls have access to. In our solution we use an Array
for the collection, but you can also use a Set
.
To recursively process each argument and collect the classes, a few approaches come to mind:
Here's how we will handle each data type:
classes
collection.classes
collection.classNames
function or inner recursive function.classes
collection.In this approach, the classNames
function calls itself and its return value is a string that can be composed by parent recursive calls.
/*** @param {...(any|Object|Array<any|Object|Array>)} args* @return {string}*/export default function classNames(...args) {const classes = [];args.forEach((arg) => {// Ignore falsey values.if (!arg) {return;}const argType = typeof arg;// Handle string and numbers.if (argType === 'string' || argType === 'number') {classes.push(arg);return;}// Handle arrays.if (Array.isArray(arg)) {classes.push(classNames(...arg));return;}// Handle objects.if (argType === 'object') {for (const key in arg) {// Only process non-inherited keys.if (Object.hasOwn(arg, key) && arg[key]) {classes.push(key);}}return;}});return classes.join(' ');}
In this approach, an inner classNamesImpl
helper function is defined and it accesses the top-level classes
collection within recursive calls. The helper function does not return anything, it's main purpose is to process each argument and add them to classes
.
export type ClassValue =| ClassArray| ClassDictionary| string| number| null| boolean| undefined;export type ClassDictionary = Record<string, any>;export type ClassArray = Array<ClassValue>;export default function classNames(...args: Array<ClassValue>): string {const classes: Array<string> = [];function classNamesImpl(...args: Array<ClassValue>) {args.forEach((arg) => {// Ignore falsey values.if (!arg) {return;}const argType = typeof arg;// Handle string and numbers.if (argType === 'string' || argType === 'number') {classes.push(String(arg));return;}// Handle arrays.if (Array.isArray(arg)) {for (const cls of arg) {classNamesImpl(cls);}return;}// Handle objects.if (argType === 'object') {const objArg = arg as ClassDictionary;for (const key in objArg) {// Only process non-inherited keys.if (Object.hasOwn(objArg, key) && objArg[key]) {classes.push(key);}}return;}});}classNamesImpl(...args);return classes.join(' ');}
In this approach, an inner classNamesImpl
helper function is defined and it accepts a classesArr
argument. The classesArr
is modified and passed along within recursive calls and all classNamesImpl
calls reference the same instance of classesArr
. The helper function does not return anything, it's main purpose is to process each argument and add them to the classesArr
argument.
export type ClassValue =| ClassArray| ClassDictionary| string| number| null| boolean| undefined;export type ClassDictionary = Record<string, any>;export type ClassArray = Array<ClassValue>;export default function classNames(...args: Array<ClassValue>): string {const classes: Array<string> = [];function classNamesImpl(classesArr: Array<string>,...args: Array<ClassValue>) {args.forEach((arg) => {// Ignore falsey values.if (!arg) {return;}const argType = typeof arg;// Handle string and numbers.if (argType === 'string' || argType === 'number') {classesArr.push(String(arg));return;}// Handle arrays.if (Array.isArray(arg)) {for (const cls of arg) {classNamesImpl(classesArr, cls);}return;}// Handle objects.if (argType === 'object') {const objArg = arg as ClassDictionary;for (const key in objArg) {// Only process non-inherited keys.if (Object.hasOwn(objArg, key) && objArg[key]) {classesArr.push(key);}}return;}});}classNamesImpl(classes, ...args);return classes.join(' ');}
The provided solution doesn't handle de-duplicating classes, which would be a nice optimization. Without du-duplication, classNames('foo', 'foo')
will give you 'foo foo'
which is unnecessary as far as the browser result is concerned.
In some cases, de-duplication can also affect the result, e.g. in the case of classNames('foo', { foo: false })
, { foo: false }
appears later in the arguments, so the user probably did not mean for 'foo'
to appear in the final result.
This can be handled by using Set
to collect the classes from the start, adding or removing classes where necessary.
De-duplicating classes is usually out of the scope for interviews but is a possible follow-up question. You can practice the de-duplicating functionality in Classnames II.
Array
s to Set
s and vice versa (for the unique classes follow-up)typeof []
gives 'object'
, so you need to handle arrays before objects.For your reference, this is how the classnames
npm package is implemented:
var hasOwn = {}.hasOwnProperty;export default function classNames() {var classes = [];for (var i = 0; i < arguments.length; i++) {var arg = arguments[i];if (!arg) continue;var argType = typeof arg;if (argType === 'string' || argType === 'number') {classes.push(arg);} else if (Array.isArray(arg)) {if (arg.length) {var inner = classNames.apply(null, arg);if (inner) {classes.push(inner);}}} else if (argType === 'object') {if (arg.toString === Object.prototype.toString) {for (var key in arg) {if (hasOwn.call(arg, key) && arg[key]) {classes.push(key);}}} else {classes.push(arg.toString());}}}return classes.join(' ');}
classnames
library on GitHubclsx
library on GitHub: A newer version which serves as a faster and smaller drop-in replacement for classnames
.console.log()
statements will appear here.