Since the discussion is ongoing I’d like to add my take. Unfortunately it will be a half reply/half brain dump since I can’t think of a better way to format it.
I think that generally a Haskell programmer has to eventually learn how all the major styles work to navigate the ecosystem. EDIT: Removed unhelpful sentence here that had a bad tone.
If a library exports a transformer (including mtl) interface then you have to use that, if it exports an effect system interface then you have to use that. You will need to write some glue code between the library interface and whatever you chose as your main style for your app (unless there’s some magic that does this?). rio is widely used so you will also run into it or see it discussed.
If you are a library developer and you want a lot of people to be able to pick up and use your library quickly the best external interface for code with side effects is plain IO even if you use an effect system internally. This would also apply to an internal company library that is shared between multiple Haskell apps that could have different styles.
For me personally I’m trying to find a Haskell style that I enjoy working with, that is easy to refactor, and that I could get other people up to speed on quickly. The issue I have with most of the main styles is that they steer you into having a type that is spread throughout your whole app. This also makes all the type signatures in the app more complicated. Effect systems make it modular but there’s still the base effect type that gets spread everywhere. I’m ok with IO everywhere because it’s part of the language.
If I have IO readily at hand then I can turn complicatedPureFunctionA $ complicatedPureFunctionB myData into
do
resultB = complicatedPureFunctionB myData
log resultB
resultA = complicatedPureFunctionA resultB
without changing any type signatures or using unsafe functions. The majority of the code can stay pure with IO code acting as a “supervisor” or “scheduler” of pure computations.
My preference would be to only bring in types as needed so that most of the code has simple type signatures. In a simple web app a lot of the time the only resource required is a database connection and I can acquire one as needed rather than having a transaction per request. Keeping the types simple also makes it easier to refactor the code.
From a caller’s standpoint IO (Either) is clear and easy to read. The problems that come up are handling early exits without verbose code and multiple error types.
For the first problem ExceptT handles it and it’s a good example of how functionality can be added to a monad. It’s easy to learn and you can understand it as a nicer IO (Either) without knowing anything else about transformers other than lifting . The issues as you mentioned are the performance issues and resource handling. I haven’t dug into it much but as far as I could tell the performance issues seem to come up when you stack more than one transformer and possibly some types of loops. From the effectful docs it looks like resource handling breaks because of the way typeclasses are used to collapse lifts? If it can be fixed with either limiting the stack to ExceptT IO or adding some wrapping/unwrapping code I would be ok with using that to keep the readability benefits.
The second problem of multiple error types is where I would want something that looks like
ExceptT e1 IO a -> (e1 -> ExceptT e2 IO a) -> ExceptT e2 IO a
This is sort of like catchExceptT from @jaror but allows you to remap the error or map the error into a successful result. This would let you log some fine-grained error data and handle cases that you might not want to propagate up. For example an UnauthorizedError far down the call stack might have some data that allows it to be handled by the caller but should not be exposed externally. Picking where to split the types is tricky so I definitely don’t have that figured out yet.
A minor issue with ExceptT is the one mentioned in the article which is that it introduces a new kind of exception. Bringing in other languages is distracting but this is where I prefer the interface of Scala Cats EitherT . The names keep the concept separate from a notion of an exception and emphasizes that it is a better IO(Either). It’s an error value not an exception.
Lastly I don’t think that using ExceptT would exclude me from using effectful or bluefin. Once I call into the effect system I can use the error handling of that system and map the error when I get out. This also means that I could experiment with both systems in the same app.
If a complicated transformer stack can do some fancy backtracking then I might want to try it out as well but isolate it to a part of the code that needs it to avoid performance issues.
I didn’t dicuss rio much mostly because you have to thread the one type through everywhere. I don’t think that this is bad but it’s not my preference. I think that the discussion around introducing the rio pattern improved Haskell best practices in general.
Knowing about rio and transformers makes reading the bluefin and effectful docs easier. That’s why I think that starting with ExceptT is the right path to learning all the styles.
I wouldn’t recommend the style I’m working on. I’m describing it mostly to argue against having one style to rule them all. In the unlikely case that I get to do Haskell for work then I would adapt to whatever style is being used. At the same time with the tradeoffs that the current styles offer I would rather mix and match in my personal projects.