Add isJS, isJVM, and isNative inline methods to scala.util.Properties

scala.util.Properties already has methods isWin and isMac, but no way to tell if Scala is running on the JVM, JS, or Native platforms.

This information is pretty important in applications like Scala 3’s Typeclass Derivation.

For example, imagine you have a Scala 3 crossproject with a /jvm, /js, and /shared folder.

In /shared, you define all your case class DTOs.

For JSON serialization, you pull in a new library and add derives JsonCodec to your classes.

It’s critical for this typeclass to know the environment. If JS, then it might use the global JSON object, and likewise on the JVM use some standard library method not-implemented by Scala.js.

The proposal is to add methods Properties.isJS, Properties.isJVM, and Properties.isNative, or something of similar effect. Since the methods will be inline, JsonCodec and other libraries can do inline ifs on the values and return a correct implementation.

The alternative that we’re living with is to define two different typeclasses, JsonCodecJVM and JsonCodecJS, and manually derive givens in objects in their respective projects, like

// in /shared project
case class User(name: String, isAdmin: Boolean, age: Int)

// in /js project
object DtoConverters:
  given JsonCodecJVM[User] = JsonCodecJVM.derived

// in /jvm project
object DtoConverters:
  given JsonCodecJS[User] = JsonCodecJS.derived

I vaguely remember @japgolly had some idea like this before. I wonder about the build-system implications for this, and also if there are other alternatives.

8 Likes

After some experimentation, it turns out that these methods are not required after all!

It’s really as simple as this:

  1. Convert the SBT project to a sbt crossproject that targets JVM, and JS (even if it doesn’t support one of these)
  2. Configure it with CrossType.Full and reload the sbt project; /js, and /jvm directories are added. An example build.sbt looks like:
lazy val root = crossProject(JVMPlatform, JSPlatform)
  .crossType(CrossType.Full)
  .in(file("."))
  .platformsEnablePlugins(JSPlatform)(ScalaJSJUnitPlugin)
  .settings(
    organization := "com.example",
    name := "json-codec",

    scalaVersion := "3.0.2"
)
  .jsSettings(
    // js dependencies
  )
  .jvmSettings(
    libraryDependencies ++= Seq(
      // other jvm dependencies
      "com.novocode" % "junit-interface" % "0.11" % "test"
    )
  )

Then in the /jvm project, implement your type class:

trait JsonCodec[A]:
  def fromJson(s: String): A
  def toJson(a: A): String

object JsonCodec:
  inline given derived[A](using m: Mirror.Of[A]): JsonCodec[A] = ??? // todo

And do the same in /js, with the same package, classname, and method signatures. The implementation itself can use any JS utility, like the global JSON object.

This way, you can use platform specific libraries, while users can still write

case class MyDto(name: String) derives JsonCodec

in their /shared cross-project directory.

To give users the above convenience even if your library does not support a given platform, do all the above steps, except in the unsupported platforms change any platform-dependent types to be Any. Then implement derived, but throw UnsupportedOperationException.

object JsonCodec:
  private val Msg = "Platform X not supported" 

  inline given derived[A](using m: Mirror.Of[A]): JsonCodec[A] with
    def fromJson(s: String): A = throw UnsupportedOperationException(Msg)
    def toJson(a: A): String = throw UnsupportedOperationException(Msg)
3 Likes