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.
On mobile so apologies for formatting. I have a comment on this, which may be counter intuitive but it is based on (probably statistically insignificant) experience: using IO everywhere seems to be worse for beginners. Please let me elaborate
I noticed that when allowing IO, people without much experience on working this way end up making bad coding choices. Like calling unsafeRunSync in many places.
Limiting to F seems to have a double benefit: the syntax is the same as with Monad/Applicative/etc (and I have not found the syntax to be an issue despite fixation on the community about it). And the limitation to what you can do helps guiding them to better understand the different types and needs without doing quick unsafeRunSync to just make it compile.
Granted, Sync has issues. And if you are at a ConcurrentEffect level there is no real difference. But I think it has a place to be for that purpose, while they figure out core concepts. Once they have enough experience, as with everything, let them make informed calls and use IO wherever they feel it fits😃
Another small reason: If you just show them Only IO, the unfortunate reality is that a big number of devs will settle with that and will not push for a deeper understanding. And that creates a bigger wall later on on why some things are a certain way.
Also, as a side note, we totally use IO in tests. Purity is nice. Pragmatism should rule decisions when you are coding for a business. As a hobby, go wild 😆