FizzBuzz in JavaScript with infinite arrays and multimapping

Published on in JavaScript

Fun way to solve the simple puzzle. Infinitely repeating arrays and mapping over multiple arrays almost makes the remainder/modulo operator (%) unnecessary.

Table of contents

TL;DR

Jump to the Finished result + playground section →

Rules of FizzBuzz

A FizzBuzz sequence consists of numbers and strings where:

  • every 3rd item is "Fizz"
  • every 5th item is "Buzz"
  • every 15th item is "FizzBuzz"
  • and other items are numbers.

So the first 15 items are:

  1. 1
  2. 2
  3. Fizz
  4. 4
  5. Buzz
  6. Fizz
  7. 7
  8. 8
  9. Fizz
  10. Buzz
  11. 11
  12. Fizz
  13. 13
  14. 14
  15. FizzBuzz

After that, the cycle repeats. (The 16th item is 16 instead of 1, and so on.)

The challenge

Here's a typical solution, though without a for loop:

const result = Array.from({ length: 15 }, (_, i) => {
  const n = i + 1
  if (n % 15 === 0) return 'FizzBuzz'
  if (n % 3 === 0) return 'Fizz'
  if (n % 5 === 0) return 'Buzz'
  return n
})

console.log(result)
// [
//        1,      2, 'Fizz',      4,     'Buzz',
//   'Fizz',      7,      8, 'Fizz',     'Buzz',
//       11, 'Fizz',     13,     14, 'FizzBuzz',
// ]

It works a-okay, but let's solve FizzBuzz without using the remainder operator in the usual way, i.e. without these:

  • if (n % 15 === 0)
  • if (n % 3 === 0)
  • if (n % 5 === 0)

Before that, we need to acquire two new skills: creating infinite arrays and mapping over multiple arrays.

Infinite arrays

How arrays normally work (d'oh):

const array = ['A', 'B', 'C']

array.length //=> 3

array[0] //=> 'A'
array[1] //=> 'B'
array[2] //=> 'C'
array[3] //=> undefined
array[4] //=> undefined
// ...

What we want: when accessing an out-of-bounds numeric property of the array object, cycle back to the beginning of the array.

That can be achieved by creating a Proxy:

const proxyHandler = {
  get(target, prop) {
    // Accessing `target.length`
    if (prop === 'length') {
      return Infinity
    }

    // Accessing a numeric property of `target`, e.g. `target[5]`
    if (typeof prop === 'string' && /^\d+$/.test(prop)) {
      return target[prop % target.length]
    }

    // Accessing whatever else, e.g. `target.foo`
    return target[prop]
  },
}

const cycle = (array) => new Proxy(array, proxyHandler)

I named the function cycle because I was inspired by Clojure's cycle function.

Usage:

const infiniteArray = cycle(['A', 'B', 'C'])

infiniteArray.length //=> Infinity

infiniteArray[0] //=> 'A'
infiniteArray[1] //=> 'B'
infiniteArray[2] //=> 'C'
infiniteArray[3] //=> 'A'
infiniteArray[4] //=> 'B'
infiniteArray[5] //=> 'C'
infiniteArray[6] //=> 'A'
// ...

Notes about the typeof prop check

Why is the typeof prop === 'string' check in the proxy handler's get method needed?

Otherwise the regex test would throw an error if prop was a Symbol.

For example, if prop was Symbol.iterator:

;/^\d+$/.test(Symbol.iterator)
//=> TypeError: Cannot convert a Symbol value to a string

The error is thrown because RegExp.prototype.test expects a string argument, and Symbols can't be implicitly converted to strings.

Symbol.iterator is used by for...of loops. So, using infiniteArray in a for...of loop would thus throw an error without the typeof check.

Using any other Symbol property key would throw as well without the typeof check.

Why check that typeof prop is 'string' instead of 'number'?

E.g. in infiniteArray[5] the property key is a number, right? Well yes, but actually no.

Numeric property keys are converted to strings. From Working with objects on MDN:

All keys in the square bracket notation are converted to string unless they're Symbols, since JavaScript object property names (keys) can only be strings or Symbols.

An alternative to the typeof prop check

I said above that Symbols can't be implicitly converted to strings. But a Symbol can be converted explicitly to a string by calling its toString() method. Doing so would make the typeof prop check unnecessary:

-if (typeof prop === 'string' && /^\d+$/.test(prop)) {
+if (/^\d+$/.test(prop.toString())) {
   return target[prop % target.length]
 }

Neither option is very clear without an explanation. I might prefer the toString() alternative.

Shortcomings

The cycle function is imperfect. For example, slicing doesn't work:

const infiniteArray = cycle(['A', 'B', 'C'])

// Current behavior:
infiniteArray.slice(0, 7)
//=> ['A', 'B', 'C', empty × 4]

// Ideal behavior:
infiniteArray.slice(0, 7)
//=> ['A', 'B', 'C', 'A', 'B', 'C', 'A']

Supporting all array methods would be interesting... In fact, I'll leave it to you as an exercise. 😉

The current imperfect implementation is enough for solving FizzBuzz, so let's proceed.

Multimapping

Clojure's map function is similar to JavaScript arrays' map method, except in Clojure you can map over multiple arrays at once:

map returns a lazy sequence consisting of the result of applying [the function] f to the set of first items of each coll, followed by applying f to the set of second items in each coll, until any one of the colls is exhausted. Any remaining items in other colls are ignored.

A multimap function can be implemented in a few lines of JavaScript (I'm ignoring the part about returning a lazy sequence):

const multimap = (fn, ...arrays) => {
  const arrayLengths = arrays.map((array) => array.length)
  const smallestArrayLength = Math.min(...arrayLengths)

  return Array.from({ length: smallestArrayLength }).reduce((result, _, i) => {
    const items = arrays.map((array) => array[i])
    result.push(fn(items, i))
    return result
  }, [])
}

Usage:

// Same lenghts -> loop over 4 times
multimap(
  (args, i) => {
    console.log(i, args)
    return i
  },
  ['A', 'B', 'C', 'D'],
  ['W', 'X', 'Y', 'Z']
)
// Console loggings:
//   0, ['A', 'W']
//   1, ['B', 'X']
//   2, ['C', 'Y']
//   3, ['D', 'Z']
// Return value:
//   [0, 1, 2, 3]

// Different lengths -> loop over 3 times (the length of the shortest array)
multimap(
  (args, i) => {
    console.log(i, args)
    return args[i]
  },
  ['♠', '♡', '♢', '♣'],
  ['A', 'B', 'C', 'D', 'E'],
  ['♪', '♫', '☻']
)
// Console loggings:
//   0, ['♠', 'A', '♪']
//   1, ['♡', 'B', '♫']
//   2, ['♢', 'C', '☻']
// Return value:
//   ['♠', 'B', '☻']

Putting the pieces together: solving FizzBuzz

With our two new skills – creating (imperfect) infinite arrays and mapping over multiple arrays – we are ready to tackle the fearsomely challenging puzzle of FizzBuzz.

Step 1: rules of FizzBuzz as arrays

Let's translate the rules of FizzBuzz to arrays (if that makes sense). Let's also focus only on the first ten items of the FizzBuzz sequence for now.

  • "Every 3rd item is 'Fizz'" means that every 3rd item of an array is 'Fizz'. Other items are irrelevant, so they can be nulls:
    [null, null, 'Fizz', null, null, 'Fizz', null, null, 'Fizz', null]
    
  • "Every 5th item is 'Buzz'" works similarly:
    [null, null, null, null, 'Buzz', null, null, null, null, 'Buzz']
    
  • "Every 15th item is 'FizzBuzz'" can be ignored for now because the rule doesn't apply to a sequence of just ten items. But it would work similarly to the two previous rules/arrays.
  • "Other items are numbers" is simply an array of numbers starting from 1:
    [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
    

Let's see how they look when put into the multimap function:

multimap(
  (args) => {
    console.log(args)
  },
  [null, null, 'Fizz', null,   null, 'Fizz', null, null, 'Fizz',   null],
  [null, null,   null, null, 'Buzz',   null, null, null,   null, 'Buzz'],
  [   1,    2,      3,    4,      5,      6,    7,    8,      9,     10]
)
// Console loggings:
//   [null,     null,  1]
//   [null,     null,  2]
//   ['Fizz',   null,  3]
//   [null,     null,  4]
//   [null,   'Buzz',  5]
//   ['Fizz',   null,  6]
//   [null,     null,  7]
//   [null,     null,  8]
//   ['Fizz',   null,  9]
//   [null,   'Buzz', 10]
// Return value:
//   [undefined × 10]

Hmm, what if we take the first non-null item from each set of items?

multimap(
  (args) => args.find((x) => x !== null),
  [null, null, 'Fizz', null,   null, 'Fizz', null, null, 'Fizz',   null],
  [null, null,   null, null, 'Buzz',   null, null, null,   null, 'Buzz'],
  [   1,    2,      3,    4,      5,      6,    7,    8,      9,     10]
)
//=> [1,    2, 'Fizz',    4, 'Buzz', 'Fizz',    7,    8, 'Fizz', 'Buzz']

Hey, FizzBuzz! 😎 (Every 15th item will be handled soon.)

Step 2: truthy vs falsy

The arrays contain only strings, numbers and nulls. Strings and numbers are truthy values, and nulls are falsy values.

Instead of taking the first non-null item, the first truthy item can be taken, and the result will be the same. This simplifies the callback function:

multimap(
  (args) => args.find(Boolean),
  [null, null, 'Fizz', null,   null, 'Fizz', null, null, 'Fizz',   null],
  [null, null,   null, null, 'Buzz',   null, null, null,   null, 'Buzz'],
  [   1,    2,      3,    4,      5,      6,    7,    8,      9,     10]
)
//=> [1,    2, 'Fizz',    4, 'Buzz', 'Fizz',    7,    8, 'Fizz', 'Buzz']

Step 3: cycling 🚴‍♂️

The first two arrays are repeating, so they can be simplified with the new cycle function:

multimap(
  (args) => args.find(Boolean),
  cycle([null, null, 'Fizz']),
  cycle([null, null, null, null, 'Buzz']),
  [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
)
//=> [1, 2, 'Fizz', 4, 'Buzz', 'Fizz', 7, 8, 'Fizz', 'Buzz']

Step 4: range function

The third array is lame because it's a manually-created range of numbers. Let's create a range function:

const range = (from, to) =>
  Array.from({ length: to - from + 1 }, (_, i) => i + from)

range(5, 8)
//=> [5, 6, 7, 8]

Usage:

multimap(
  (args) => args.find(Boolean),
  cycle([null, null, 'Fizz']),
  cycle([null, null, null, null, 'Buzz']),
  range(1, 10)
)
//=> [1, 2, 'Fizz', 4, 'Buzz', 'Fizz', 7, 8, 'Fizz', 'Buzz']

Step 5: index argument and filler arrays

The range function is not actually even needed.

The callback function passed to the multimap function accepts index as its second argument. Just like the callback function passed to Array.prototype.map.

By using the index argument, the third array (range of numbers) is not needed, and thus the range function is not needed.

But the third array can't just be omitted as all of the remaining arrays would be infinite:

multimap(
  (args, i) => args.find(Boolean) || i + 1,
  cycle([null, null, 'Fizz']),
  cycle([null, null, null, null, 'Buzz'])
)
//=> RangeError: Invalid array length

Remember that the multimap function continues going over the arrays until any of them is exhausted. In other words, multimap goes over the arrays n times where n is the length of the shortest array.

Thus, multimap can be provided a "filler" array with a certain length. Because the other arrays are infinite, the filler array's length determines how many times multimap goes over the arrays.

We are not interested in the filler array's values, so an array with ten empty slots is fine:

multimap(
  (args, i) => args.find(Boolean) || i + 1,
  cycle([null, null, 'Fizz']),
  cycle([null, null, null, null, 'Buzz']),
  Array(10) // [empty × 10]
)
//=> [1, 2, 'Fizz', 4, 'Buzz', 'Fizz', 7, 8, 'Fizz', 'Buzz']

If you think using the range function is clearer, feel free to continue using it instead. I'm a rebel, so I'm going to continue using the filler array. Filler arrays will also be useful in the next steps.

Step 6: left-padded arrays

The first two arrays are mostly fillers too. A more concise way of creating them is to create filler arrays and concatenating them with the last non-filler value:

// Before:
;[null, null, 'Fizz']
;[null, null, null, null, 'Buzz']

// After:
Array(2).concat('Fizz') // [empty × 2, 'Fizz']
Array(4).concat('Buzz') // [empty × 4, 'Buzz']

Kinda like left-padding arrays... Someone quickly publish left-pad-array on npm!

Usage:

multimap(
  (args, i) => args.find(Boolean) || i + 1,
  cycle(Array(2).concat('Fizz')),
  cycle(Array(4).concat('Buzz')),
  Array(10)
)
//=> [1, 2, 'Fizz', 4, 'Buzz', 'Fizz', 7, 8, 'Fizz', 'Buzz']

Step 7: the remaining rule

The code works so far with Fizzes and Buzzes, but not with FizzBuzzes. Every 15th item is "Fizz" instead of "FizzBuzz":

multimap(
  (args, i) => args.find(Boolean) || i + 1,
  cycle(Array(2).concat('Fizz')),
  cycle(Array(4).concat('Buzz')),
  Array(15)
)
//=> [1,
//    2,
//    'Fizz',
//    4,
//    'Buzz',
//    'Fizz',
//    7,
//    8,
//    'Fizz',
//    'Buzz',
//    11,
//    'Fizz',
//    13,
//    14,
//    'Fizz', // :( Should be 'FizzBuzz'
//   ]

An easy fix is to use another array based on the remaining rule ("every 15th item is 'FizzBuzz'"):

multimap(
  (args, i) => args.find(Boolean) || i + 1,
  cycle(Array(14).concat('FizzBuzz')),
  cycle(Array(2).concat('Fizz')),
  cycle(Array(4).concat('Buzz')),
  Array(15)
)
//=> [1,
//    2,
//    'Fizz',
//    4,
//    'Buzz',
//    'Fizz',
//    7,
//    8,
//    'Fizz',
//    'Buzz',
//    11,
//    'Fizz',
//    13,
//    14,
//    'FizzBuzz', // :) Correct
//   ]

But the new array is not actually necessary because Fizz + Buzz = FizzBuzz:

multimap(
  (args, i) => args.join('') || i + 1,
  cycle(Array(2).concat('Fizz')),
  cycle(Array(4).concat('Buzz')),
  Array(15)
)
//=> [1,
//    2,
//    'Fizz',
//    4,
//    'Buzz',
//    'Fizz',
//    7,
//    8,
//    'Fizz',
//    'Buzz',
//    11,
//    'Fizz',
//    13,
//    14,
//    'FizzBuzz', // Still correct
//   ]

Finished result + playground

const cycle = (array) =>
  new Proxy(array, {
    get(target, prop) {
      if (prop === 'length') return Infinity
      if (/^\d+$/.test(prop.toString())) return target[prop % target.length]
      return target[prop]
    },
  })

const multimap = (fn, ...arrays) => {
  const arrayLengths = arrays.map((array) => array.length)
  const smallestArrayLength = Math.min(...arrayLengths)

  return Array.from({ length: smallestArrayLength }).reduce((result, _, i) => {
    const items = arrays.map((array) => array[i])
    result.push(fn(items, i))
    return result
  }, [])
}

const range = (from, to) =>
  Array.from({ length: to - from + 1 }, (_, i) => i + from)

// Variation 1:
multimap(
  (args) => args.find(Boolean),
  cycle(Array(14).concat('FizzBuzz')),
  cycle(Array(2).concat('Fizz')),
  cycle(Array(4).concat('Buzz')),
  range(1, 15)
)

// Variation 2:
multimap(
  (args, i) => args.join('') || i + 1,
  cycle(Array(2).concat('Fizz')),
  cycle(Array(4).concat('Buzz')),
  Array(15)
)

There are a lot more variations to discover. For example, these two are nice:

multimap(
  ([fizz, buzz, number]) => [fizz, buzz].join('') || number,
  cycle([null, null, 'Fizz']),
  cycle([null, null, null, null, 'Buzz']),
  range(1, 15)
)

multimap(
  ([word], i) => word || i + 1,
  cycle([null, null, 'Fizz', null, 'Buzz', 'Fizz', null, null, 'Fizz', 'Buzz', null, 'Fizz', null, null, 'FizzBuzz']),
  Array(15)
)

Play around with the code on flems.io

What kind of cool variations can you come up with? Send me an email! 😎 (Address on the home page.)

Acknowledgements

The approach and code in this blog post were like 90% inspired by the YouTube video FizzBuzz in Clojure with & without modulus/remainder/rest by Fred Overflow (cool name!).

I wanted to call this blog post "FizzBuzz in JS without the remainder operator," but I couldn't as I had to use the operator in the cycle function. ¯\(ツ)/¯