Skip to Content
Course content

205: Full-Stack Type Safety with Scala.js and Tapir

Click on the "Edit" button in the top corner of the screen to edit your slide content.

One of the most frustrating parts of full-stack development is the "contract gap." You change a field name in your backend API, forget to update the frontend, and suddenly you're staring at a 400 Bad Request or a null pointer in the browser. If you're using Scala on both ends, there's no reason to tolerate this. We can share the API definition itself.

Today, we're building a simple "Movie Watchlist" tracker. Instead of writing a JSON spec by hand and hoping the frontend matches, we'll use Tapir to define the endpoint once and use it to drive both the server implementation and the Scala.js client.

Defining the Shared Contract

First, we need a shared module. This is a plain Scala project that both the backend and the Scala.js frontend depend on. I'm putting the data models and the endpoint definitions here so that any change here triggers a compilation error in both the server and the client.

case class Movie(id: Int, title: String, watched: Boolean)

object MovieApi {
  import sttp.tapir._

  val getMoviesEndpoint = endpoint.get
    .in("movies")
    .out(jsonBody[List[Movie]])
    .description("Get the current movie watchlist")
}

Notice that MovieApi doesn't contain any logic. It's just a description. It says: "There is a GET request at /movies that returns a JSON list of Movies." This is the "source of truth."

Bringing the API to Life on the Server

On the backend—I'll use ZIO Http for this example, but the pattern is the same for Http4s or Akka—we just need to "interpret" that definition. I don't have to worry about routing paths or manually parsing JSON; Tapir handles that based on the contract we just wrote.

import sttp.tapir.server.ziohttp._
import zio._

val movieServer = ZIO.succeed {
  val movies = List(Movie(1, "Inception", true), Movie(2, "Tenet", false))
  
  TapirServerInterpreter().toZioHttp(
    MovieApi.getMoviesEndpoint.serverLogic(_) {
      ZIO.succeed(movies)
    }
  )
}

It feels a bit magical, but it's actually quite lean. The server logic is decoupled from the transport layer. If I decide to move from JSON to Protobuf tomorrow, I change one line in the shared module, and the compiler tells me exactly what needs to change in the server.

Connecting the Frontend via Scala.js

Now for the best part. In the Scala.js project, we don't use strings for URLs. We use the MovieApi.getMoviesEndpoint definition to create a type-safe client using sttp.

import sttp.client3._
import sttp.tapir.client.sttp._

val backend = HttpClientScalaJsBackend()
val movieClient = MovieApi.getMoviesEndpoint.client

val request = movieClient.toSttpRequest()
backend.send(request).await { response =>
  response.body match {
    case Right(movies) => println(s"Found ${movies.size} movies!")
    case Left(error)   => println(s"Oops: $error")
  }
}

If I try to treat the response as a List[User] instead of List[Movie], the code won't even compile. The type safety extends from the database query all the way to the browser's memory.

Fixing the Serialization Mismatch

I'll be honest: when I first wrote this example, I ran into a runtime error. The browser console was screaming about "Unexpected token" while trying to parse the JSON. I looked at my code and everything seemed correct. Then I realized my mistake: I had defined the Movie case class in the shared module, but I forgot to provide a codec (like Circe or uPickle) that both the server and the client agreed upon.

Tapir tells you what the endpoint looks like, but it doesn't magically know how to turn a case class into JSON. I had the jsonBody[List[Movie]] call, but no implicit codec in scope. I fixed this by adding the Circe generic derivation to the shared module:

import io.circe.generic.auto._
import sttp.tapir.circe._
// Now the implicit codecs are available to both server and client

It's a common trip-up. Remember: the endpoint definition is the map, but the codecs are the vehicle that actually moves the data.




📋 Practical Task

Implement a Type-Safe "Add Movie" Endpoint

Currently, our Movie Watchlist is read-only. Your task is to extend the full-stack type safety to allow adding new movies to the list.

  • In the shared module: Create a new Tapir endpoint called addMovieEndpoint. It should be a POST request to /movies, accept a Movie object in the request body, and return a Unit (or a 201 Created status).
  • In the server: Implement the logic for this endpoint. For now, just print the movie to the console or add it to a mutable List in memory.
  • In the Scala.js client: Create a function that takes a Movie object and uses the addMovieEndpoint.client to send that movie to the server.

Ensure that the project compiles. If you change the Movie case class to add a genre: String field, verify that both the server and the client fail to compile until the field is handled in both places.

Rating
0 0

There are no comments for now.

to be the first to leave a comment.