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.
Maybe I'm repeating the same points Kai is making, but basically I see the application as a series of layers, so you have your
Logging[F]
,db: Database[F]
layers, but cruciallymyApplicationFunction
is also part of a layer (for exampleUser[F]
), and when you start doing that, specialising toIO
happens:Moreover, I'd like to switch perspectives. For me it's not primarily about switching types (rarely happens), or transformers (useful but not crucial), or different testing types (just use
IO
). It's about building and composing languages that make your problem easy to express. Even proponents of concrete types like John also advise building domain specific languages that express key parts of your domain, the difference is just in approach: some people prefer these languages to be done in terms of data types, others in terms of parameterised functions (and the latter is tagless final).Can you build similar abstractions by passing records of functions that return IO (like ZIO environment)? You can, but (imho) it's way harder to stick to the "little languages" mindset when you always have the full
IO
there.So, sure, if you all of your code is made of
Sync[F].delay
, you can just useIO
, but the point of using tagless final is moving to a point where all of your code is not a bunch ofdelay
, but an onion ofUser[F]
,Logging[F]
,Db[F]
,Caching[F]
languages. At the end of the onion you find:F
IO
and so at the end of the day, you still only see
IO
at the very top level.Also, I feel that often the issues of "abstracting over
F
" and "passing things implicitly" get conflated: if you don't like passing things implicitly or are not sure, make more classes and passAbstraction[F]
explicitly (more in ML modules style than in Haskell style).Finally, I'd like to add a word about
Stream
, and how for me it's fine for that to be concrete, and how it fits in the "little languages" picture.Stream
is reifying your control mechanism, so you have concrete control (Stream
) of abstract actions (User[F]
)