There are actually two solutions.
The first solution is the proposal proposed by Daniel Wagner: you modify two basic monads to use the same type of Left . For example, we could normalize them to use ByteString . To do this, first take the ByteString pack function:
pack :: String -> ByteString
Then we raise it to work on the left value of a EitherT :
import Control.Error (fmapLT) -- from the 'errors' package fmapLT pack :: (Monad m) => EitherT String mr -> EitherT ByteString mr
Now we need to target your Consumer base monad using hoist :
hoist (fmapLT pack) :: (Monad m, Proxy p) => Consumer pa (EitherT String m) r -> Consumer pa (EitherT ByteString m) r
Now you can compose your consumer directly with your producer, as they have the same basic monad.
The second solution is a proposal by Daniel Diaz Carrete. Instead, you agree that your two pipes agree with a common monad transformer stack that contains both layers of EitherT . All you have to do is decide in which order to nest these two layers.
Suppose you decide to put an EitherT String transformer outside of an EitherT ByteString transformer. This would mean that your final monad transformer target stack would be:
(Proxy p) => Session (EitherT String (EitherT ByteString p)) IO r
Now you need to promote both of your channels to target this transformer stack.
For your Consumer you need to insert an EitherT ByteString layer between the EitherT String and IO if you want to combine this final transformer stack. Creating a layer is very simple: you just use lift , but you need to target this level between these two specific layers, so you use hoist twice because you need to skip both the transformer with the proxy monad and the EitherT String monad transformer:
hoist (hoist lift) . consumer :: Proxy p => () -> Consumer pa (EitherT String (EitherT ByteString IO)) ()
For your Producer you need to insert an EitherT String layer between the intermediary transformer and the EitherT ByteString transformer if you want to combine the final transformer stack. Again, creating a layer is very simple: we just use lift , but you need to target this level between these two specific layers. You are just a hoist , but this time you use it only once, since you only need to skip the intermediary monad transformer to attach the lift in the right place:
hoist lift . producer :: Proxy p => () -> Producer pa (EitherT String (EitherT ByteString IO)) r
Now your manufacturer and consumer have the same monad transformer unit, and you can assemble them directly.
You may be wondering now: is this hoist ing lift process doing the “right thing”? The answer is yes. Part of the magic of category theory is that we can strictly define what it means to correctly insert the "empty monad transformer layer" using lift , and we can also exactly determine what it means to "target something between two monad transformers" using hoist , pointing out some theoretically inspired laws and verifying that lift and hoist comply with these laws.
Once we satisfy these laws, we can simply ignore all the detailed details of what exactly lift and hoist . Category theory frees us from working with a very high level of abstraction, where we just think about the terms “insert elevators” in the space between monad transformers, and the code magically translates our spatial intuition into strictly correct behavior.
My guess is that you probably want the first solution, since you can share error handling between producer and consumer in the same EitherT layer.