Olivier Nouguier
25 min read •
Share on:
In this article, we will show a full-stack web application with Scala and ZIO. This application will expose a REST API to register a person, and will display the registered user in a reactive UI. It will leverage the Scala ecosystem to build a full stack web app in a type safe manner, extensively using Scala derive
and extension
mechanism.
Building a full stack web application using the same language has been a never-ending quest.
In general, dynamically typed languages and their loosely compilation guarantees are hard to compete with because of:
On the opposite side, statically typed languages are perceived with somewhat good reason as pachyderm on a wire, where static code guarantees are eclipsed by:
In this article, I will demonstrate you how the Scala ecosystem changed the game for me.
Without compromising type safety guarantees of Scala and in the meantime being able to embed library from JavaScript/TypeScript ecosystem.
The approach is to build a full stack web app with the same language (Scala) on both the client and the server side.
This is a very powerful combination, that allows to build a full stack web app in a type safe manner. And in the meantime we can leverage the JavaScript ecosystem, without compromising type safety, architecture and scalability of the application.
Within a single repository, we will set up a multi-modules application using:
For the very impatient, I’ve cooked a g8 template 👇:
sbt new cheleb/zio-scalajs-laminar.g8 --name=zio-laminar-demo
This setup is a little complex because it handles:
If you are a VSCode / metals user, you might be lucky:
code zio-laminar-demo
Leveraging the tasks VSCode supports, and after a few downloads, your new full stack development environment should look like this:
4 processes are running here:
Server side:
Client side:
Visit the registration from at http://localhost:5173/ and register a person, you should see the registered user in the UI.
In this section, we’ll briefly cover the most important details about the project build.
Scala build here is driven by SBT in a rather classic way, except for the shared
project that will be cast in two different worlds .jvm
and .js
Generated build contains documentation on details. Please take a look and don’t hesitate to ask question/provide PR on the g8.
For ScalaJS
“org.portable-scala” % “sbt-scalajs-crossproject” % “1.3.2”
This is the plugin that will allow to spread project resources between JS and JVM worlds. Shared project configuration 👇
share.jvm
variantshared.js
variantScalaJS and ScalaJS bundler are smart enough to cross-compile and deploy module/shared/src/main/scala
sources in both target.
JS integration is also a classical one with NPM and Vite, here a again specific configuration is limited to vite.config.js
import { defineConfig } from "vite";
import scalaJSPlugin from "@scala-js/vite-plugin-scalajs";
export default defineConfig({
plugins: [
scalaJSPlugin({
// path to the directory containing the sbt build
// default: '.'
cwd: "../..", // (1)
// sbt project ID from within the sbt build to get fast/fullLinkJS from
// default: the root project of the sbt build
projectID: "client", // (2)
// URI prefix of imports that this plugin catches (without the trailing ':')
// default: 'scalajs' (so the plugin recognizes URIs starting with 'scalajs:')
uriPrefix: "scalajs",
}),
],
build: {
sourcemap: true,
},
// base: "/zio-laminar-demo",
});
Where most of the work is achieved by @scala-js/vite-plugin-scalajs
plugin, which needs to know:
During development, the sbt project must be bundled as ESModule. If you are using the g8 template, this is already the case.
In this setup:
This setting will allow Vite to hot reload changes on the fly, giving an incredible developer experience.
Thanks to the power of SBT and multi-module support, it easy to compile and package all together.
In VS code you can see the modules view:
<table>
<tr>
<td>
<div>
<img src="./images/layout.png" />
</div>
<div>
VS Code modules view.
</div>
</td>
<td>
<strong>Client Module</strong>, UI:
<ul>
<li>Laminar is the reactive UI</li>
<li>Sttp client leverages the same Tapir endoint definitions</li>
</ul>
<strong>Server</strong> Module, ZIO HTTP:
<ul>
<li>Tapir ServerEndpoint from shared endpoint definitions.</li>
<li>Swagger exposition gracefully provided by Tapir http://localhost:8080/docs/</li>
<li>Prometheus metrics http://localhost:8080/metrics/</li>
</ul>
<strong>Shared</strong> module:
<ul>
<li>model with Json support</li>
<li>API endpoint definitions</li>
</ul>
</td>
</tr>
</table>
Sometimes we need to generate some static files, like the index.html of the server module. This is done with SBT only when the server module is build.
Let’s see how ZIO, Laminar and Tapir can be combined to build a full stack web app.
We will now focus on the server side, but the same principles will apply to the client side.
We will see how ZIO will combine:
A library for asynchronous and concurrent programming in Scala. There’s a lot to say about it and Rock the JVM teaches this in detail in a dedicated ZIO course, but we’ll cover the essentials below.
ZIO is a data type that represents an effectful program that when evaluated:
R
to executeA
E
Sometimes an oversimplified mental model is to represent ZIO as:
type ZIO[R, E, A] = R => Either[E, A]
But ZIO is much more than that, it is an extensive library that provides a lot of utilities to handle effects, blocking, concurrency, retry, and so on. Very importantly, ZIO is a monad, and as such it provides a lot of combinators to handle effects, errors and dependencies.
A ZIO aka program, workflow, or effect:
At a glance, ZIO[–R,+E,+A]
uses variance annotations to allow a very convenient constructions like our Server side application:
object HttpServer extends ZIOAppDefault {
val runMigrations: ZIO[FlywayService, Throwable, Unit] = [...] // (1)
val server: ZIO[PersonService & Server, Nothing, Unit] = [...] // (2)
val program: ZIO[FlywayService & PersonService & Server, Throwable, Unit] = // (4)
for {
_ <- runMigrations // (3)
_ <- server
} yield ()
override def run =
program
.provide( // (5)
Server.default,
// Service layers
PersonServiceLive.layer,
FlywayServiceLive.configuredLayer,
// Repository layers
UserRepositoryLive.layer,
Repository.dataLayer
// Uncomment to get a mermaid diagram of the dependencies is compilation logs.
//, ZLayer.Debug.mermaid
)
}
runMigrations
is a ZIO[FlywayService,…] that depends on a FlywayService, and that will handle the database migrations.server
is a ZIO[PersonService & Server,…] that depends on a PersonService and a Server and that will handle the HTTP server.To be runnable, those dependencies need to be satisfied (5). This is what layers are made for. A layer is a ZIO concept that will provide a service, and that can depend on other services. Letțs see how ZIO, assembles the application, one layer after another.
Repository datalayer
is the first layer of the application, it abstracts the database access. In this project, a classic Repository object connects the database through a classical JDBC DataSource.
object Repository {
private def datasourceLayer: TaskLayer[DataSource] =
Quill.DataSource.fromPrefix("db") // (1)
def quillLayer: URLayer[DataSource, Postgres[SnakeCase.type]] =
Quill.Postgres.fromNamingStrategy(SnakeCase) // (2)
def dataLayer: TaskLayer[Postgres[SnakeCase.type]] =
datasourceLayer >>> quillLayer // (3)
}
This requires a (1) DataSource layer, that will provide a DataSource service from the configuration.
TaskLayer is a type alias for a ZLayer that does not require a dependency and can fail with a Throwable:
type TaskLayer[+A] = ZLayer[Any, Throwable, A]
The second requirement is (2) Quill layer, that will provide a Quill Postgres service, with a SnakeCase naming strategy for the table mapping, this layer depends on the DataSource service.
URLayer is a type alias for a ZLayer that does require a dependency and cannot fail:
type URLayer[-R, +A] = ZLayer[R, Nothing, A]
The third requirement is a (3) data layer, that will provide a Quill Postgres service from the DataSource service.
The
>>>
operator is a ZLayer combinator that will chain the layers. It is a vertical composition of layers, that will provide the service from the first layer to the second layer. The++
operator is a ZLayer combinator that will merge the layers, it is a horizontal composition of layers. This video on Rock the JVM’s YouTube channel explains layer composition.
ZIO contains a rich variety of type aliases that after a while will make the code very readable.
The User Repository is a classic repository that will handle the User entity.
trait UserRepository: // (1)
def create(user: User): Task[User]
def getById(id: Long): Task[Option[User]]
// (2)
class UserRepositoryLive private (quill: Quill.Postgres[SnakeCase]) extends UserRepository {
import quill.*
inline given SchemaMeta[User] = schemaMeta[User]("users") // (3)
inline given InsertMeta[User] = insertMeta[User](_.id)
inline given UpdateMeta[User] = updateMeta[User](_.id, _.creationDate)
override def create(user: User): Task[User] =
run(query[User].insertValue(lift(user)).returning(r => r)) // (4a)
override def getById(id: Long): Task[Option[User]] = // (4b)
run(query[User].filter(_.id == lift(Option(id)))).map(_.headOption)
}
object UserRepositoryLive: // (5) (6)
def layer: RLayer[Postgres[SnakeCase], UserRepositoryLive] = ZLayer.derive[UserRepositoryLive]
Note how
ZLayer.derive[UserRepositoryLive]
is used to generate the layer from the constructor of the class, and how this layer is then depends on thePostgres[SnakeCase]
.
Quill is a compile-time query language for Scala and SQL. It allows to write queries in Scala and compile them to SQL.
Quill compiles (4a) and (4b) to SQL like this:
INSERT INTO users (name,email,age,creation_date) VALUES (?, ?, ?, ?) RETURNING id, name, email, age, creation_date;
SELECT x4.id, x4.name, x4.email, x4.age, x4.creation_date AS creationDate FROM users x4 WHERE x4.id IS NULL AND ? IS NULL OR x4.id = ?;
In the same way, Flyway service is a layer that will handle the database migrations.
object FlywayServiceLive {
private val DATASOURCE_PATH = "db"
def live: RLayer[FlywayConfig, FlywayService] = ZLayer( // (1)
for {
config <- ZIO.service[FlywayConfig]
flyway <- ZIO.attempt(Flyway.configure()
.dataSource(config.url, config.user, config.password).load())
} yield new FlywayServiceLive(flyway)
)
val configuredLayer: TaskLayer[FlywayService] = Configs.makeConfigLayer[FlywayConfig](DATASOURCE_PATH)
>>> live // (2)
}
Person service is a layer that will handle the business logic of the application. It may for example validate the data before calling the repository.
In this example, the PersonService will check if the person is older than 18 before registering it, and will fail with a TooYoungException if not.
Task is a type alias for a ZIO that can fail with a Throwable:
type **Task**[A] = ZIO[Any, **Throwable**, A]
trait PersonService { // Service trait
def register(person: Person): Task[User]
}
// Dependency injection
class PersonServiceLive private (userRepository: UserRepository) extends PersonService {
def register(person: Person): Task[User] =
if person.age < 18 then ZIO.fail(TooYoungException(person.age))
else userRepository.create(
User(
id = None,
name = person.name,
email = person.email,
age = person.age,
creationDate = ZonedDateTime.now()
)
)
}
object PersonServiceLive { // Layer derivation (macro) from the constructor
val layer: RLayer[UserRepository, PersonService] = ZLayer.derive[PersonServiceLive]
}
This is a very simple service, but it could be more complex, and could depend on other services. One of the power of ZIO is to be able to combine services in a very elegant way, for example, let say that the PersonService depends on another Service like a NotificationService, it could be done like this:
class PersonServiceLive private (userRepository: UserRepository, notificatonService: NotificationService) extends PersonService {
def register(person: Person): Task[User] =
if person.age < 18 then ZIO.fail(TooYoungException(person.age))
else for {
user <- userRepository.create(
User(
id = None,
name = person.name,
email = person.email,
age = person.age,
creationDate = ZonedDateTime.now()
)
)
_ <- notificatonService.notify(user)
} yield user
}
And the layer would be derived from the constructor, without any additional code:
object PersonServiceLive:
val layer: RLayer[UserRepository & NotificationService, PersonService] =
ZLayer.derive[PersonServiceLive]
Lifting a new dependency in the service is just a matter of adding it to the constructor, and the layer will be derived from the constructor. In this case, we would need to provide the NotificationService in the main program, and the PersonService would be able to use it.
This is really powerful, convenient during development, and efficient at runtime — a truly modular and scalable application.
The Server layer is a layer that will handle the HTTP server. It is provided by ZIO HTTP with convenient default configuration.
It is a dependency that will be provided to the server effect.
private val server: ZIO[PersonService & Server, IOException, Unit] =
for {
_ <- Console.printLine("Starting server...")
apiEndoints <- HttpApi.endpointsZIO // (1)
docEndpoints = SwaggerInterpreter() // (2)
.fromServerEndpoints(endpoints, "zio-laminar-demo", "1.0.0")
_ <- Server.serve( // (3)
ZioHttpInterpreter(serverOptions)
.toHttp(metricsEndpoint :: webJarRoutes :: endpoints ::: docEndpoints)
// (4)
)
} yield ()
This is a ZIO effect that will:
The
::
and:::
are List concatenation operators,::
is for a single element,:::
is for a list of elements, with right-associativity.
(metricsEndpoint :: (webJarRoutes :: (apiEndpoints ::: docEndpoints)))
Quite a Scala idiom, that will allow to build a list of elements in a very readable way.
Hence,
metricsEndpoint :: webJarRoutes :: endpoints ::: docEndpoints
is a list of endpoints.
The endpoints are a combination of the metrics endpoint, the webjar routes, the API endpoints and the documentation endpoints.
object HttpApi {
private def gatherRoutes(
controllers: List[BaseController]
): List[ServerEndpoint[Any, Task]] =
controllers.flatMap(_.routes)
private def makeControllers: URIO[PersonService, List[BaseController]] = for {
healthController <- HealthController.makeZIO
personController <- PersonController.makeZIO
} yield List(healthController, personController)
val endpointsZIO: URIO[PersonService, List[ServerEndpoint[Any, Task]]] =
makeControllers.map(gatherRoutes)
}
And last part of puzzle, PersonController that will provide the API endpoints.
As it leverages Tapir, see the next section for more details.
In the meantime, enjoy the Mermaid diagram of the dependencies compiled by ZIO.
Tapir is a library for defining API endpoint as immutable data structures.
This is a huge win, because it allows us to:
Here is an example of a Tapir endpoint definition:
In the shared module, the API endpoints are defined as immutable data structures.
object PersonEndpoints {
val get: Endpoint[Unit, Unit, String, Person, Any] = endpoint.get
.in("person")
.out(jsonBody[Person])
.errorOut(stringBody)
.summary("Get a person by id")
val create: Endpoint[Unit, Person, Throwable, User, Any] = baseEndpoint
.tag("person")
.name("person")
.post
.in("person")
.in(
jsonBody[Person] // (1)
.description("Person to create")
.example(Person("John", 30, Left(Cat("Fluffy"))))
)
.out(jsonBody[User])
.description("Create person")
}
This is a simple GET endpoint that will return a Person and a POST endpoint that will create a Person and return a User.
From this definition, Tapir will help generate the client and server side code, the documentation and the tests.
On the server side, Tapir Endpoints.create
will be implemented by a ZIO effect, through the zServerLogic combinator.
// (1)
class PersonController private (personService: PersonService) extends BaseController {
[...]
val create: ServerEndpoint[Any, Task] = PersonEndpoint.createEndpoint
.zServerLogic( // (2)
p => personService.register(p)
)
val routes: List[ServerEndpoint[Any, Task]] =
List(create, get, ...) // (3)
}
object PersonController {
def makeZIO: URIO[PersonService, PersonController] = // (4)
ZIO.service[PersonService] // (4a)
.map(personSevice => new PersonController(personService)) // (4b)
}
Here are the important bits:
p: Person
is the payload of the request as describe in the endpoint definition.personService.register(p)
is the ZIO effect that will register the person.Everything is described as ZIO effects. URIO[PersonService, PersonController]
is a ZIO that needs a PersonService and will produce a PersonController and cannot fail.
URIO is a type alias for a ZIO that cannot fail:
type URIO[R, A] = ZIO[R, Nothing, A]
zServerLogic is part of the Tapir ZIO support, and bridges a ZIO in the Rest API. Itis a combinator that will transform a ZIO effect into a server logic, it will handle the error and the success case.
Tapir provides a lot of combinators to handle the ZIO effect, Cats Effect or Future.
On the client side, Tapir will allow to interact as client, it will leverage the same endpoint definition to generate the client side code.
It will smartly generate the client side code, that will be used in the Laminar UI.
Laminar is a 100% Scala reactive UI library. In few words, Laminar provides reactive variables and events that can be watched and consumed in the UI:
Laminar also provides Dom manipulation facilities, and bindings to WebComponent libraries (UI5 in this example).
In this example, I use LaminarSAPUI5Bindings and Magnolia to derive HTML forms from a case class.
import dev.cheleb.scalamigen.*
import com.raquo.laminar.api.L.*
case class Cat(name: String, weight: Int, kind: Boolean = true)
val catVar = Var(Cat("Scala le chat", 6))
catVar.asForm
Et voilà, a form is derived from the case class, and can be rendered in the UI.
On the client side, Tapir will allow to interact as client, is a very straightforward way:
Button(
"-- Post -->",
onClick --> { _ => // (1)
PersonEndpoint // (2)
.createEndpoint(
personVar.now() // (2a)
) // (2b)
.emitTo( // (3a)
userBus // (3b)
)
}
)
We have:
This is a lot of magic in a single line!
In the same way that on server side zServerLogic bridges ZIO in Tapir through an extension method, on client side two extension methods are used:
This is the power of Scala 3 extension methods, that allows to extend existing classes with new methods.
A first extension, turns this endpoint value into a function:
extension [I, E <: Throwable, O](endpoint: Endpoint[Unit, I, E, O, Any])
/**
* Call the endpoint with a payload, and get a ZIO back.
* @param payload
* @return
*/
def apply(payload: I): RIO[BackendClient, O] =
ZIO.service[BackendClient]
.flatMap(_.endpointRequestZIO(endpoint)(payload))
When called, this turns the enhanced endpoint to a function that:
Simply said, with this extension in scope, now createEndpoint can be used as a function:
def createEndpoint[I, O](payload: I): RIO[BackendClient, O]
Note: RIO is just a type alias where error are Throwable:
type RIO[R, A]= ZIO[R, Throwable, A]
This effect needs a BackendClient to run - look at ZIO.service[BackendClient]
.
This is what the second extension will satisfy in a glimpse of Scala:
extension [E <: Throwable, A](zio: ZIO[BackendClient, E, A])
/**
* Emit the result of the ZIO to an EventBus.
*
* @param bus
*/
def emitTo(bus: EventBus[A]): Unit =
Unsafe.unsafe { implicit unsafe => // (1)
Runtime.default.unsafe.fork( // (2)
zio
.tapError(e => Console.printLineError(e.getMessage())) // (3)
.tap(a => ZIO.attempt(bus.emit(a))) // (4)
.provide(BackendClientLive.configuredLayer) // (5)
)
}
This extension will allow to run the ZIO effect and emit the result to an EventBus.
BackendClientLive.configuredLayer
is the layer that will provide the BackendClient service, this is where most of the magic happens.
object BackendClientLive:
val layer = ZLayer.derive[BackendClientLive]
val configuredLayer: ULayer[BackendClient] = {
val backend: SttpBackend[Task, ZioStreams] = FetchZioBackend()
val interpreter = SttpClientInterpreter()
val config = BackendClientConfig(Some(uri"${Constants.backendBaseURL}"))
ZLayer.succeed(backend) ++ ZLayer.succeed(interpreter) ++ ZLayer.succeed(config) >>> layer
}
In short term, in this one-liner we:
In the same way, a secured client can be defined, that will handle the authentication and the authorization.
Of course, it will be a bit more complex, but the same principles will apply.
Tapir does distinguish the secured endpoints from the unsecured ones, and will generate the client side code accordingly.
For example, a secured endpoint will be defined like this:
val profile: Endpoint[String, Boolean, Throwable, (User, Option[Pet]), Any] = baseEndpoint
.securityIn(auth.bearer[String]())
.tag("person")
.name("profile")
.get
.in("profile")
.in(query[Boolean]("withPet").default(false))
.out(jsonBody[(User, Option[Pet])])
.description("Get profile")
Notice the first type parameter of the endpoint, it is a String, that will be the token, and that will be used to authenticate the request.
This is a secured endpoint, that will require a bearer token to be called.
The server will need to handle the authentication and the authorization.
In the same way, the secured client will be defined with an extension method that will handle the authentication and the authorization.
trait SecuredBaseController[SI, Principal](
principalExtractor: SI => Task[Principal] // (1)
):
/** Enriches an endpoint with security logic
*/
extension [I, O, R](endpoint: Endpoint[SI, I, Throwable, O, R]) // (2)
/** ZIO security logic for a server endpoint
*
* Extracts the user principal from
* the request Security Input, and applies
*
* @param logic,
* curryied function from user principal
* to request to response
* @return
*/
@SuppressWarnings(Array("org.wartremover.warts.Any"))
def securedServerLogic(
logic: Principal => I => Task[O] // (3)
): ServerEndpoint[R, Task] =
endpoint
.zServerSecurityLogic(principalExtractor)
.serverLogic(principal => input => logic(principal)(input))
This trait will provide an extension method that will handle the authentication and the authorization of the secured endpoint. But in a generic way, that will allow to handle any kind of security input and any kind of principal.
Then the controller (server side) will be defined like this:
class PersonController private (personService: PersonService, jwtService: JWTService)
extends BaseController
with SecuredBaseController[String, UserID](jwtService.verifyToken) { // (1)
val profile: ServerEndpoint[Any, Task] =
PersonEndpoint.profile.securedServerLogic { userId => withPet => // (2)
personService.getProfile(userId, withPet)
}
}
extends SecuredBaseController
with the token extractor function: String => Task[UserID]
.One-liners again!
On client side, the secured client will be defined with an extension method that will handle the authentication. Not to brag, but I believe this is the most impressive part of this article (Daniel here: I agree!)
Let’s see how the secured client will be used in the UI:
val userBus = new EventBus[(User, Option[Pet])]
def apply() = div(
child <-- session( // (1)
div(h1("Please log in to view your profile")) // (2)
)(_ =>
div(
onMountCallback { _ => // (3)
PersonEndpoint.profile(false).emitTo(userBus) // (4)
},
h1("Profile Page"), // (5)
// Display the user profile
The attentive reader will notice that the secured endpoint is called without any token, just like the unsecured endpoint. How is it possible?
This is because the token is handled by the extension method that is triggered by the secured endpoint.
extension [I, E <: Throwable, O](endpoint: Endpoint[String, I, E, O, Any]) // (1)
/** Call the secured endpoint with a payload, and get a ZIO back.
* @param payload
* @return
*/
@targetName("securedApply")
def apply[UserToken <: WithToken]( // (2)
payload: I
)(using session: Session[UserToken]): RIO[SameOriginBackendClient, O] = // (3)
ZIO
.service[SameOriginBackendClient]
.flatMap(_.securedEndpointRequestZIO(endpoint)(payload)) // (4)
securedEndpointRequestZIO
is the method which will handle the token through the session and local storage.With this extension methods, secured endpoints can be called in the same way as unsecured endpoints, and the token will be handled in a full type safe manner.
This is part of an external library.
Here is the full code of the page that will create a Person and display the registered User in the UI.
package com.example.ziolaminardemo.app.demos.scalariform
// imports ...
object ScalariformDemoPage:
def apply() =
val personVar = Var(Person("Alice", 42, Left(Cat("Fluffy")))) // (1)
val userBus = EventBus[User]() // (2)
div(
styleAttr := "width: 100%; overflow: hidden;",
div(
styleAttr := "width: 600px; float: left;",
Form.renderVar(personVar) // (3)
),
div(
styleAttr := "width: 100px; float: left; margin-top: 200px;",
Button(
"-- Post -->",
onClick --> { _ =>
// scalafmt:off
PersonEndpoint // (4)
.createEndpoint(personVar.now())
.emitTo(userBus)
// scalafmt:on
}
)
),
div(
styleAttr := "width: 600px; float: left;",
h1("Databinding"),
child.text <-- personVar.signal.map(p => s"$p"), // (5)
h1("Response"),
child <-- userBus.events.map(renderUser) // (6)
)
)
def renderUser(user: User) =
div(
h2("User"),
div(s"Name: ${user.name}"),
div(s"Age: ${user.age}"),
div(s"Pet: ${user.pet}"),
div(s"Creation Date: ${user.creationDate}")
)
This is a lot of magic in a single page.
We can leverage the JavaScript ecosystem in a type safe manner, either by using a Scala.js façade or by using the JavaScript API directly.
To illustrate this, we are using a Laminar binding to a SAP UI5 component. But more interestingly, you will find in the project a façade to the Chart.js library.
This is generated by ScalablyTyped, a tool that will generate Scala.js façades from TypeScript definitions.
This sample is extracted from Sébastien Doeraene demonstration: Getting started with Scala.js, Laminar and ScalablyTyped
addSbtPlugin("org.scalablytyped.converter" % "sbt-converter" % "1.0.0-beta44")
Some configuration, devDependencies point to TypeScript whereas runtime dependencies point javascript:
{
"name": "scalablytyped",
"private": true,
"version": "0.0.1",
"type": "module",
"dependencies": {
"chart.js": "2.9.4"
},
"devDependencies": {
"typescript": "5.4.5",
"@types/chart.js": "2.9.29"
}
}
In this example Chart.js bindings are generated from the TypeScript definition and fully accessible from ScalaJS at compile time, whereas the chart.js will be used at runtime. Generated classes are pushed in your local (ivy) repo, hence generated only once.
Notice, this package.json is not the application one, because I want to limit this binding generation to strictly necessary.
Then you can refer to TypeScript library very easily, see here.
This is a very simple example, but it shows how ZIO, Laminar and Tapir can be combined to build a full stack web app.
ZIO provides a type safe way to handle effects, and to build a full stack application both on the client and the server side.
Laminar provides a type safe way to build a reactive UI.
Tapir provides a type safe way to define, expose and consume API endpoints.
A powerful combination, that allows to build a full stack web app in a type safe manner. And in the meantime we can leverage the JavaScript ecosystem, without compromising type safety, architecture and scalability of the application.
The g8 starter project needs:
This is the subject of a future article.
Hope you liked this introduction.
Many more patterns to discover in the ZIO rite of passage course.
I really love how the combo of ZIO, Laminar and Tapir, ScalaJs is able to leverage both Scala and JS ecosystem to build fully type-safe, reactive web app with a minimum of boilerplate, thanks to Scala 3 new derivation and extension mechanism.
Cheers, and happy coding.
All the g8 scaffolding code is available on github zio-scalajs-laminar.g8
Share on: