Skip to main content

Your First Five Minutes with ZIO Blocks

In five minutes, we write one small program that uses three blocks together. It reads temperature readings from a file in JSON Lines format (one JSON object per line), decodes each line into a case class, skips the line that is broken, and prints a summary as JSON.

Each block does one job:

  • Schema turns JSON into case classes and back.
  • Scope opens the file and guarantees that it is closed.
  • Chunk holds the decoded readings and computes the summary.

No block needs a runtime, an effect type or any setup: every call on this page is an ordinary method call.

Add Two Dependencies​

The program needs two artifacts; Chunk comes along with Schema. On Scala 2.13 and Scala 3.3 to 3.8, add them to build.sbt:

libraryDependencies ++= Seq(
"dev.zio" %% "zio-blocks-schema" % "0.0.56",
"dev.zio" %% "zio-blocks-scope" % "0.0.56"
)

On Scala 3.9 and newer, the artifact names start with zio-blocks-next- and need -experimental; Installation covers that case, scala-cli and Scala.js.

Describe the Data with Schema​

We start with two case classes: Reading is one line of the file, and Summary is the result we print. Giving each one a derived Schema is all it takes to read and write it as JSON:

import zio.blocks.schema._

final case class Reading(sensor: String, celsius: Double)

object Reading {
implicit val schema: Schema[Reading] = Schema.derived
}

final case class Summary(readings: Int, averageCelsius: Double, hottestSensor: String)

object Summary {
implicit val schema: Schema[Summary] = Schema.derived
}

With the schema in scope, every Reading has a toJsonString method, and every String has a fromJson method that returns either an error or a value:

println(Reading("kitchen", 21.5).toJsonString)
// {"sensor":"kitchen","celsius":21.5}

"""{"sensor":"garage","celsius":12.0}""".fromJson[Reading]
// res1: Either[SchemaError, Reading] = Right(
// Reading(sensor = "garage", celsius = 12.0)
// )

Read the File with Scope​

Now we need a file to read. This block writes four readings to a temporary file; the third one is broken on purpose, because its temperature is a string:

import java.nio.file.{Files, Path}

val path: Path = Files.createTempFile("readings", ".jsonl")

Files.writeString(
path,
"""{"sensor":"kitchen","celsius":21.5}
|{"sensor":"garage","celsius":12.0}
|{"sensor":"attic","celsius":"hot"}
|{"sensor":"office","celsius":23.5}
|""".stripMargin
)

A file reader must be closed after use, even when reading fails. Scope.global.scoped opens a region of code; allocate registers the reader with that region, and the region closes the reader when it ends. The $(reader)(...) call is how we use the reader inside the region: Scope checks at compile time that the reader itself never escapes it, while the list of lines does:

import java.io.BufferedReader
import scala.jdk.CollectionConverters._
import zio.blocks.scope._

def readLines(path: Path): List[String] =
Scope.global.scoped { scope =>
import scope._
val reader: $[BufferedReader] = allocate(
Resource.acquireRelease {
println("Opened the readings file")
Files.newBufferedReader(path)
} { reader =>
reader.close()
println("Closed the readings file")
}
)
$(reader)(_.lines().iterator().asScala.toList)
}

When we call it, the file opens, the lines come back, and the file is already closed by the time we hold the result:

val lines = readLines(path)
// Opened the readings file
// Closed the readings file
// lines: List[String] = List(
// "{\"sensor\":\"kitchen\",\"celsius\":21.5}",
// "{\"sensor\":\"garage\",\"celsius\":12.0}",
// "{\"sensor\":\"attic\",\"celsius\":\"hot\"}",
// "{\"sensor\":\"office\",\"celsius\":23.5}"
// )

Decode Each Line​

Each line decodes to an Either. Splitting the results with List#partitionMap gives us the errors on one side and the readings on the other:

val (errors, parsed) = lines.map(_.fromJson[Reading]).partitionMap(identity)

Three lines decode, and the broken one becomes an error:

parsed
// res3: List[Reading] = List(
// Reading(sensor = "kitchen", celsius = 21.5),
// Reading(sensor = "garage", celsius = 12.0),
// Reading(sensor = "office", celsius = 23.5)
// )

errors.foreach(error => println(s"Skipped a line: ${error.getMessage}"))
// Skipped a line: illegal number at: .celsius

The error message names the field that failed and the reason, so a bad line in a large file is easy to find.

Summarize with Chunk​

Chunk is the sequence type that all blocks share. It works like a Vector, and it stores primitives such as Double without boxing them. We compute the summary from a Chunk of readings, then print it as JSON with the schema we derived for Summary:

import zio.blocks.chunk.Chunk

def summarize(readings: Chunk[Reading]): Summary =
Summary(
readings = readings.size,
averageCelsius = readings.map(_.celsius).sum / readings.size,
hottestSensor = readings.maxBy(_.celsius).sensor
)

println(summarize(Chunk.fromIterable(parsed)).toJsonString)
// {"readings":3,"averageCelsius":19.0,"hottestSensor":"office"}

The Whole Program​

Here is the complete program as one file. Run it from the zio-blocks-examples directory of the repository with sbt "gettingStarted/runMain gettingstarted.FirstFiveMinutes":

zio-blocks-examples/getting-started/src/main/scala/gettingstarted/FirstFiveMinutes.scala
package gettingstarted

import java.io.BufferedReader
import java.nio.file.{Files, Path}
import scala.jdk.CollectionConverters._

import zio.blocks.chunk.Chunk
import zio.blocks.schema._
import zio.blocks.scope._

/**
* Getting Started — Your First Five Minutes
*
* Reads temperature readings from a JSON Lines file, decodes them with Schema,
* summarizes them in a Chunk, and prints the summary as JSON. Scope opens the
* file and closes it as soon as the lines are read.
*
* Run with: sbt "gettingStarted/runMain gettingstarted.FirstFiveMinutes"
*/
final case class Reading(sensor: String, celsius: Double)

object Reading {
implicit val schema: Schema[Reading] = Schema.derived
}

final case class Summary(readings: Int, averageCelsius: Double, hottestSensor: String)

object Summary {
implicit val schema: Schema[Summary] = Schema.derived
}

object FirstFiveMinutes {

def readLines(path: Path): List[String] =
Scope.global.scoped { scope =>
import scope._
val reader: $[BufferedReader] = allocate(
Resource.acquireRelease {
println("Opened the readings file")
Files.newBufferedReader(path)
} { reader =>
reader.close()
println("Closed the readings file")
}
)
$(reader)(_.lines().iterator().asScala.toList)
}

def summarize(readings: Chunk[Reading]): Summary =
Summary(
readings = readings.size,
averageCelsius = readings.map(_.celsius).sum / readings.size,
hottestSensor = readings.maxBy(_.celsius).sensor
)

def main(args: Array[String]): Unit = {
val path = Files.createTempFile("readings", ".jsonl")
Files.writeString(
path,
"""{"sensor":"kitchen","celsius":21.5}
|{"sensor":"garage","celsius":12.0}
|{"sensor":"attic","celsius":"hot"}
|{"sensor":"office","celsius":23.5}
|""".stripMargin
)

val lines = readLines(path)
val (errors, parsed) = lines.map(_.fromJson[Reading]).partitionMap(identity)

errors.foreach(error => println(s"Skipped a line: ${error.getMessage}"))

val readings = Chunk.fromIterable(parsed)

println(summarize(readings).toJsonString)

Files.delete(path)
}
}

What You Just Used​

The program uses three blocks, and each one has a page in this section or in the reference:

BlockWhat it does hereLearn more
SchemaDecodes each JSON line and encodes the summarySchema in a Nutshell
ScopeOpens the file and closes it after readingScope in a Nutshell
ChunkHolds the readings and computes the summaryChunk reference

Next, How ZIO Blocks Thinks explains why the blocks fit together this easily.