One Script, Many Styles - Exploring Scala's Ecosystem with scala-cli
Scala is a rich and expressive language, and one of its strengths is the flexibility it offers in how you write your code. Whether you lean towards functional purity, pragmatic scripting, or modern direct-style syntax, there’s likely a Scala flavor that suits your workflow.
In this post, I’ll walk through a simple script I use to help publish my “news of the month” blog series. What starts as a basic task — reading a list of URLs and printing each page’s title — becomes an opportunity to explore different Scala styles, from idiomatic scripting and parallelism with Future, to effect systems like cats-effect, ZIO, and even the direct-style Ox library.
The goal? To showcase how Scala’s ecosystem lets you solve the same problem in multiple elegant ways.
Context
Each month, I publish a “news of the month” post, a curated list of interesting articles. To do so, throughout the month, I collect interesting URLs in a text file. In order to automate a bit the creation of the markdown for the post, I wrote a script that reads this file, fetches the title for each URL, and prints it to the standard output.
Requirements:
- Do not crash in case of errors (e.g. invalid URLs).
- Do not leave the file open in case of errors. In this simple case, it is more
for educational purposes (
Source.fromFile
creates an open file handle. If we don’t call explicitly.close()
, it causes a file descriptor leak. The OS has a limit on the number of concurrently open files, you can check it withulimit -n
(default is often 256 on macOS).
Getting Started with scala-cli
A simple yet powerful tool has emerged: scala-cli that allows to compile and run scala scripts with an automatic setup (no build files). The dependencies are declared using a directive in the script file itself. So, it’s incredibly easy to experiment with different libraries.
I personally installed it with sdkman sdk install scala-cli
, and I update it with sdk upgrade scala-cli
Here is how to setup IntelliJ. When a new script is added, a pictogram suggests syncing BSP changes (otherwise, the script won’t be recognized and you won’t have completions).
Style 1: Idiomatic scala
scala-cli supports scripts that end with .sc
.
//> using dep org.jsoup:jsoup:1.20.1
import org.jsoup.Jsoup
import org.jsoup.nodes.Document
import scala.io.Source
import scala.util._
Using(
Source.fromFile("/Users/yamo/projects/perso/blog/news-raw/2025-04.txt")
) { source => source.getLines.toList.foreach(fetchTitle) }
def fetchTitle(url: String): Unit = {
Try {
val document = Jsoup.connect(url).get()
val title = document.title()
s"[$title]($url)"
} match {
case Success(result) => println(result)
case Failure(e) => println(s"ERROR: $url ($e)")
}
}
Since Scala 2.13, we can use the Using
class, which provides support for automatic resource management in Scala.
It’s very lightweight (around 20 lines of code) to write this kind of tasks in Scala (I personnaly prefer it over writing a bash script)
scala-cli run fetch-titles-idiomatic.sc
Style 2: Parallelism with Future
It is the standard library concurrency.
//> using dep org.jsoup:jsoup:1.20.1
import org.jsoup.Jsoup
import org.jsoup.nodes.Document
import scala.io.Source
import scala.concurrent.{Await, Future}
import scala.concurrent.ExecutionContext.Implicits.global
import scala.concurrent.duration.*
import scala.util.{Failure, Success, Try, Using}
Using(
Source.fromFile("/Users/yamo/projects/perso/blog/news-raw/2025-04.txt")
) { source =>
val futures = source.getLines.map(url => fetchTitle(url))
Await.result(Future.sequence(futures), 5.minutes)
}
def fetchTitle(url: String): Future[Unit] = {
Future {
Try {
val document = Jsoup.connect(url).get()
val title = document.title()
s"[$title]($url)"
} match {
case Success(result) => println(result)
case Failure(e) => println(s"ERROR: $url ($e)")
}
}
}
The method fetchTitle
now returns a Future
.
scala-cli run fetch-titles-future.sc
Comparison of execution time (measured with time
)
user | system | cpu | total | |
Style 1 | 3.64s | 0.38s | 38% | 10.78 |
Style 2 | 4.88s | 0.58s | 73% | 7.44 |
As expected, the wall-clock time is lower with Style 2.
Style 3: Effect library - cats effect
For cats-effect, a scala file must be used (see cats-effect - faq#scala-cli)
//> using dep org.typelevel::cats-effect:3.6.1
//> using dep org.jsoup:jsoup:1.20.1
import cats.effect.{IO, IOApp, Resource}
import cats.syntax.all.*
import org.jsoup.Jsoup
import scala.io.Source
import scala.language.postfixOps
// https://typelevel.org/cats-effect/docs/faq#scala-cli
object Main extends IOApp.Simple {
def fetchTitle(url: String): IO[Unit] = IO
.blocking {
val document = Jsoup.connect(url).get()
val title = document.title()
println(s"[$title]($url)")
}
.handleErrorWith(e => IO.println(s"ERROR: $url (${e.getMessage})"))
def run: IO[Unit] = {
Resource
.fromAutoCloseable(
IO.blocking(
Source
.fromFile("/Users/yamo/projects/perso/blog/news-raw/2025-04.txt")
.getLines()
.toList
)
)
.use(lines => lines.parTraverse(fetchTitle))
}
}
The .get() method performs a synchronous HTTP request. This blocks the current thread until the request completes or fails. So, this call should be wrapped in a blocking-safe context : IO.blocking(…). This ensures the blocking operation is run on a dedicated thread pool meant for blocking operations, avoiding starvation of compute threads.
Using
is replaced by Resource.fromAutoCloseable
The method fetchTitle
now returns an IO
.
We no longer need to use Try
, errors are captured on the
error channel of IO
.
Note : As opposed to previous implementation, the file is read fully into memory.
scala-cli run fetch-titles-cats-effect.scala
Style 4: Effect library - ZIO
//> using dep "dev.zio::zio:2.1.17"
//> using dep "org.jsoup:jsoup:1.20.1"
import zio._
import org.jsoup.Jsoup
import scala.io.Source
object Main extends ZIOAppDefault {
def fetchTitle(url: String): Task[Unit] =
ZIO
.attemptBlocking {
val doc = Jsoup.connect(url).get()
println(s"[${doc.title()}]($url)")
}
.catchAll(e => ZIO.succeed(println(s"ERROR: $url - ${e.getMessage}")))
def run: ZIO[Scope, Throwable, Unit] = {
for {
source <- ZIO.fromAutoCloseable(
ZIO.attemptBlocking(
Source.fromFile(
"/Users/yamo/projects/perso/blog/news-raw/2025-04.txt"
)
)
)
lines <- ZIO.attemptBlocking(source.getLines().toList)
_ <- ZIO.foreachParDiscard(lines)(fetchTitle)
} yield ()
}
}
On this case, ZIO is quite similar to cats-effect. The same concepts exist in both library.
Here is a summary of the syntax differences
cats-effect | ZIO |
IO.blocking | ZIO.attemptBlocking |
Resource.fromAutocloseable | ZIO.fromAutoCloseable |
lines.parTraverse | ZIO.foreachParDiscard(lines) |
Style 5: Direct-style with Ox and high-level concurrency
In a nutshell, direct-style means you don’t have to deal with nested flatMap
/map
.
//> using dep com.softwaremill.ox::core:0.5.13
//> using dep org.jsoup:jsoup:1.20.1
import org.jsoup.Jsoup
import scala.io.Source
import scala.util.{Failure, Success, Try, Using}
import ox.*
Using(
Source.fromFile("/Users/yamo/projects/perso/blog/news-raw/2025-04.txt")
) { source =>
source.getLines.toList
.foreachPar(4)(fetchTitle)
}
def fetchTitle(url: String): Unit = {
Try {
val document = Jsoup.connect(url).get()
val title = document.title()
s"[$title]($url)"
} match {
case Success(result) => println(result)
case Failure(e) => println(s"ERROR: $url ($e)")
}
}
The method fetchTitle
remains exactly the same as in the first version. The only change
is the call to foreachPar.
Ox is based on project Loom, which requires JDK 21. Thankfully, once again, it’s super easy to get up and running.
scala-cli run fetch-titles-cats-effect.sc --jvm temurin:21
Style 6: Direct-style with Ox and structured-concurrency
what-is-structured-concurrency
Structured concurrency is an approach where the lifetime of a thread is determined by the syntactic structure of the code.
//> using dep com.softwaremill.ox::core:0.5.13
//> using dep org.jsoup:jsoup:1.20.1
import org.jsoup.Jsoup
import scala.io.Source
import scala.util.{Failure, Success, Try, Using}
import ox.*
supervised {
val source = useCloseableInScope(Source.fromFile("/Users/yamo/projects/perso/blog/news-raw/2025-04.txt"))
source.getLines.toList.map(url => fork(fetchTitle(url)).join())
}
def fetchTitle(url: String): Unit = {
Try {
val document = Jsoup.connect(url).get()
val title = document.title()
s"[$title]($url)"
} match {
case Success(result) => println(result)
case Failure(e) => println(s"ERROR: $url ($e)")
}
}
Ox provides methods to manage resources.
scala-cli run fetch-titles-cats-effect.sc --jvm temurin:21
Other styles
Other alternatives that I have not tested :
Conclusion
This tiny script turned out to be a fun playground to explore the diversity of styles and libraries available in modern Scala. Whether you prefer the simplicity of standard futures, the power of type-safe effects with cats-effect or ZIO, or the readability of direct-style with Ox, you can choose the right tool for your needs — all within the same language.
Having options is empowering. It allows you to write code that matches your mindset, project constraints, and performance goals. If you’ve tried a different approach or library for similar scripting tasks, I’d love to hear about it — feel free to reach out or leave a comment!