Http4s - logging inside "onError" method

Hello all - I’m a Scala newbie - I followed the example here: Quick Start (http4s.org) and imported the project into Intellij IDEA.

One of the REST APIs that is implemented in the example fetches a dad joke from a website and returns it as JSON:

def impl[F[_]: Concurrent](C: Client[F]): Jokes[F] = new Jokes[F]{
    val dsl = new Http4sClientDsl[F]{}
    import dsl._

    def get: F[Jokes.Joke] = {
      C.expect[Joke](GET(uri"https://icanhazdadjoke.com/"))
        .adaptError{ case t => JokeError(t) }
    }
  }

I want log any errors in .onError(), after .adaptError(), and I’ve tried using logging with one of these two constructs, and both result in compilation errors:

def logError[F[_]: Logger]: PartialFunction[Throwable, F[Unit]] = {
        case e: Exception      => Logger[F].error(e)("error handler")
  }

Adding .onError(logError) after .adaptError() results in:

No implicit arguments of type: Logger[F_]

What am I missing?

Thank you!

You need to provide a Logger for whatever F is instantiated to (IO in this case) and propagate it to the usage site.

Fixing compilation errors bottom up, you’ll have to modify the Jokes#impl() signature to

def impl[F[_]: Concurrent: Logger](C: Client[F]): Jokes[F] =
  // ...

or, equivalently:

def impl[F[_]: Concurrent](C: Client[F])(using Logger[F]): Jokes[F] =
  // ...

…proceed similarly with QuickstartServer#run(), then eventually create a Logger instance in Main and pass it, e.g.:

val run = 
  Slf4jLogger.create[IO].flatMap { 
    case given Logger[IO] => QuickstartServer.run[IO]
  }

See Contextual Abstractions for details on this mechanism.

http4s uses quite many advanced Scala constructs. I’d suggest to use a text book and smaller ad hoc projects not involving any external libraries in order to familiarize yourself with Scala fundamentals before (or at least: side by side with) getting into the nitty-gritty of http4s and similar libraries.

2 Likes

My two cents, you can make your life easier if you forget about both the whole F[_] stuff and if you just pass stuff explicitly.

def impl(client: Client[IO], logger: Logger[IO]): Jokes = new Jokes {
  import org.http4s.dsl.io._

  override val get: IO[Jokes.Joke] =
    client
      .expect[Joke](GET(uri"https://icanhazdadjoke.com/"))
      .adaptError { case t => JokeError(t) }
      .onError(error => logger.error("Error", ex))
}

Hi Patrick - you’re a godsend, thank you!

impl()'s signature has been changed to:

trait Jokes[F[_]]{
  def get: F[Jokes.Joke]
}

object Jokes {

  def apply[F[_]](implicit ev: Jokes[F]): Jokes[F] = ev

  final case class Joke(joke: String) extends AnyVal
  object Joke {
    implicit val jokeDecoder: Decoder[Joke] = deriveDecoder[Joke]
    implicit def jokeEntityDecoder[F[_]: Concurrent]: EntityDecoder[F, Joke] =
      jsonOf
    implicit val jokeEncoder: Encoder[Joke] = deriveEncoder[Joke]
    implicit def jokeEntityEncoder[F[_]]: EntityEncoder[F, Joke] =
      jsonEncoderOf
  }

  final case class JokeError(e: Throwable) extends RuntimeException

  def logError[F[_]: Logger]: PartialFunction[Throwable, F[Unit]] = {
    case e: Exception      => Logger[F].error(e)("error handler")
  }

  def impl[F[_]: Concurrent :  Logger](C: Client[F]): Jokes[F] = new Jokes[F]{
    val dsl = new Http4sClientDsl[F]{}
    import dsl._
    def get: F[Jokes.Joke] = {
      C.expect[Joke](GET(uri"https://icanhazdadjoke.com/"))
        .adaptError{ case t => JokeError(t)}
        .onError(logError)
    }
  }
}

QuickstartServer.scala:

object QuickstartServer {

  def run[F[_]: Async: Network: Logger]: F[Nothing] = {
    for {
      client <- EmberClientBuilder.default[F].build
      helloWorldAlg = HelloWorld.impl[F]
      jokeAlg = Jokes.impl[F](client)

      // Combine Service Routes into an HttpApp.
      // Can also be done via a Router if you
      // want to extract segments not checked
      // in the underlying routes.
      httpApp = (
        QuickstartRoutes.helloWorldRoutes[F](helloWorldAlg) <+>
        QuickstartRoutes.jokeRoutes[F](jokeAlg)
      ).orNotFound

      // With Middlewares in place
      finalHttpApp = Logger.httpApp(true, true)(httpApp)

      _ <- 
        EmberServerBuilder.default[F]
          .withHost(ipv4"0.0.0.0")
          .withPort(port"8080")
          .withHttpApp(finalHttpApp)
          .build
    } yield ()
  }.useForever
}

My Main however complains with this error:

could not find implicit value for evidence parameter of type org.typelevel.log4cats.Logger[cats.effect.IO]
      case _: Logger[IO] => QuickstartServer.run[IO]

Here’s what I got main to look like:

object Main extends IOApp.Simple {
  def run: IO[Unit] = {

    Slf4jLogger.create[IO].flatMap {
      case _: Logger[IO] => QuickstartServer.run[IO]
    }
  }
}

Not sure how to do it with your ‘val run = …’ approach. By the way, I am using Scala 2.13.13, not Scala 3.

Thanks BalmungSan, that was the approach I got working already, I forgot to mention, but this whole F[_] thing piqued my curiosity and apparently it is pretty advanced stuff for my newb Scala brain. Been doing C++ and Java for 20 years, but this is a whole different ball game.

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 :grimacing:).

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 :stuck_out_tongue:)

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.

3 Likes

Why? Scala 3 has been out for almost three years now. There may be reasons to still use/keep using Scala 2 in production, but for learning it feels like an odd choice by now.

A Scala 2 way of putting the logger into implicit scope, to be picked up by the callee’s constraints on F, would be:

val run =
  Slf4jLogger.create[IO]
    .flatMap {
      implicit _: Logger[IO] => Quickstart2Server.run[IO]
    }

We had cases where we moved from plain IO to ReaderT/StateT on top of IO, and I’m pretty confident that the migration was helped a lot by the fact that most of the code was in generic F[_] to start with. (Of course one can question whether monad stacking is a good idea at all. :wink:) I also like that you don’t have a blunt effect/pure divide, but that you can reason about the requirements of a piece of code (e.g. ranging from Functor to Async) in a more fine-grained way, and that up to MonadError you can test (or even reuse) it in other, weaker effects than IO, e.g. Either.

Even if you dislike this style and avoid using it, which is perfectly fine and justifiable, of course, you’ll probably have to learn it at some point, anyway - you need the background to make the decision for yourself, and others are using it - such as the author(s) of the http4s quickstart project. :person_shrugging:

That being said, I definitely think this stuff should come up pretty late in the learning curve - which is why I tried to steer away from using http4s for a learning project…

1 Like

Fair, but I personally have never needed to do that. But I know that is not a proper argument.
However, note that AFAIK StateT[IO] is simply broken, and I really can’t think of a good reason for doing that over a Ref. And ReaderT[IO] (aka Kleisli) can easily be replaced with a constructor argument or IOLocal if one does not want to pass a repeated argument down the chain multiple layers.

A similar argument was pointed by @sageserpent-open on a DM.
While I understand that is technically possible, IME it never happened.
Usually, such tests end up needing either Concurrent or something like a Ref. Thus, they has always been in IO.

The reuse argument is more compelling IMHO, but given that it is easy to convert an Either into an IO I would rather write the utility using Either and convert when interoperating with IO.
And yeah, this sounds like a workaround and that is something I will tackle in a moment.

TBH, this is actually something good. I tried to highlight it in my original post, but I should have been clearer.
My argument though is just that I don’t think this is good enough to justify the whole cost of TF in terms of syntax burden and loss of developer UX.

Imagine if rather we could do something like this:

def impl(client: Client)(using Concurrent): Jokes = new Jokes {
  override final val get: IO[Jokes.Joke] =
    client.expect[Joke](GET(uri"https://icanhazdadjoke.com/")) // Compiles!
    IO.delay(println("Printing!")) // Doesn't compile because we only have access to the Concurrent capability.
}

We would get both the documentation benefit as well as the avoid using the wrong function benefit without losing everything else.


So let me try to be clearer.

I do understand that you can get positives with TF.
But I think there are either workarounds or simpler encodings that could have been used.
And that the negatives outweigh the positives.

And this is the reason why I feel so grumpy about it.
IMHO TF and the http4s DSL are the to biggest reasons why newcomers see typelevel and think “bruh that is some mathy stuff that needs a PhD”. I understand IO on its own is also steep learning curve, but you can easily sell the positives.

So, right now I would say that using plain IO + smithy4s is a great way to sell typelevel as a productive framework.

1 Like

StateT is broken wrt concurrency, i.e. for Spawn and upward. You can use it with Sync (and instantiate to IO) just fine, though.

In our specific case, we needed to carry along some mutable state in HTTP handlers that so far didn’t have any concurrent flow, so StateT would’ve been an option. But of course there was a risk concurrency might be introduced later on, so we went along with ReaderT (and #local() for “mutation”) instead.

This only covers the scope of a single class/trait, you’ll still need to propagate the argument between chained classes/traits.

I’ve had cases of (partial) computations that only required MonadError where the failure mode was associated directly with the effect. (I.e. an internal error, whereas I’d have encoded a user input error as a dedicated Either, anyway.) It probably wouldn’t hurt a lot to write this in Either, but writing it in MonadError, running in IO in production but testing in Either feels cleaner and clearer to me.

AFAIU that’s something that’s currently being worked on, and I’m really curious to try it out and compare it to existing “monadic” effect systems. But until then we’ll have to make do with what’s there… :slightly_smiling_face:

Hmm… I recall that I found the polymorphism aspect pretty enticing when I first learned about monads and IO - to me personally, IO in isolation might’ve been a harder sale… (I also do recall that I found this segment of the learning curve pretty tough, but worthwhile throughout.)

But just accepting your premise - why on earth should the whole eco system exclusively be tailored to newcomers? Just as Scala as a language supports the full bandwidth between “a better (still OO leaning) Java” and “almost Haskell on the JVM”, the CE stack supports both “hardwired” effects and MTL style, and that’s great - what’s there to be grumpy about?

I haven’t looked into smithy4s, but just taking a look at the quickstart, I see

object HelloWorldImpl extends HelloWorldService[IO]:
  // ...

…which makes me wonder how far it actually insulates the user from polymorphic effects. I’d think that http4s allows you to write code hardwired to IO in pretty much the same way if you want to - it’s just that their documentation takes a different stance than smithy4s’…?

1 Like

I have mixed feelings here. I agree that I generally wouldn’t start folks off with F[_] until they’re comfortable with FP more generally – it’s an additional complication that is mostly unnecessary when you’re starting out.

OTOH, having recently gone through the somewhat unpleasant process of upgrading from ZIO 1 → ZIO 2 for a substantial enterprise application, that did a lot to help me appreciate the decoupling and indirection provided by the F[_] approach: when everything is built on top of a single concrete type, that means you have no choice but go through a “big bang” upgrade, which is a major pain for a complex fast-moving product. There are benefits to having that indirection available, if used judiciously.

But that said, even within the Typelevel community, I don’t think there’s a clear consensus here. We generally recommend that libraries operate in F[_], in order to provide flexibility for usage. But there are plenty of folks who recommend using plain IO in application code: the flexibility tends to be less relevant, and it simplifies signatures around the code.

It’s a matter of taste: just keep in mind that changing it after-the-fact isn’t simple, so think about it before starting a large project. (You can mix-and-match, but not randomly: basically, the lower-level code might be F[_] while the higher-level code is concrete IO, but not really the other way around.)

2 Likes

I don’t know. I am here just to write concurrent programs in a simpler, safer, and more efficient way.
Everything else around it is either necessary or a good extra. But I really don’t care too much about it. “To each their own” I guess.

Well because unless newcomers join the ecosystem will eventually die. Anyways, I also didn’t mean to imply that the whole thing had to be tailored to newcomers.+

First, my main argument has been simply that IME I have never found it useful enough, but I didn’t mean to imply it was not useful at all. And “enough” is probably very subjective.
I do however think that there is no reason to push the F[_] stuff to newcomers on day one, which is basically what stuff like the http4s official template does. You can also see it in the CE docs and what not. It simply became the typelevel standard so most people try to learn it at the beginning.
At that point my argument is even simpler, if you want to use it on applications then great, but at least let’s teach folks IO first and then F[_] rather than everything together at the beginning.

Sorry for not being clear.
Yeah you can use concrete IO with http4s just like with smithy4s you can use F[_]
The reason to prefer smithy is because it hides the http4s DSL which has been, at least IME, another source of headaches and ragequits for newcomers.


While F[_] does mitigate that, it does as long as the typeclasses hierarchy doesn’t change like with CE2 VS CE3. So, I would be interested to know if that was any different (I have zero experience with both situations). AFAIK, it was also a big-bang update.
Now, there are two counterarguments.

  1. Either argue that it was still easier than changing concrete effects.
  2. Or / And argue that such typeclass hierarchy change is way less frequent.

I may buy both. Still, migrations are a PITA, that is just a reality. However, yeah something that either simplifies or minimizes them is a worthwhile endeavor.


PS: Sorry for the delay folks, busy weekend.

1 Like

It’s true that CE2 → CE3 was a big migration, but that’s because of major conceptual changes – the whole hierarchy got rearranged, and I suspect that won’t happen again.

The main benefit to the F[_] approach is, as so often, decoupling: you can in many places state more-precise requirements, which limits the blast radius a little when something changes in the concrete type. (And sometimes allowing shims so that the call sites don’t have to change at all.) That’s not nothing, but I’ll grant that it tends to be a minor consideration for applications.

NB: I’ve built CE applications both ways, as well as the sort of “mix-and-match” hybrid I described above. (One of my sub-teams went hardcore on 'F[_]` for their section of the code, while another sub-team stuck to ‘IO’ – worked fine in practice.) They all work, so much of this comes down to taste, testing style, and stuff like that.

1 Like

But how did you learn to do it this way…?

The Scala std lib uses functor/monad/… as an implicit design pattern - if you use Future, you’ll recognize the similarity to collection APIs, but that’s about it.

Cats reifies these concepts, makes them explicit and opens them up for polymorphism, reuse and custom implementations. Learning this, you will necessarily encounter F[_] - the very definition of Functor has it to start with… This is the stage that I referred to when I said I found the polymorphism promise enticing (and the learning curve steep). Note that I’m not even talking IO at this point.

Now cats-effect builds on Cats, and http4s is a cats-effect based library - its documentation may very well assume some familiarity with the lower levels of this stack, and I don’t think it’s totally unreasonable to expect the reader to recognize and understand F[_] usage at this point. Expecting the generic http4s API documentation to cater to “newcomers on day one” simply doesn’t feel fair to me.

Having stumbled back on the old post from Paul Graham via the links that @philipschwarz has just posted on the effects topic thing, I find myself feeling conflicted - go the high road for maximum ability and damn the barriers to entry, or cater for refugees from Java who just want to get something done.

Not going to come off the fence here - I do like the odd suborbital hop into the higher altitudes of Scala, but generally cruising in the stratosphere suits me mine; I get to avoid the Java thunderheads in the trophosphere either way.

My litmus test - walk away from your codebase for several months and then resume work on it. You’ll know if you made the right call then. :laughing:

2 Likes

I mean maybe, maybe what we really lack of is a good tutorial? that can explain the whole cause and the approach like what author of Dhall did for MTL.

Also I agree with sangamon that from what cats, cats-effect and IO to the TF style doesn’t seem to be a big leap, you already know everything you need to know, it’s mostly just matter of style. it could be argued if it is suitable for every cases, just like people arguing if side effect(namely IOs) should be marked explicitly for every cases. but certainly wouldn’t be a bad idea if it’s an option.

Also also, there’s a legitimate usage of TF style, library tofu’s Middleware could benefit from that to achieve kind AOP experience.

You are assuming one has to start with Functor, or even with cats in general.

My argument is rather the opposite.
While I personally did the long route of starting with Scala as a better Java, then adding immutability and higher order functions, then ADTs, then typeclasses, then cats, then cats-effect, then http4s, and finally TF (and in the middle, I learned about kinds, variance, CT, etc). This whole process took me around 2-3 years. I have to note that I was doing this on the side, as a hobby, so of course I am not arguing everyone would take that much time. I do recognize that there is simply a lot to learn.
I would say that folks can do a simpler and more short-term beneficial route:

  1. Learn Scala syntax.
  2. Get used to immutability by default and use the stdlib higher-order functions.
  3. ADTs + Type tetris
  4. “Programs as Values”; i.e. cats.effect.IO + fs2.Stream ← You can already write prod code at this point after at most 3-4 weeks of training.
  5. smithy4s / http4s / tapir or whatever your project uses to define REST endpoints.
  6. Learn cats as a utils library that provides mostly.
  7. Typeclasses, kinds, variance.
  8. CT for programmers ← Optional if you are interested.
  9. Monad hierarchy and Tagless final.

Yes, at least I’d think that’s the default expectation. http4s is not only based on Cats, CE and fs2, but it deliberately leaks these dependencies - you need to be(come) familiar with Kleisli (F[_]!), IO, etc.,… So either you are coming from a Cats/CE background already, looking for an HTTP lib, or you should be willing to learn the stack - at least in parts as a prerequisite to learning http4s.

That’s a possible approach, but I’d think it’s a rather unusual one, so I wouldn’t expect the official http4s docs to support this progression. Of course you can campaign for making this the standard approach, you just shouldn’t get grumpy while you’re not there, yet. :slightly_smiling_face:

Stream is polymorphic in the effect type, you’ll encounter F[_] here, as well.

I have a sense that you two are talking past each other, and it’s worth separating the two concepts in play here. I have a suspicion that we’re all at least somewhat in agreement.

On the one hand, there is the question we started with, “Should newer folks program in F[_]?”. I think there’s a fair consensus that the answer is “no”; even in the TL community, I don’t think most people start there. And there is at least general comfort with people building applications in raw IO, although tastes vary about what people prefer.

OTOH, there’s the question of, “Should you be able to recognize F[_]?”, which I think is what @sangamon is talking about. IMO the answer there is necessarily yes if you’re going to work with the TL libraries. You don’t need to grok it in depth, or even know what it means aside from “When you see F[_], just mentally substitute IO and keep going”. But it’s all over the signatures in those libraries, so you need to know how to read it at least basically in order to use the documentation.

2 Likes

Well, that is the approach we have been using at my company for a while now. It is also the approach some / many (not sure TBH) typelevel folks are recommending lately.
It has proven to be very useful in easing the ramp-up process for newcomers.

Plus, this is basically what ZIO did on top of their better marketing, and it has shown to be very successful.

Well for the record there is now: GitHub - http4s/http4s-io.g8: An alternate giter8 template fixed on IO
Also, the discussion about migrating the docs of cats-effect, fs2, and http4s to prefer concrete IO over TF is always there. It is just too much work and “not a priority”.

Another reason why I now prefer to use smithy4s over plain http4s :slight_smile:

While totally fair and I in general agree with you.
I am just arguing that IO and the “Programs as Values” paradigm are the “prerequisites”. CT, TF, variance, kinds, etc, are not.

Sure, but thankfully Justin already explained what I wanted to say better.
It is pretty easy to tell folks to just use IO when they see F[_].
One thing is to write:

def getFoos(client: Client[IO]): Stream[IO, Foo]

And another is to write:

def getFoos[F[_] : Concurrent](client: Client[F]): Stream[F, Foo]

While the F[_] and IO look the same, doing proper TF needs more than just replacing IO with F[_]. It requires knowing the typeclasses and which ones should be used, it requires knowing the syntax, the common variance pitfalls, the extensions imports, debugging implicit resolution, etc.

BTW, even when doing TF, folks in the Discord server would now recommend sticking to just defaulting to Concurrent for logic and to Sync / Async for FFIs rather than looking for the most specific typeclass for every piece of code.


So yeah, I am only arguing that you don’t need to write code in TF style, nor understand all the concepts behind it to be productive.
Not that there is any way to avoid seeing the F[_] somewhere.

Now, depth inside me I would prefer if there was just one IO data type, and all libraries would use that directly
But, I very much know that was impossible given the way the ecosystem evolved, and we would be on a very different landscape if that had been the case. I also know that fewer folks would agree with me on this one; if anyone at all would do.