raquo / Airstream

State propagation and event streams with mandatory ownership and no glitches
MIT License
248 stars 28 forks source link

splitByType / splitByEnum #103

Open raquo opened 2 years ago

raquo commented 2 years ago

What if you have a Signal[MyEnum] or Signal[MyGADT], and you want to split the signal by type, much like the normal split operator splits the signal by value?

Waypoint has a useful SplitRender feature that works like this:

val splitter = SplitRender[Page, HtmlElement](router.currentPageSignal)
  .collectSignal[UserPage] { userPageSignal => renderUserPage(userPageSignal) }
  .collectStatic(LoginPage) { div("Login page") }

// this method requires a Signal of a specific page type, and SplitRender is able to provide it
def renderUserPage(userPageSignal: Signal[UserPage]): Div = {
  div(
    "User page ",
    child.text <-- userPageSignal.map(user => user.userId)
  )
}

val app: Div = div(
  h1("Routing App"),
  child <-- splitter.signal // get the signal.
)

I could potentially move this functionality into Airstream, but it's arguably... not good enough. The biggest downside is the lack of exhaustiveness matching, that is, you can call .signal any time to get the signal, even if you didn't handle all the cases. I also dislike the need to call .signal at all, perhaps I will hide it with an operator signature like def splitByType(f: SplitRender => SplitRender): Signal[Out] = f(SplitRender(this)).signal (various type params omitted for brevity).

@felher suggested using Scala 3 metaprogramming for this, and although my design goal is to keep Laminar & Airstream simple, I think exhaustive matching on subtypes is actually a good use for this, that is, the metaprogramming implementation will probably be the simplest most straightforward one.

I will not be using any third party macro libraries though, plain Scala only, which means Scala 3 only, for the sake of reducing dev and maintenance effort.

For reference, here is @felher's implementation of splitByEnum: https://gist.github.com/felher/5515eb1124268b0e10eadc78778f49a8 – it's short, and even though I have zero experience with macros, I can still understand what it does at a high level. I even dare say this might be good to include in Airstream as-is.

If we're doing this, we should probably also support a more complicated splitByType use case, which, similar to Waypoint's SplitRender, would let the user split type hierarchies on arbitrary subtypes, while providing exhaustive matching. I don't know how hard it is to implement with Scala 3 metaprogramming facilities, I need to find the time to learn this whole thing.

Any help / advice is welcome in this direction.

HollandDM commented 9 months ago

I do have some ideals about this, and would like to help. SplitByType would be very welcomed, as my app usually required something similar to that.

raquo commented 9 months ago

@HollandDM I'll be happy to hear your ideas!

HollandDM commented 9 months ago

I'm currently using something like this https://gist.github.com/HollandDM/446bb41a8c89607b140d3bf3297b2a92. This macro's main feature is to turn a bunch of code from this:

sealed trait Foo

final case class Bar(strOpt: Option[String]) extends Foo
enum Baz extends Foo {
  case Baz1, Baz2
}
case object Tar extends Foo

val splitter = fooSignal.splitMatch
  .handleCase { case Bar(Some(str)) => str } { (str, strSignal) => renderStrNode(str, strSignal) }
  .handleCase { case baz: Baz => baz } { (baz, bazSignal) => renderBazNode(baz, bazSignal) }
  .handleCase {
     case Tar => ()
     case _: Int => ()
  } { (_, _) => div("Taz") }
  .toSignal

Into this:

val splitter = fooSignal.
  .map { i =>
     i match {
        case Bar(Some(str)) => (0, str)
        case baz: Baz => (1, baz)
        case Tar => (2, ())
        case _: Int => (2, ())
     }
  }
  .splitOne(_._1) { ... }

(After macros expansion, compiler will warns this code "match may not be exhaustive" and "unreachable case" as expected).

As far as I know, "exhaustive matching" cannot be trigger by any mean beside defining a match case, as this is a compiler feature, not a scala's macros/runtime feature. So macros will need to do something similar to the code above. It's will be too much for us create exhaustive matching manually.

FYI: I think this is the entry for exhaustive match checking in scala 3 https://github.com/lampepfl/dotty/blob/main/compiler/src/dotty/tools/dotc/transform/patmat/Space.scala#L825.

I can help with a PR if you want to go with this, but this is scala 3's macro only, and I don't have any experience with scala 2's macro, unfortunately.

raquo commented 9 months ago

@HollandDM Thanks, this is awesome, it's pretty much what I was hoping for!

Don't worry about Scala 2, I wasn't planning to support it for the same reasons you mention. I'm ok with all macro-enabled features being Scala 3 only (not like there's a lot, it's just this).

I'll be happy with any PR / help, but I won't be able to include this in 17.x, it will go in a later version. I will only be able to give this an in-depth look in a few months, so just keep that timeline expectation in mind.

HollandDM commented 9 months ago

@raquo Don't worry, I can still use my internal implementation in the mean time. Take your time!

felher commented 9 months ago

I like this a lot. The implementation is quite complex compared to my initial one above, but the payoff is so much greater.

For example, this should work well with splitting a union of string literal types. Javascript has a ton of those. In fact, a common pattern for discriminated unions in typescript works exactly this way, as seen in the following link from the official typescript Documentation: https://www.typescriptlang.org/docs/handbook/2/narrowing.html#discriminated-unions