A materialization primer

(Assumed audience: folks with a working knowledge of Combine and the ObservableObject protocol.)

Materialization, like “fusion” and “dual,” is an intimidating term for a not-so-intimidating concept.

When I first encountered the word, I thought it was over the top. And, it kind of is. This entry tries to bring it down from abstraction and into Swift.

Let’s run into needing it by way of example, fetching integers from random.org.

Fetching random integers

We’ll extend Point-Free’s pinging of random.org for a random number.

(Gist permalink.)

With randomNumberPublisher in hand, we can imagine a view model using it when responding to pings to fetch a number.

(Gist permalink.)

Now’s let’s send on randomNumberPing a few times and see what happens.

(Gist permalink.)

And while not ideal, it checks out that a failure in the first request would preclude attempting the second. The Publisher contract states that an error event means a publisher won’t further emit values, effectively rendering our viewModel inert after a failure.

It might be tempting to rework RandomNumberViewModel.init with error handling.

(Gist permalink.)

Then, well, we run into another problem. We have no way of disambiguating between an initial and error state, since they both bear nil values. Moreover, if we had more errors than RandomDotOrgError.failedRequest, we’d lossily squash them.

We could lift RandomNumberViewModel.randomNumber into a Result, but, doing so would put the burden on consumers to switch over Result.success and .failure when focusing in on the cases they’re concerned about (e.g. content views, the success case, and error views, the failure).

Materialization—as you probably guessed—has a say in this matter.

First, let’s upgrade our view model output from an Int? to a type that better relays error and loading states.

(Gist permalink.)

And then, trying to wire everything up with DataLoadState.

(Gist permalink.)

Let’s take a breather. That was a ton of scaffolding to climb through, but, it’ll pay off—I promise!

Notes (3), (8), and (10) are gaps we’ll fill in with materialization.

<aliens-guy>Errors as values</aliens-guy>

We’ll often need publishers to drive client-side errors views—which, seem at odds with Combine’s Publisher contract:

[After sending a subscription to a subscriber and they request values,] the publisher is free to send that number of values or fewer to the subscriber.

…, if the publisher is finite, then it will eventually send a [single] completion or an error.

WWDC ’19, Session 722 (timestamped transcript)

Receiving only a single failure (or finished) event makes user-initiated retries and displaying error views difficult. We’d have to peek into the ‌receiveCompletion argument of either Publisher.handleEvents or .sink and then re-attach subscribers to new publisher instances.

Not ideal.

This not-ideal’ness stems from completion events being terminal. There’s sound practical (and theoretical1) reasons for this and we’ll need to bridge the gap by moving our involved Publisher’s Failure generic into its Output type.

“Into” in that sentence is a bit vague. Let’s pause with it.

Publishers emit either some (or none) Output-typed values or a single (or no) Subscribers.Completion<Failure> terminating event.

So, we need a way of translating a publisher to one in this form (subbing in AnyPublisher as a placeholder),

AnyPublisher<Output, Failure> ⇒ AnyPublisher<Event<Output, Failure>, Never>.

It’s worth noting our old friend2, Never prevents the transformed publisher from error’ing out.

I’ll also need to properly introduce our new friend, Event. It’s an enumeration to represent the possible events a subscriber may receive.

(Gist permalink.)

This is all sounds nice and well. But, what’s the point?

By materializing errors into a publisher’s output, we can then react to them in the same way we would handle ordinary values.

Or, rather, we’re considering errors as values.

Two implementations

We have two options: lean on existing operators or sketch out our own Publisher conformance. There’s tradeoffs for each and since there’s prior art for the latter in CombineCommunity/CombineExt, we’ll go with the former.

(Gist permalink.)

Now, we can start chaining operators, case by Event case.

Values? We need to box ‘em into Event.value.

(Gist permalink.)

Completion.finisheds? Well, we need a way of silencing upstream’s finished event and then turning around and publishing an Event.completion(.finished). Thankfully, the framework ships with a few .append(_:) overloads.

(Gist permalink.)

And lastly, (and densely3), catching error events.

(Gist permalink.)

That’s a lot to digest—let’s recap (and maybe take some Tums).

We’re doing three things.

  • Lifting emitted values from upstream into the Event.value case.
  • Intercepting completions with the append operator and turning around and sending an Event.completion(.finished) to subscribers.
  • Catching any errors, boxing them up into a Event.completion(.failure(error)), and pass it along as a value event.

Dematerializing

Materializing—in a sort of pseudo-Swift syntax—takes a Publisher<Output, Failure> and lifts it to a Publisher<Event<Output, Failure>, Never>, shifting errors to the value side.

However, consumers might only be concerned with specific Event cases. Think content views backed by Output instances or error views driven by Failure values.

We need a way of lowering a previously-materialized sequence, or as our wise elders called it, “dematerializing”

To start, we might be tempted to extend Publisher with an ‌Output == Event<Output, Failure> constraint.

(Gist permalink.)

Sadly, the compiler can’t reconcile the recursive constraint. We’ll need to introduce an intermediary protocol, EventConvertible, following Rx’s lead.

(Gist permalink.)

In both values and errors we’ll wanna focus in on upstream Event.value and .completion(.failure(_))s, respectively. There’s a couple of ways we could go about this—e.g. filtering cases or compacting associated values à la Point-Free’s enumeration properties.

The latter route is more fun—and of course, that doesn’t make the former any less valid.

To start, let’s add computed properties to EventConvertible to pluck out either an associated Output or Failure value.

(Gist permalink.)

These optional properties tee up Publisher.values and .errors to compactMap them out.

(Gist permalink.)

“All together now (materializing and dematerializing).” — The Beatles…I think.

The above snippets should all compile, again, and now we can sketch out a ContentView against RandomNumberViewModel with a button to fire off requests, honor loading states, and present a sheet with either a random number or error in under thirty lines.

(Gist permalink.)

In GIF action,

Sample `ContentView` using `RandomNumberViewModel`.

(Image permalink.)

Prior art

You’re probably—and rightfully—thinking that performing the materialize-share-values-errors dance on every ObservableObject conformance is going to be…a lot.

The community agrees. Thankfully, we can stand on their shoulders. In the past, folks have abstracted away materialization under a higher-level type called Action.

It comes in a few flavorings:

Each exposes observables and signals (synonyms for “publishers”) like Action.errors, .elements, and .executing corresponding to Publisher.values and .errors, and DataLoadState.loading above.

…is there a Combine Action analog?

Stay tuned, Adam Sharp has something in the works over at thoughtbot/CombineAction.


Special thanks to Van for feedback on an early draft of this entry.

⇒ Shai’s implementation of materialization.

⇒ The ReactiveX reference for materialize and dematerialize.

⇒ “TIL about Publishers.MergeMany.init

⇒ “Postfix type erasure

⇒ “Weak assignment in Combine

  1. I’ll have more on this soon. Until then, Brian Beckman and Erik Meijer’s ’09 Expert to Expert session is worth the time and in prose form, my post on duals translates Casey Liss’ from Rx

  2. No fret if you’re meeting Never for the first time. The NSHipster folks have a fantastic entry on the type. 

  3. If you’re open to operators, importing your Prelude of choice can rework the catch to this form