Scala.js, the good the bad and the ugly

case study:

Gigme.in

case study - Gigme.in

Lessons learned from using Scala.js in real world applications, advantages and pitfalls, and a practical use case for abstracting over the effect type

A few years ago, I started working on Gigme.in, a small web application designed to discover concerts happening around you.
An interesting aspect of Gigme.in, is that it’s entirely written in Scala, including its frontend which was built on top of React and Scala.js. Since then, I’ve adopted the same architecture in other projects, such as Controbuio, a card game platform that you might have already heard about.

Today, I am going to share some of the joy and the pain of dealing with Scala.js.

The good

Let’s start with something evident: Scala.js implies type-safe frontend.
Sure, you could achieve that by adopting TypeScript, but in reality the Scala type system is simply superior, by far, not to mention the Scala ecosystem and purely functional streaming libraries such as fs2, to name one.

Code sharing (aka cross building) is, in my opinion, the biggest advantage when adopting Scala.js.
The ability to share code between the frontend and the backend means less code duplication, less boilerplate, and in general less bugs.

But what exactly can or should be shared?
In theory, you can cross build anything you want, as long as the libraries you use are Scala.js capable.
What is worth sharing really depends on the nature of the application.

In the context of a traditional web app, where frontend and backend communicate via an API, then it’s useful to share at least the following 3 things:

1. Share the data

Anything that can be produced and consumed by the API should be shared.
In the case of Gigme.in, this includes data transfer objects such as Concert, Artist, Venue and User.
Another thing that I found worth sharing is the error model (e.g. the ServiceError ADT).

2. Share the codecs

The entities mentioned above will be travelling over HTTP and will have to be encoded in some way (almost certainly in JSON).
It is therefore very handy to share the codecs.

3. Share the contracts

The third thing that it’s really worth sharing are services interfaces:

trait Service {
  type ServiceResponse[A] = Either[ServiceError, A]
}

trait ConcertService extends Service {
  def findOne(authToken: AuthToken, id: UUID): ServiceResponse[Option[Concert]]
  def findMany(authToken: AuthToken, searchCriteria: SearchCriteria): ServiceResponse[Page[Concert]]
  def create(authToken: AuthToken, eventData: ConcertData): ServiceResponse[Concert]
}

trait AuthService extends Service {
  def authenticate(authToken: AuthToken): ServiceResponse[User]
}

The reason why this is so important, is because it will ensure that both producer and consumer of those services conform to the same contract.
This might not remove the need for the so called consumer-driven-contract tests, but it will help by bringing some sort of type safety between two fundamentally loosely coupled parts.

Warning: the code above doesn’t take side effects into consideration, if you’re interested in this kind of things (and you should), jump to modelling effects

4. Share the business logic

The three points above are sort of a no-brainer, but you might want to take this a step further and cross build much more than that.
Let’s suppose you’re working on a gaming platform where users can play both online (against other players connected to the remote server) and offline (on the browser, for instance, against an AI).
In this scenario, if you don’t share any code, you will basically end up implementing the same application twice, which is clearly not ideal, as it might take you weeks or even months, not to mention the costs of maintaining and evolving two relatively complex codebases.
This is a classic example where Scala.js could be an important tool, and you should seriously consider it.

Here be dragons!

I hopefully convinced you that code sharing is useful, but there are things to consider.
The domain will inevitably evolve, and extra care will be required to ensure that your application is working at all times.
This is true in all cases, even when no code is shared at all, but it’s also true that the more you share the less flexibility you’ll have when evolving the software.

The bad

Using Scala.js comes with a price, of course.
An obvious consideration is that experienced Scala developers are expensive and probably not that skilled when it comes to UI development.
Besides, even if you’re the only one working on your toy project, Scala.js might still be the wrong choice.

1. Slow feedback

Every tiny change you make to the UI will take ages to appear on the browser, as you will have to re-compile.
If you are in a “cowboy mode” (building a prototype or simply playing around), and you need a quick feedback about what you’re doing, you will simply get frustrated and start questioning why on earth you’re doing this.

2. Writing facades for third party libraries

The beauty (and the danger) of npm, is that you find libraries for almost everything you need.
Technically, you can make use of any third party library from Scala.js, you just need to add them to the npmDependencies list in your build.sbt, and write a Scala.js facade to use them.
This process isn’t particularly complicate, but it’s tedious and time-consuming.

The ugly

1. Niche technology

Even after its 1.0 release, Scala.js still feels somehow like an eternal experimental project.
Its adoption in the industry is limited (and probably for good reasons).

In a nutshell, you will be embracing a niche technology, which usually means poor documentation, a tiny community and not a lot of mature tools available.

Nevertheless, you might be surprised by how many libraries support Scala.js (most of the typelevel ecosystem do, for instance).
However, when things don’t work as you would expect, or if you’re heavily relying on a scarcely used one-man library, be prepared for the worst.

2. Bootstrapping

It will take some time to setup your build.sbt in a way that works as there’s boilerplate, plugins and black magic involved.
Although this has been vastly improved in recent versions of Scala.js, you will have to spare a little patience when setting up your project.

3. Testing

Testing is simple in a way. You need to have node installed on your machine, but you can write tests in the same way as you normally would on the backend side (using ScalaTest for example).
This is true, until you want to test things like React components, where things can get much more complicated.
Unfortunately, you will not find an awful lot of examples out there about how to do this.

The compromise

Scala.js could be a great tool when there is a lot of business logic that can be nicely unit tested and embedded in the UI, especially when that logic needs to be shared with the backend.

On the other hand, Scala.js could be tedious and painful to embrace, particularly when it’s about pure UI, that it’s tricky to test, involves a lot of third party libraries, and requires a quick visual feedback.

There is, as always, a compromise that could be made.
When we talk about interoperability, we mostly refer to the possibility of using native JavaScript libraries in Scala. However, it is also possible to use a library built in Scala.js from a native JavaScript application.
This might sound a little crazy, but it could actually make a lot of sense in some cases, for instance, when your business logic is already in Scala, but you’d rather have your UI implemented independently, hopefully maintained by frontend specialists!

Appendix: modelling effects

In a previous paragraph, I touched on the benefits of sharing services interfaces.
However, the interface that I presented was missing an important point.
Methods such as findOne, findMany and create are likely to be doing all sort of nasty things, such as sending HTTP requests, querying databases, logging information, caching results and so on.

As we are in the context of functional programming, if we want to be serious about it, we will need a way of modelling this behaviour, the so-called effect.

We have an important decision to make here: should we commit to a concrete implementation of an effect such as cats IO or ZIO, or should we be agnostic about it?

This isn’t usually a trivial question to answer, especially after ZIO came out claiming the death of finally-tagless.
However, in this case, it turns out we are going to use two different effect types at the same time.
For instance, we might choose cats IO for the backend and the Callback data type for the frontend.

This detail completely justifies the appearance of the magic F[_]:

trait Service[F[_]] {
  type ServiceResponse[A] = F[Either[ServiceError, A]]
}

trait ConcertService[F[_]] extends Service[F] {
  def findOne(authToken: AuthToken, id: UUID): ServiceResponse[Option[Concert]]
  def findMany(authToken: AuthToken, searchCriteria: SearchCriteria): ServiceResponse[Page[Concert]]
  def create(authToken: AuthToken, eventData: ConcertData): ServiceResponse[Concert]
}

trait AuthService[F[_]] extends Service[F] {
  def authenticate(authToken: AuthToken): ServiceResponse[User]
}

The server side

Here’s a plausible implementation of the ConcertService in a finally-tagless fashion:

class ConcertRepo[F[_]] {
  def findById(id: UUID): F[Option[Concert]] = ???
}

class ConcertServiceProvider[F[_]: AuthService: ConcertRepo](
  implicit 
  me: MonadError[F, ServiceError]
) extends ConcertService[F] {
  override def findOne(authToken: AuthToken, id: UUID): ServiceResponse[Option[Concert]] = {
    (for {
      user    <- EitherT(AuthService[F].authenticate(authToken))
      concert <- EitherT(ConcertRepo[F].findById(id).attempt)
    } yield concert.filter(c => c.published || c.authorId == user.id)).value
  }
}

The client side

In JavaScript-land, where there’s only one thread available, asynchronous calls are often modelled as callbacks.
Instead of blocking for the results, you could pass a function that will be called whenever the result becomes available (the callback function).

If we combine this concept with the idea of an IO that will encapsulate side effects (called Callback in this case), we end up with the following type signature:

type ServiceCallback[A] = (Either[ServiceError, A] => Callback) => Callback

Here’s a plausible implementation for the ConcertService client:

object ConcertServiceConsumer extends ConcertService[ServiceCallback] {
  override def findOne(authToken: AuthToken, id: UUID): ServiceResponse[Option[Concert]] = {
    (handler: Either[ServiceError, Option[Concert]] => Callback) => {
      Ajax("GET", s"/concert/$id")
        .setRequestContentTypeJsonUtf8
        .setRequestHeader("Authorization", s"Bearer $authToken")
        .onComplete(response => response.status match {
          case success if success >= 200 && success < 300 => 
            parse[Concert](response)(concert => callback(Right(Some(concert))))
          case 404 => 
            callback(Right(None))
          case code =>
            parse[ServiceError](response)(error => callback(Left(error)))
        })
        .asCallback
    }
  }
  
  private def parse[A: Decoder](response: XMLHttpRequest)(handler: A => Callback): Callback =
    decode[A](response.responseText) match {
      case Left(error) => Callback.throwException(error)  // cannot parse the response
      case Right(a) => callback(a)
    }
}

And here’s an example of how to use the client:

val sideEffect: Callback = ConcertServiceConsumer.findOne(authToken, id) {
  case Right(Some(concert)) => Callback(println("Found :)"))
  case Right(None) => Callback(println("Not found :S"))
  case Left(error) => Callback(println("That did not work :'("))
}

Exactly like an IO, the code above is referential transparent, nothing will happen until it’s run.