cats icon indicating copy to clipboard operation
cats copied to clipboard

Add `unit[F]` syntax to `cats.syntax.applicative`

Open FunFunFine opened this issue 2 months ago • 6 comments

I am very surprised it's not been proposed before, but searching for something similar in pull request resulted in nothing.

The motivation is that ().pure[F] is used quite often and it's a lot more typing than a simple unit[F] (three symbols more! that's a lot when there are hundreds of them in a project.) There's IO.unit, Resource.unit, it's even similar to none[A], so this one tries to replicate it, but without a companion object to call this def from.

FunFunFine avatar Oct 28 '25 17:10 FunFunFine

Thank you for the PR!

But to be honest, I'm not sure it would be a good idea to start bringing unit to the global scope with any Cats syntax import. It can be quite confusing in some cases. Consider this:

import cats.syntax.all.*

def foo[A](o: Option[A]) = ...

foo(none) // very clear
foo(unit) // ???

The important difference between none and unit here is that none is the same as None, just with better type inference. Whereas, unit is not the same as Unit – it is a Unit value lifted to some context, which can be omitted in certain cases.

Besides, there are ways already to achieve pretty much the same conciseness:

def bar[F[_]](implicit F: Applicative[F]) = {
  ...
  F.unit // arguably even better than `unit[F]`
}

Here F.unit looks even conciser than unit[F] and closer corresponds to the IO.unit and Resource.unit syntax.

Moreover, on Scala3 it can look even better:

def bar[F[_]: Applicative as F] =
  ...
  F.unit

satorg avatar Oct 28 '25 21:10 satorg

foo(none) == foo(None), while foo(unit) == foo(Some(())) which is not the same thing at all and the difference is quite obvious, is it not?

as for the F.unit suggestion — at my work place I almost never see any implicit arguments like that, we still use scala 2 and heavily rely on context bounds (Tagless Final approach and all that), so it's not really a plausible way, as nobody's going to want to rewrite all class headers for that.

Yet people at my job still complain about using ().pure[F] and other options as they are quite cumbersome, so I wanted to give them a ready-out-of-the-box solution, no imports or anything

FunFunFine avatar Oct 28 '25 22:10 FunFunFine

it might be better to name it something like unitOf[F], which would indicate that's not just a Unit value

FunFunFine avatar Oct 28 '25 22:10 FunFunFine

Also, for what it's worth, I'd like to note that method F.unit itself has a very little value. It is used for two cases mostly: to conclude a chain of effects making it emiting no value, or to shut one of the branches in some conditional expressions. In both cases there are better combinators available in Cats:

one(a).two(b, c).three(d, e, f) *> F.unit
// VS
one(a).two(b, c).three(d, e, f).void
if (condition) sideFffectWithoutValue else F.unit
// VS
sideFffectWithoutValue.whenA(condition)

Those don't cover all possible cases, of course, but quite helpful in many of them.

... at my work place I almost never see any implicit arguments like that, we still use scala 2 and heavily rely on context bounds (Tagless Final approach and all that), so it's not really a plausible way, as nobody's going to want to rewrite all class headers for that.

It shouldn't be a big issue anyway:

class Foo[F[_]: Applicative](...) {
  private val F = Applicative[F] // <-- just add this when necessary

  def bar(...) = {
    ...
    F.unit
  }
}

Moreover, taking into account this experimental (for now) yet pretty expressive feature: https://docs.scala-lang.org/scala3/reference/experimental/typeclasses.html#better-default-names-for-context-bounds

it would be no exaggeration to say that matching context bound names with their types is becoming a de facto standard.

satorg avatar Oct 29 '25 05:10 satorg

it might be better to name it something like unitOf[F], which would indicate that's not just a Unit value

Cats usually uses upper case letters appended to function names: whenA, ifM, liftF, mapK, etc. In a case of the "unit" function it could be unitA, I guess.

That said, I personally not a big fan of one letter "modifiers" since they look cryptic. I don't believe short names should be the ultimate goal, especially if it sacrifices clarity.

unitOf would work, I guess, but it still allows obscure syntax to occur: foo(unitOf), since F can be omitted. I would personally prefer a longer yet clearer name like liftedUnit (or whatever), but this style is not somewhat common in Cats.

satorg avatar Oct 29 '25 05:10 satorg

Also, for what it's worth, I'd like to note that method F.unit itself has a very little value. I

Actually, we use it a bit differently:

  • in stubs/mocks/empty instances of TF-like traits
  • in pattern matching expressions

Here's a very simplified example for both cases:

trait SendingService[F[_]] {
  def send(something: Something): F[Unit]
}

def implementation[F[_]: Monad](actor: Actor[F], log: Log[F]) = new  SendingService[F[_]] {

   def send(something: Something): F[Unit] = 
     actor.ask(something).flatMap {
        case State(_) => ().pure[F] 
        case Response(r) => log.info(r)
        case Error(err) => new Error(err).raiseError[F, Unit]
     }
} 

def empty[F[_]: Applicative] = new  SendingService[F[_]] {
   def send(something: Something): F[Unit] = ().pure[F]
} 

We have traits with dozens of methods that we add def empty to.

It shouldn't be a big issue anyway:

it still requires some additional effort which is just not worth it in the end. Copy-pasting ().pure[F] works with the same amount of effort, however shouldn't the library be nice to use it for people?

FunFunFine avatar Oct 29 '25 10:10 FunFunFine