As a person who wants to be a professor, it is not really my thing to tell people not to learn stuff; especially out of curiosity.
However, I just want to point out that after some 8 years of writing Scala and 6 using almost exclusively the typelevel ecosystem. I still haven’t seen any good reason for doing the whole F[_]
stuff over just using IO
directly; especially for applications.
TBF, the underlying idea is not that advanced per se. However, the problem is that there are a lot of technical details in the middle and a lot of sugar syntax in the middle that muddles the waters (which is one of the main reasons why I don’t recommend it).
The whole point of F[_]
stuff is just polymorphism.
Just like when creating a data structure like List
we don’t want to force it to contain only numbers, words, dogs, etc. Rather we would want it to be able to contain anything. Thus, we add a type parameter A
to the definition:
trait MyList[A] {
def head: A
def tail: MyList[A]
}
We could apply a similar logic to our services or concurrent data structures.
Thus, instead of:
trait Jokes {
def get: IO[Jokes.Joke]
}
We don’t need to tie Jokes
to IO
but rather we could leave it abstract for any “effect type”.
trait Jokes[F[_]] {
def get: F[Jokes.Joke]
}
Here comes the first syntax issue, what F[_]
even is? Well, is just a type parameter like the A
in the List
. But, it looks different enough to confuse folks the first time they see it.
The reason for that is that while most people have an ok understanding of types (despite the usual confusion with classes), people usually don’t know about kinds (which are like the types of the types).
I may expand about kinds if you want but for now the TL;DR; is that contrary to the A
for List
, we here need a way to say that our type F
is itself “generic” (the correct word is parametric). Because the result of get
is not a F
but an F
of Jokes.Joke
; i.e. F[Jokes.Joke]
. The way Scala allows us to say that is by adding the funny nested [_]
to the type parameter. Thus the trait Jokes[F[_]]
.
The second common problem once you go down the F[_]
route is adding functionality. Because generics stop being simple once you need them to be “less generic”.
Returning to the MyList
example.
We could add some methods that are completely transparent to the underlying types; like map
:
trait MyList[A] {
def map[B](f: A => B): MyList[B]
}
If we were to implement that, we don’t care about what will be the real types behind the type parameters A
and B
.
But, what if we like to add a method that sums all the numbers in the List
?
trait MyList[A] {
def sum: A
}
The moment you try to implement that you realize you need to know something about A
, not everything, no need to know it will be Int
specifically. But, you do need to know that it has some kind of +
method as well as some form of zero
value.
This, is again a problem of polymorphism. We could try to tackle it using the traditional subtyping approach, but sooner than later we will find that we need something else. Here is where typeclasses enter. I won’t go into much detail about that since I already have a whole post about it: Polymorphism in Scala. · GitHub
The stdlib already has the typeclass that we need for this: Numeric
. Which as the name says, represents that a type behaves like a number; e.g. can be summed and has a zero
value (plus more stuff).
trait MyList[A] {
def sum(ev: Numeric[A]): A
}
But, there is still one small issue, we don’t want our users to have to pass that Numeric
instance themselves. Since it kind of feels redundant. Like if I have a MyList
of Ints
I know I want the Numeric
of Ints
. The word “the” here is important, because there is one and only one Numeric[Int]
; where addition is +
and the zero is 0
.
The way to solve that is to add the funny implicit
modifier to the parameter:
trait MyList[A] {
def sum(implicit ev: Numeric[A]): A
}
And now folks can do MyList(1, 2, 3).sum
and get back a 6
as expected (unless we fuck up the implementation
).
And how does that affect our F[_]
stuff? Well, because most of the time when you use a type parameter like F[_]
you need to manipulate values of type F[A]
(for some type A
). And you will need to know something about that F
, like maybe you need to know it has a map
method, or that it should be able to run in the background, or that it may have failed, etc.
You could be tempted to define your own interfaces / typeclasses for those behaviors that you want. But for better and for worse these are already well defined by maths.
Enter category theory and its funny words like Monad
(WTF is a Monad?).
I also won’t elaborate further because despite what most folks believe, all this is actually very simple (which is one of the reasons why people struggle such much, they want something more meaningful). Functor
, Monad
, and friends are just interfaces of common behaviors that these “effect types” tend to provide like map
and flatMap
. So actually, the hard concept is not Monad
; that just means that your F[_]
has a flatMap
, the hard concept is understanding what flatMap
means in a general sense!
Anyways, probably if they didn’t have such funny names folks would be more open to learning those, thankfully the capabilities added by CE have “better names” like Concurrent
or Async
. (despite those being actual hard concepts that most devs don’t fully grasp; oh the irony
)
Oh speaking of which, I just used a new term: capabilities.
Don’t worry, this is easy, is just a common word folks use to refer to these typeclasses that provide behavior that is useful to “track”.
And which ones would be those you ask? Well, as with most things, it kind of depends on who you. But, personally, I like this division:
- Sequential composition:
MonadError
.
- Concurrent composition:
Concurrent
.
- Synchronous FFI:
Sync
.
- Asynchronous FFI:
Async
.
But some folks want to move time out of that: Temporal
. Some others want to point out that parallelism is its own thing:
Parallel, and that it relies upon weaker versions of sequential composition:
Functor&
Applicative. And others will point out that we haven't talked about abstracting collections:
Foldable&
Traverse`.
Anyways, and what do I mean about “Tracking”?
Well, while using F[_]
means you may implement the function using either cats.effect.IO
or zio.ZIO
, and this is great for libraries.
Some folks would point out that even if you know you will be using IO
there is still value in doing this since now you can see a method / class and see which capabilities does it needs, it also means that if you say something only needs Concurrent
then it is unable to “suspend side-effect”; i.e. delay
. And all this is really nice actually.
PS: The phrase tracking capabilities will be more famous now that everyone and their pets are talking about effect tracking, effect systems, direct style, caprese, gears, loom, and whatnot. And while these are somehow related to what I just said, it is actually a different idea altogether, but to achieve the same goal, but in a different way, yet analogous, but with different flavor and style, but similar, but different, …
Anyway, too much rambling, let’s get back to the code.
def impl[F[_]: Concurrent](client: Client[F]): Jokes[F] = new Jokes[F] {
override final val get: F[Jokes.Joke] =
client.expect[Joke](GET(uri"https://icanhazdadjoke.com/"))
}
So this says that we can create a Jokes[F]
for any type F[_]
as long as we can provide a Client[F]
that will be used to make an HTTP call to get the Joke
(humor in 2024 is definitively dead if we need a full web app just to make a joke). Which makes sense, but we don’t want to block a compute thread while we wait for those bytes (oh right, because the whole point behind all this is building efficient concurrent systems, that is actually something useful, which is why I love IO
and typelevel despite hating F[_]
, and the reason why ZIO folks were smart about not mentioning anything about CT or programs-as-values in they landing page and just say “Concurrent programming framework”). And for not blocking the thread we need concurrency, that is what the funny-looking : Concurrent
thing is saying.
And wait, what is even that? Well, my friend, that is just more sugar syntax for the implicit that I mentioned before.
// This is just sugar syntax
def impl[F[_]: Concurrent](client: Client[F]): Jokes[F] = new Jokes[F] {
// Translates to this
def impl[F[_]](client: Client[F])(implicit ev: Concurrent[F]): Jokes[F] = new Jokes[F] {
Just like the Numeric
example I discussed like three lectures before.
But don’t worry, we are almost done.
There is only one extra point to discuss syntax imports:
So now we know that F[_]
is just a type parameter and that we will be using capabilities (typeclasses) to add behavior to that type. So, for example, you can write something like this:
def runTwice[F[_]](program: F[Unit])(using ev: Monad): F[Unit] =
ev.flatMap(program)(_ => program)
And that is great and really useful. But, having to go through the ev
for every operation is tiring. We know program
is able to flatMap
because its type if F[Unit]
and we know that F[_]
forms as Monad
. So we would like to just be able to do: program.flatMap
.
And the solution to that is just extension methods! And cats already provides lots of them:
import cats.syntax.all.*
def runTwice[F[_]](program: F[Unit])(using ev: Monad[F]): F[Unit] =
program.flatMap(_ => program)
And if we want to get fancy with the syntax we reach your typical looking CE code:
def runTwice[F[_] : Monad](program: F[Unit]): F[Unit] =
program >> program
And if you wonder what extension methods are, well they are just more implicits
, yeih!
So that is the problem with F[_]
and “capabilities”.
They are a very simple idea to its core, but in order to use them in Scala you end up stumbling against various concepts like kinds, typeclasses, implicits, CT, etc. Add to that the additional syntax, plus the loss of ergonomics and developer experience. Like having to find where each method is defined, remembering to import the syntaxes, etc.
Which is why I don’t recommend them. While I appreciate some of its advantages, I don’t think the positives outweigh the negatives; making the overall trade-off a loss IMHO.