Skip to content

Instantly share code, notes, and snippets.

@cb372
Last active June 5, 2023 16:16
Show Gist options
  • Star 14 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save cb372/b54c974d2aa29bfdf4ef19f3535c719e to your computer and use it in GitHub Desktop.
Save cb372/b54c974d2aa29bfdf4ef19f3535c719e to your computer and use it in GitHub Desktop.
IO and tagless final

TL;DR

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.

Abstracting over IO

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 or Option, even though your production F might be IO or some complex transformer stack

Testing

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.

Tagless final style

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]

Exceptions and counter-arguments

Here are a few attempts to argue against myself.

Avoiding lock-in

"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.

Extracting a library

"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.

Monad transformers

"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.

Feedback

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.

@cb372
Copy link
Author

cb372 commented Feb 22, 2020

Thank you for the insightful responses everybody!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment