Skip to content

Instantly share code, notes, and snippets.

@s5bug
Created June 5, 2020 19:04
Show Gist options
  • Save s5bug/981b5595b4f3b65c43613243855a7b45 to your computer and use it in GitHub Desktop.
Save s5bug/981b5595b4f3b65c43613243855a7b45 to your computer and use it in GitHub Desktop.
ScalablyTyped Effect Wrappers Draft

Say we want different flavors of effect wrappings:

  • Cats, over F[_]
  • Cats, over IO
  • ZIO
  • Monix

We introduce an enum for these:

sealed trait EffectWrappingFlavor
object EffectWrappingFlavor {
  case object ZIO extends EffectWrappingFlavor
  case object CatsF extends EffectWrappingFlavor
  case object CatsIO extends EffectWrappingFlavor
  case object Monix extends EffectWrappingFlavor
}

We also want to be able to describe how to wrap functions:

sealed trait EffectWrapping
object EffectWrapping {

  case object Pure extends EffectWrapping
  case object Synchronous extends EffectWrapping
  case class Asynchronous(
    successCallback: JSIdentifier,
    failureCallback: Option[JSIdentifier]
  ) extends EffectWrapping
  case class Stream(
    addCallback: JSIdentifier,
    removeCallback: Option[JSIdentifier]
  ) extends EffectWrapping

}

We introduce three new keys:

val stEffectWrappingFlavor =
  settingKey[Option[EffectWrappingFlavor]](
    "What flavor, if any, to generate effect wrappings in"
  )

val stEffectWrappingMap =
  settingKey[Map[JSIdentifier, EffectWrapping]](
    "A map from JavaScript methods to how they should be wrapped"
  )

val stEffectWrappingStrict =
  settingKey[Boolean](
    "If true, default wrappings will not be generated"
  )

Now, say we:

Compile / npmDependencies ++= Seq(
  "pixi.js" -> "5.2.4"
),
stEffectWrappingFlavor :=
  Some(EffectWrappingFlavor.CatsF),
stEffectWrappingStrict := true,

Initially, no methods will be output by the effect wrapper, as we have turned on strict mode. Let's add a mapping:

stEffectWrappingMap +=
  "PIXI.Loader#onComplete" -> EffectWrapping.Stream(
    "add",
    "remove
  )

This will generate a two classes, like so:

package typings.pixiJs.effect.PIXI

import cats.effect.{ConcurrentEffect, ContextShift, IO}
import cats.implicits._
import fs2._
import fs2.concurrent.Queue
import scala.scalajs.js

class Loader(val internal: typings.pixiJs.PIXI.Loader) extends AnyVal {
  def onComplete[F[_]](
    implicit cce: ConcurrentEffect[F],
    cs: ContextShift[F]
  ): Stream[F, (Loader, Map[String, LoaderResource])] =
    for {
      q <- Stream.eval(Queue.unbounded[F, (Loader, Map[String, LoaderResource])])
      callback: js.Function2[
        typings.pixiJs.PIXI.Loader,
        typings.std.Partial[typings.std.Record[String, typings.pixiJs.PIXI.LoaderResource]],
        Unit
      ] = (iloader, record) => {
        val recordAsDict = record.asInstanceOf[js.Dictionary[typings.pixiJs.PIXI.LoaderResource]]
        val dictAsMap = js.Object.entries(recordAsDict).map {
          case js.Tuple2(name, value) => (name, new LoaderResource(value))
        }.toMap
        ce.runAsync(
          q.enqueue1((new Loader(iloader), dictAsMap))
        )(_ => IO.unit).unsafeRunSync()
      }
      _ <- Stream.bracket(
        cce.delay(internal.onComplete.add(callback))
      )(binding => cce.delay(internal.onComplete.detach(binding)))
      item <- q.dequeue
    } yield item
}

class LoaderResource(val internal: typings.pixiJs.PIXI.LoaderResource) extends AnyVal

This LoaderResource will be filled with methods once mappings are added in stEffectWrappingMap.

If, instead of CatsF, we used:

  • CatsIO, we would use IO.runAsync instead of cce.runAsync, and IO.apply instead of cce.delay
  • Monix, we would create an Observable; I haven't been able to find a good resource on creating an Observable from a repeated callback
  • ZIO, we would create a ZStream; same problem as above

If we turned strict mode off, the methods above would be automatically generated like this:

type OnCompleteSignal = (Loader, Map[String, LoaderResource]) => Unit
class Loader(val internal: typings.pixiJs.PIXI.Loader) extends AnyVal {
  def onComplete[F[_]](implicit s: Sync[F]): F[Signal[OnCompleteSignal]] =
    s.delay(internal.onComplete).map(new Signal[OnCompleteSignal](_))
}

// PIXI doesn't actually have any bindings for Signal, which is odd
// https://englercj.github.io/type-signals/classes/signal.html
class Signal[T](val internal: ???) extends AnyVal {
  def add[F[_]](callback: T)(implicit s: Sync[F]): F[SignalBinding] =
    s.delay(internal.add(callback)).map(new SignalBinding(_))
  def detach[F[_]](node: SignalBinding)(implicit s: Sync[F]): F[Unit] =
    s.delay(internal.detach(node.internal)).as(())
}

Which isn't too usable, at least in a pure environment, because callbacks are not A => F[Unit], and there's no perfect way to tell automatically whether a Higher Order Function wants the internal function in question to be pure or not.

Note that, when strict mode is off, all unbound methods are just wrapped in Sync as if they're run-of-the-mill impure functions; I did not write the generations for all methods that would be generated in practice.

Issues this method does not address:

  1. On an asynchronous callback, an error status may be returned in the same callback as the success callback.
  2. On an asynchronous failure callback, an error may not extend Throwable.
  3. JSIdentifier is not defined here; someone with experience in the ScalablyTyped internals would probably be able to write it better.
  4. Conversion from typings.std.Partial[typings.std.Record[String, typings.pixiJs.PIXI.LoaderResource]] is something that can safely be automatically generated, but I don't know if it's efficient/worth it/the right option.
  5. TypeScript types that cannot be safely represented in the Scala world.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment