Why does merging or combining publishers fail to compile with “types Never and E must be equivalent”, and how do setFailureType, mapError and tryMap fix it?

11.10.2 · L3 · Interview

Predict first

Does merging a Never-failing publisher with one that fails with E compile?

import Combine
struct E: Error {}
let a = Just(1)
let b = Fail<Int, E>(error: E())
let both = a.merge(with: b)
  • No, compile error
  • Yes

Predict first

And after setFailureType, plus tryMap then mapError?

import Combine
struct E: Error {}
let a = Just(1).setFailureType(to: E.self)
let b = Fail<Int, E>(error: E())
let both = a.merge(with: b)
let converted = Just(1).tryMap { $0 }.mapError { _ in E() }
_ = (both, converted)
  • Yes, it compiles
  • No, compile error

Mental model. Plugs must match the socket. setFailureType is a plug adapter for a connector that can never carry current; mapError is a converter between two real standards.

Every publisher has an Output and a Failure, and operators that combine publishers (merge, combineLatest, zip, flatMap) require the failure types to be identical. Just(1) has Failure == Never, while Fail<Int, E> fails with E, so merging them is a compile error (“instance method ‘merge(with:)’ requires the types ‘Never’ and ‘E’ be equivalent”). The fix is to make the types agree explicitly: setFailureType(to: E.self) upgrades a Never-failing publisher to one that can fail with E (it never actually fails), and mapError converts one error type to another; tryMap and the other try operators make the failure type Error (the existential), which then needs mapError to get back to a specific type.

Design guidance: use one app-level error type per layer (a NetworkError, a RepositoryError) and convert at the boundary with mapError, so downstream code is not littered with Error; keep Never failure for UI-facing publishers (Q311) by handling errors in the inner publisher; and use eraseToAnyPublisher() at API boundaries so the long generic type does not leak. Since Swift 6 typed throws, async code has a similar story: prefer a specific error type when it is meaningful, or any Error when it is not. The compiler messages here are verbose but always point at the mismatch; read them for Failure and fix the type, never cast.

Failure types must be equal for merge.

setFailureType and mapError make the types agree.

Common traps

  • Fighting the compiler with as! or force-casting errors. Convert with mapError.
  • Using tryMap “just in case”. It widens the failure to Error and infects the rest of the pipeline.

They might follow up with

  • When can I use assign(to:on:)? Only when Failure == Never; so handle errors first.
  • Why does catch change the failure type? The replacement publisher’s failure becomes the new one, so catch { Just(…) } yields Never.

Related questions

  • 11.10.1 How do you end a pipeline: prefix(untilOutputFrom:), cancel and completion, and why does a sink that captures…
  • 11.7.3 Where should a catch go in a search pipeline, and what happens to the stream if you put it outside the…
  • 11.5.2 How do failures work in Combine, what does Never mean, and how do catch, replaceError, retry and mapError…
  • 11.6.1 When do you use Combine, AsyncSequence or the Observation framework, and when should you not use Combine?

← How do you end a pipeline: prefix(untilOutputFrom:), cancel and…