We should use a type parameter with a context bound (e.g. F[_]: Sync
) in library code so users can choose their IO monad, but we should use a concrete IO monad in application code.
If you're writing a library that makes use of effects, it makes sense to use the cats-effect type classes so users can choose their IO monad (IO, ZIO, Monix Task, etc).
So instead of
def myLibraryApiFunction(x: Int): IO[String] = {
val foo = IO(???)
// ...
}
you would write
def myLibraryApiFunction[F[_]: Sync](x: Int): F[String] = {
val foo = Sync[F].delay(???)
// ...
}
and a user of your library would instantiate F[_]
to be their chosen concrete IO monad.
(Instead of Sync
, you might use F[_]: ConcurrentEffect
or whatever, depending on the capabilities you require.)
But in application code, usually you know exactly which IO monad you're using. In such situations, I would argue that abstracting over it and writing [F[_]: Sync]
everywhere is just theatre. It's a false abstraction.
There's a qualitative difference between Cats type classes such as Functor
and Monad
, and the cats-effect type classes. You can write code using Monad
without thinking about whether you are dealing with a List
, an Option
, an Either
or one of dozens more data types with Monad
instances. But as soon as you need Sync
(or anything more powerful), you know your F[_]
is going to be IO
(or whatever IO monad implementation you've chosen for your application). So you may as well make it concrete and make your life easier.
All those calls to Sync[F].delay()
introduce a lot of noise, compared with wrapping things in IO(...)
, and I don't believe the noise is justified by the benefits of abstraction.
(Aside: the context-applied plugin can reduce the noise a bit.)
To be clear, I'm not arguing against all abstraction in application code. Writing
def myApplicationFunction[F[_]: Monad](input: F[String])
is a reasonable thing to do, as:
- it forces your code to be abstract as it can be, since it can't make any assumptions about its input other than the information provided by
Monad
- it acts as documentation, showing users of the function exactly what features of
F
the function relies on - you can test the function using a simple monad such as
Id
orOption
, even though your productionF
might beIO
or some complex transformer stack
Using a F[_]: Monad
context bound means you are free to use a nice simple monad in your tests, making the tests easier to construct and more readable.
This is not true if you use F[_]: Sync
. In that case you'll have to use IO
in your tests, and call unsafeRunSync()
.
A lot of people seem to have an aversion to using IO
in tests, but I'm not sure why.
Even if you make IO
concrete, it's still fine to give your dependencies a type parameter and pass them implicitly, just like you would normally:
def myApplicationFunction(x: Int)(implicit log: Logging[IO], db: Database[IO]): IO[String]
Here are a few attempts to argue against myself.
"I don't want my application to be locked in to cats-effect IO, as I might want to switch to Monix or ZIO later."
I'm not sure if anyone actually thinks this, but I've never really encountered this kind of situation myself. Seems like premature abstraction.
"I might want to extract part of my application into a library later."
That's a fair point, and there's probably something to be said for blurring the line between what's an application and what's a library. Maybe it makes sense to abstract over IO
in the most generic, "library-like" parts of your application but use concrete IO
towards the edges.
"Even though I'm using IO, my production F
is actually Kleisli[IO, Something, ?]
(or some other transformer)"
In that case, it definitely makes sense to hide away the monad transformer ugliness by using an abstract F[_]: Sync
.
For Kleisli in particular, it's worth pointing out that you can often achieve the same thing using a Ref
, avoiding the need for a transformer.
I'm not sure if any of the above is controversial, or I'm preaching to the choir. Please let me know your thoughts in the comments or on Twitter.
I'm also not entirely convinced of my own argument, as arguing against abstraction doesn't sound like me at all.
I'd be interested to hear more arguments for why using an abstract F[_]
everywhere is a good idea.
First of all thanks for a great piece. I share your sentiment against spending effort on things that will unlikely pay off. Most of the time one doesn't switch between various implementations of IO. Extraction of a library is more common but not so frequent either. Given that using pair of
[F[_]: Sync]
andSync[F].delay(...)
everywhere is more painful than simply going forIO(...)
one can try to minimize the total amount of pain expressed as follows (P for probability, 0.0 to 1.0 range, c for cost/pain in arbitrary units):where
The
c(IO_lib_version_bumps)
grows with the amount of IO-specific code that gets written and with probability of version conflicts in a larger, multi-module/multi-library project. It's something that you haven't mentioned, but I think it need to be taken in consideration in bigger projects. In theory at least, depending just on abstract typeclasses should reduce the number of necessary library version bumps and thus also reduce the number of version conflicts (maybe not in practice yet but who knows how the library evolution proceeds).The
c(IO_to_Sync_rewrite)
is a one-time cost, but again it depends on the amount of IO-specific code. I have some experience in maintaining of a codebase that relies heavily on Futures, without any sort of abstraction on top of it. The number of places that rely on some Future-specific features got really big over the years and the amount of work to replace it with some abstraction, or even with IO/ZIO would consume some serious effort. I think that following your recommendation it wouldn't be that bad, because one would ideally use some less powerful constraints, likeMonad
orApplicative
most of the time, but one needs to keep track of the places that use more IO features that exposed by a (Sync
) typeclass.Summing up, I think it's up to the expected cost (
P * c
) of each option:...the less you need to be worried about going for IO directly I believe.
On a final note, it just occurred to me, that the "concrete IO vs abstract Sync" is actually some specific example of a more general "technical debt vs clean code" decision, I find it interesting!