Arrays as a Foundation

Angular relies heavily on RxJS observables. Personally, I can't wrap my head around a concept until I've built it from the ground up myself. Maybe you're wired the same way. If so, let's roll up our sleeves and construct an observable from nothing!

When I first encountered RxJS, I was completely lost. These days, I can tackle problems with observables and navigate the RxJS source code with confidence. It's absolutely an achievable skill for you as well.

The one prerequisite is this: to get observables, arrays must be second nature first.

We're going to see that observables operate on the very same conceptual model as arrays, with the addition of the time dimension. That comparison won't help, though, unless arrays themselves feel intuitive.

NOTE: If you are already confident with map, filter, reduce, and flattening nested arrays, feel free to jump straight to part 2.

The Starting Point

Let's start with a simple challenge: build an array in JavaScript containing the numbers 1, 2, and 3.

const arr = [1,2,3];

Next, write code that increments each number by one and collects the results into a fresh array.

const arr = [1,2,3];
const mappedArr = arr.map(value => value + 1); // returns [2,3,4]

Excellent. The tool that makes this possible is Array.prototype.map. It accepts a projection function—here, vale => value + 1—and runs it against every item in the array, returning a brand-new array. If you wanted, you could write your own version:

// overriding properties on Array.prototype is a bad idea. This is given for educational purposes only :D
Array.prototype.map = function(projectionFn) {
 let retVal = [];
  
 for (let i = 0; i < this.length; i++) {
   retVal.push(projectionFn(this[i])); 
 }
  
  return retVal;
}

No trickery involved. We just iterate over the original array, apply projectionFn to each element, and push the output into a new array. The this keyword refers to the array because map is invoked from Array.prototype. [1,2,3].map(v => v + 1)

Moving on to filter. Suppose we want to keep only the even numbers in the array:

const arr = [1,2,3];

const filteredArr = arr.filter(v => v % 2 === 0); // [2]

filter takes a predicate function—here, v => v % 2 === 0—and tests each element against it. Elements that produce true are included in the resulting array. Here's a manual filter implementation:

Array.prototype.filter = function(predicateFn) {
 let retVal = [];
 
 for (let i = 0; i < this.length; i++) {
   if (predicateFn(this[i]) {
     retVal.push(this[i]);    
   }
 }
  
 return retVal;
}

Again, it's a straightforward piece of logic.

The point here is to prove to yourself that there's no magic in these Array.prototype methods. They aren't opaque boxes—you could hand-code them if you chose.
Once you're comfortable with their internals, translating them to observables later on becomes much easier.

With map and filter in hand, we can start composing operations!

const arr = [1,2,3,4,5,6];
const composedArr = arr.map(x => x + 1).filter(y => y % 2 === 0); // [2,4,6];

The key detail is that our map and filter methods always produce arrays. This is essential for chaining array operations. If they returned anything else—undefined, null, or 567, say—we'd lose access to Array.prototype and composition would break.

Consider [1,2,3].map(x => x + 1).567.filter(x => x > 2): it fails because 567 lacks a filter method (it's not an array, and that's what we're chaining on).

Composition is an intriguing subject. MPJ has a great video series on Youtube here, and Eric Elliott offers an excellent introduction as well.
As we'll discover, observables are equally composable.

Still stuck on arrays after all this? I assure you, observables are just around the corner. But there's one more lesson to extract from arrays first. Once that clicks, observables will feel simple.

What does this code produce?

const gameData = [
  {
    title: 'Mega Man 2',
    bosses: [
      {
        name: 'Bubble Man',
        weapon: 'Bubble Beam'
      },
      {
        name: 'Metal Man',
        weapon: 'Metal Blade'
      }]
  },
  {
    title: 'Mega Man 3',
    bosses: [
      {
        name: 'Gemini Man',
        weapon: 'Gemini Laser'
      },
      {
        name: 'Top Man',
        weapon: 'Top Spin'
      }]
  }
];

// return an array of all bosses

const bossesArray = gameData.map(game => {
  return game.bosses;
});

// uh oh, those are nested arrays!
// [[{},{}],[{},{}]]

At line 30, we call map on the gameData array. The rule is clear: map always returns an array. No exceptions.
Yet, within the map callback, we return game.bosses—also an array—resulting in a nested structure like [[{ …boss data…}]].

That's likely not what we intended. We'd prefer everything packed into one flat array. What we need is a function that takes an array and reduces its nesting by one level. For our array with a single layer of nesting, we'd invoke this depth-reducing function once.

Unfortunately, Array.prototype lacks a clean method for this today. Array.prototype.concat can serve, though it's not the best fit. Looking ahead, we'll have flat and flatMap, but they're not here yet, so let's craft our own flatten function. It will output a new array with one less level of depth.

Array.prototype.flatten = function() {
	let retVal = [];
  
  this.forEach(a => {
    retVal = retVal.concat(a);
  });
  
  return retVal;
}

let arr = [[1,2], [3], 4, [5,6], [[7], 8]];

console.log(arr.flatten()); // [1, 2, 3, 4, 5, 6, [7], 8]

Now we return to our earlier example and apply flatten to the nested game.bosses array generated by map:

const bossesArray = gameData.map(game => {
  return game.bosses;
}).flatten();

// returns a flattened array of boss objects [{}, {}, {}, {}]

Using a map followed by a flatten is such a typical pattern that most languages merge them into a single flatMap operator.

Array.prototype.flatMap = function(fn) {
	return this.map(fn).flatten();
}

// usage
const bossesArray = gameData.flatMap(game => {
  return game.bosses;
}); // [{}, {}, {}, {}]

The ability to dive down a level with map, then resurface with flatten, is indispensable when handling nested data structures like arrays. This same need shows up regularly with higher-order observables (observables within observables). If you've ever faced nested observables and reached for the flatMap operator, you know exactly what I'm talking about.


That wraps up part one. If you're confident with these array methods, it's time to introduce asynchrony with observables.

Key Takeaways

  • Array methods such as map and filter excel at enabling composition
  • These methods are not mysterious—they're simple enough to implement yourself
  • Nested arrays demand a more capable tool, like a flatMap.
  • As we'll see shortly, these principles translate directly to observables.

Further Reading

Part 2: Containers and Intuition