classnames is a commonly used utility in modern frontend 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'
Falsy 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 that serves as a faster and smaller drop-in replacement for classnames.These clarifications define which input shapes the class-name flattener should accept and which ones it should ignore.
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 duplicate classes. However, this case is not tested.
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, this case is not tested.
The subtle part is the recursive nature of the function. Separate the solution into two parts:
Use a classes data structure to collect all classes for the lifetime of the function and make them available to recursive calls. The solution uses an Array for the collection, but a Set also works.
To recursively process each argument and collect the classes, a few approaches come to mind:
Handle each data type as follows:
classes collection.classes collection.classNames function or inner recursive function.classes collection.The data-type order is part of the property. Check falsy values before anything else, then arrays before generic objects, because arrays also report typeof value === 'object'. Object values are conditions; object keys are the class names.
For the nested example classNames('a', ['b', { c: true, d: false }]), the recursion behaves like this:
| Value being processed | Contribution |
|---|---|
'a' | a |
['b', { c: true, d: false }] | recurse into each item |
'b' | b |
{ c: true, d: false } | c only |
| final join | a b c |
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) {// Each recursive call returns one space-joined segment; the parent call keeps// those segments in the original left-to-right order.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;}// Arrays recurse before objects because `typeof []` is `'object'`.if (Array.isArray(arg)) {classes.push(classNames(...arg));return;}// Only own truthy keys contribute classes.if (argType === 'object') {for (const key in arg) {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; its 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> = [];// Every recursive call writes into the same outer collection.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;}// Arrays recurse before objects because `typeof []` is `'object'`.if (Array.isArray(arg)) {for (const cls of arg) {classNamesImpl(cls);}return;}// Only own truthy keys contribute classes.if (argType === 'object') {const objArg = arg as ClassDictionary;for (const key in objArg) {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; its 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> = [];// Thread the same accumulator array through every recursive call.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;}// Arrays recurse before objects because `typeof []` is `'object'`.if (Array.isArray(arg)) {for (const cls of arg) {classNamesImpl(classesArr, cls);}return;}// Only own truthy keys contribute classes.if (argType === 'object') {const objArg = arg as ClassDictionary;for (const key in objArg) {if (Object.hasOwn(objArg, key) && objArg[key]) {classesArr.push(key);}}return;}});}classNamesImpl(classes, ...args);return classes.join(' ');}
null, undefined, false, '', and 0 do not add classes.typeof [] is 'object'.The provided solution does not handle de-duplicating classes, which would be a nice optimization. Without de-duplication, classNames('foo', 'foo') returns '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 scope for interviews, but it is a possible follow-up question. The de-duplicating functionality is covered in Classnames II.
Arrays to Sets and vice versa (for the unique classes follow-up)typeof [] gives 'object', so arrays need to be handled before objects.{ className: false } belongs to a different conflict-resolution model.For reference, this is how the classnames npm package is implemented:
export type ClassValue =| ClassArray| ClassDictionary| string| number| null| boolean| undefined;export type ClassDictionary = Record<string, any>;export type ClassArray = Array<ClassValue>;const hasOwn = Object.prototype.hasOwnProperty;export default function classNames(...args: Array<ClassValue>): string {const classes: Array<string> = [];for (let i = 0; i < args.length; i++) {const arg = args[i];if (!arg) continue;const argType = typeof arg;if (argType === 'string' || argType === 'number') {classes.push(String(arg));} else if (Array.isArray(arg)) {if (arg.length) {const inner = classNames(...arg);if (inner) {classes.push(inner);}}} else if (argType === 'object') {if (arg.toString === Object.prototype.toString) {const objectArg = arg as ClassDictionary;for (const key in objectArg) {if (hasOwn.call(objectArg, key) && objectArg[key]) {classes.push(key);}}} else {classes.push(arg.toString());}}}return classes.join(' ');}
classnames library on GitHubclsx library on GitHub: A newer version that serves as a faster and smaller drop-in replacement for classnames.console.log() statements will appear here.