setFailureType, setOutputType, and initial objects

(Assumed audience: folks familiar with Combine.)

Publisher.setFailureType(to:) is a head scratcher. At a glance, it seems to step on .mapError(_:)’s toes. And, it sort of does!

The “available when Failure is Never” note in the documentation hints at why.

So, let’s spin up our favorite Never-erroring publisher, Just, and map over its error type for a closer look.

(Gist permalink.)

Hmm. We need to implement (Never) -> OurError, eh? Maybe we can signal an OurError return, ignore the input, and call it a day?

(Gist permalink.)

This compiles‽

If you scratched your head at this, don’t worry. I did, too.

That’s the magic setFailureType generalizes and tucks away. In fact, we can prove that somewhere in Combine’s closed source, Apple calls { _ -> NewFailure in } for some generic failure type, NewFailure.

Before we do, it’s natural to ask if Publisher’s other associated type, Output, has the same affordance.

Whelp—time to write our own (!).

Publisher.setOutputType(to:)

After learning about setOutputType from Adam and PR’ing it to CombineExt, a couple of folks asked about its value.

[…], if the upstream is Never’d you’d probably append another publisher, which would change the output type to my understanding.

CombineExt/pull/15

This is almost the case! Let’s dig further.

A way to Never out a publisher is ignoring its output. Doing so turns it into a “completable” that only notifies downstream of Subscribers.Completion<Failure>.finished or .failure events.

(Gist permalink.)

We did this often at Peloton.

Our API had a notion of “workout finalization,” after which, a workout’s metrics would be min-max’d, summed, averaged, and cleaned before reporting secondary statistics like achievements and streaks.

So, we’d ignore the output of the publisher backing the finalization request and then, if successful, concatenate another publisher—of a different output type—to fetch any finalization-dependent metadata.

Some issues shake out when mirroring this in the above gist by appending another element.

(Gist permalink.)

Downstream from the third line, Output is fixed to Never, forcing the append method to accept either a variadic number of Nevers, a Sequence of ‘em, or another Never-outputting publisher—each corresponding to the three available overloads:

The first isn’t possible by extension of Never being uninhibited, the second only works with an empty Never sequence, and the third would mean chaining another “completable” (which, does have its uses).

We need to map Never into another type and that’s where setOutputType fits.

To start, let’s scope the implementation by providing a (Never) -> String.

(Gist permalink.)

The { _ -> String in } closure is a vacuous transformation. In that, since it’s never called, we can claim to return any type.

Requiring folks to roll their own { _ -> T in } closures for each T isn’t ideal and that’s the cosmetic setOutputType(to:) and setFailureType(to:) provide.

(Gist permalink.)

I should address the rogue type erasure after the Just publisher.

Turns out Apple buried some Easterlockdown eggs there—removing the erasure and setOutputType call still compiles.

(Gist permalink.)

Combine ships with overloads of ignoreOutput for specific publishers to keep Output in tact, but that isn’t true across all publishers (and why I reached for erasure to demonstrate the subtlety).

setFailureType(to:) and initial objects

You might’ve noticed—it’s okay, if you didn’t—that setOutputType’s implementation is kind of “forced” by the compiler to a lone map call (sans possibly sprinkling prints, fatalErrors, or other statements around).

If we tried to implement our own setFailureType operator, we’d be similarly guided.

(Gist permalink.)

There’s no other way to implement this function (with the noted exceptions).

And if you’re thinking that’s too much of a coincidence to not scratch a larger concept, you’re right (or also guessed from the section title)!

The concept is the (Never) -> NewOutput, (Never) -> NewFailure, or more generally, (Never) -> T closures for an arbitrary type, T. No matter which T you have in hand, the only way to map from Never to it is our new friend { _ -> T in }.

Put another way, there is a unique (Never) -> T closure per T in Swift’s type system—making Never a “starting point” each type can be uniquely mapped from.

Mathematicians call such starting points initial objects.

In their words,

An initial object of a category C is an object I in C such that for every object X in C, there exists precisely one morphism IX.

Replacing C with Swift1, I with Never, X with T, and “morphism” with “closure,” it’s fair to say Never is an initial object.

Further, since there is a unique closure from Never to any other type, that means Apple’s implementation of setFailureType—somewhere underneath the Publishers.SetFailureType hood—contains that mapError { _ -> NewFailure in } call.

Which is wicked because even though Combine is closed-source, initiality (i.e. Never being an initial object) forces the implementation.

setOutputType’s single expression definition packs almost a thousand words-worth of detail behind its signature and usage.

Further, Never is only one side of the coin. Can you think of a type in the Standard Library that has unique closures towards it? That is, which type, ???, in Swift has a unique function (T) -> ???, per type T?

It’s a fun exercise to think on.

Until next time.


Footnotes

  1. Calling Swift a category comes with asterisks. Still, it’s a trove from which we can translate decades-worth of learnings from category theory into everyday programming.