With all of the talk I've had about breaking changes in my libraries,
I definitely didn't want the Yesod world to feel left out. We've been
stable at yesod-core version 1.4 since 2014. But the changes going
through my package ecosystem towards
MonadUnliftIO are going to
affect Yesod as well. The question is: how significantly?
For those not aware,
MonadUnliftIO is an alternative typeclass to
MonadBaseControl and the
MonadMask classes in
exceptions, respectively. I've mentioned the
advantages of this new approach in a number of places, but the best
resource is probably the
release announcement blog post.
At the simplest level, the breaking change in Yesod would consist of:
WidgetT's internal representation. This is necessary since, currently, it's implemented as a
WriterT. Instead, to match with
MonadUnliftIO, it needs to be a
IORef. This is just about as minor a breaking change as I can imagine, since it only affects internal modules. (Said another way: it could even be argued to be a non-breaking change.)
- Drop the
MonadMaskinstances. This isn't strictly necessary, but has two advantages: it allows reduces the dependency footprint, and further encourages avoiding dangerous behavior, like using
StateTon top of
- Switch over to the new versions of the dependent libraries that are changing, in particular conduit and resourcet. (That's not technically a breaking change, but I typically consider dropping support for a major version of a dependency a semi-breaking change.)
- A number of minor cleanups that have been waiting for a breaking
changes. This includes things like adding strictness annotations in
a few places, and removing the defunct
This is a perfectly reasonable set of changes to make, and we can easily call this Yesod 1.5 (or 2.0) and ship it. I'm going to share one more slightly larger change I've experimented with, and I'd appreciated feedback on whether it's worth the breakage to users of Yesod.
Away with transformers!
NOTE All comments here, as is usually the case in these discussions,
refer to code that must be in
IO anyway. Pure code gets a pass.
You can check out the changes (which appear larger than they actually
no-transformers branch. You'll
see shortly that that's a lie, but it does accurately indicate
intent. If you look at the pattern of the blog posts and recommended
best practices I've been discussing for the past year, it ultimately
comes down to a simple claim: we massively overuse monad transformers
in modern Haskell.
The most extreme response to this claim is that we should get rid of
all transformers, and just have our code live in
IO. I've made a
slight compromise to this for ergonomics, and decided it's worth
keeping reader capabilities, because it's a major pain (or at least
perceived major pain) to pass extra stuff around for, e.g., simple
The core data type for Yesod is
HandlerT, with code that looks like
getHomeR :: HandlerT App IO Html. Under the surface,
looks something like:
newtype HandlerT site m a = HandlerT (HandlerData site -> m a)
Let's ask a simple question: do we really need
HandlerT to be a
transformer? Why not simply rewrite it to be:
newtype HandlerFor site a = HandlerFor (HandlerData site -> IO a)
All we've done is replaced the
m type parameter with a concrete
IO. There are already assumptions all over the place
that your handlers will necessarily have
IO as the base monad, so
we're not really losing any generality. But what we gain is:
- Slightly clearer error messages
- Less type constraints, such as
MonadUnliftIO m, floating around
- Internally, this actually simplifies quite a few ugly things around weird type families
We can also regain a lot of backwards compatibility with a helper type synonym:
type HandlerT site m = HandlerFor site
Plus, if you're using the
Handler type synonym generated by the
Template Haskell code, the new version of Yesod would just generate
the right thing. Overall, this is a slight improvement, and we need to
weigh the benefit of it versus the cost of breakage. But let me throw
one other thing into the mix.
Handling subsite (yes, transformers)
I lied, twice: the new branch does use transformers, and
is more general than
HandlerFor. In both cases, this has to do
with subsites, which have historically been a real pain to write
(using them hasn't been too bad). In fact, the entire reason we have
HandlerT today is to try and make subsites work in a nicely layered
way (which I think I failed at). Those who have been using Yesod long
enough likely remember
GHandler as a previous approach for this. And
anyone who has played with writing a subsite, and the hell which
ensues when trying to use
defaultLayout, will agree that the
situation today is not great.
So cutting through all of the crap: when writing a subsite, almost everything is the same as writing normal handler code. The following differences pop up:
- When you call
getYesod, you get the master site's app data (e.g.
Appin a scaffolded site). You need some way to get the subsite's data as well (e.g., the
- When you call
getCurrentRoute, it will give you a route in the master site. If you're inside
yesod-auth, for instance, you don't want to deal with all of the possible routes in the parent, but instead get a route for the subsite itself.
- If I'm generated URLs, I need some way to convert the routes for a subsite into the parent site.
In today's Yesod, we provide these differences inside the
type itself. This ends up adding some weird complications around
special-casing the base (and common) case where
in the new branch, we have just one layer of
ReaderT sitting on top
HandlerFor, providing these three pieces of functionality. And if
you want to get a better view of this,
check out the code.
What to do?
Overall, I think this design is more elegant, easier to understand, and simplifies the codebase. In reality, I don't think it's either a major departure from the past, or a major improvement, which is what leaves me on the fence about the no transformer changes.
We're almost certainly going to have a breaking change in Yesod in the near future, but it need not include this change. If it doesn't, the breaking change will be the very minor one mentioned above. If the general consensus is in favor of this change, then we may as well throw it in at the same time.