combineLatest’ing an empty collection

⇐ Notes archive

(This is an entry in my technical diary. There will likely be typos, mistakes, or wider logical leaps — the intent here is to “[let] others look over my shoulder while I figure things out.”)

Palle asked a thoughtful question in an issue on the CombineExt repository the other day:

When calling Collection.combineLatest on an empty collection of publishers, an empty publisher is returned that completes immediately.

Instead, wouldn’t it make sense to publish just an empty array instead (Just([Output]()))?

And they have a point. Even though CombineExt and RxJS (links to each’s handling) return an empty sequence, RxSwift forwards the result selector applied to an empty array before completing and ReactiveSwift even allows for an emptySentinel to be specified in this case.

I can understand both camps.

  • One could argue that combineLatest should only emit when any one if its inner observables does and if there are none then it’s a no-go for value events (i.e. return an empty sequence).
  • One could also argue that by not emitting a value event, this behavior truncates operator chains that might back UIs dependent on non-terminal events. Think fetchArrayOfFriendIDs.flatMapLatest { ids in ids.map(publisherOfFriendDetails).combineLatest() }.bind(…)… — the bindee (?) would never hear back if we completed immediately in the empty ids case.

Here’s a quick workaround to land in the second camp while using CombineExt and then I wanted to note some theory that supports the position.

import CombineExt

[Just<Int>]() // (1) `Just<Int>` for sake of example, any `Publisher` will do.
  .combineLatest()
  .replaceEmpty(with: []) // (2) Forward a `[]` value event.

Now for the — cracks knuckles — theory.

To derive a non-empty publisher value of [Just<Int>]().combineLatest(), we’ll take the approach the Point-Free duo did back in episode #4 (timestamped) when they asked what a function product: ([Int]) -> Int, which multiplies the supplied integers together, should return when called with an empty array.

Translating their approach means figuring out how combineLatest should distribute across array concatenation,

[Just(1)]
  .combineLatest()
  .combineLatest(
    [Just<Int>]()
      .combineLatest()
  ) // Yields a `Publisher<([Int], [Int])>`.

should ≈

([Just(1)] + [Just<Int>]()).combineLatest() // Yields a `Publisher<[Int]>`, hence the `≈`.

The righthand side of the equals sign evaluates to a publisher that emits a sole [1], which forces our hand on the left side. [Just<Int>]().combineLatest() needs to return at least one value event to avoid cutting off the [Just(1)].combineLatest() before it from emitting.

If [Just<Int>]().combineLatest() emits a sole [] then the first expression will emit a ([1], []) — which is why we can only use approximate equality because there’s an isomorphism between [1] and ([1], []) in the same way there’s one between 1 and (1, ()) in tupled form.

All of this is to sketch out that if we view combineLatest1 as a monoidal operator, then a publisher that emits a single [Output]() (i.e. Just([Output]())) acts as the unit and in turn, the result of the empty product under the operation.


  1. Or Publisher.zip