Your First Schema: Round-Trip a Type Through JSON
Welcome! This tutorial is for Scala developers who are new to ZIO Blocks. If you can write a case class, a companion object and an Either pattern match, you have everything you need. You don't need any prior experience with schemas, codecs or JSON libraries to follow along.
1. Introduction
By the end of this tutorial, you will understand:
- What a
Schemais and how to derive one for your own case classes - How to encode a value to JSON and decode it back into the same value
- How nested case classes, optional fields and lists fit into one schema without extra work
- How to read a
SchemaErrorand use its path to fix bad input - Why one schema can also give you a generic data view and a JSON Schema document
We learn these concepts by building a tiny book catalog, one step at a time:
- Describe a type with a schema
- Encode a value to JSON
- Decode JSON back into a value
- Grow the model with nested types, optional fields and lists
- Read and fix a decode error
- Look at the same data two more ways
We recommend reading from top to bottom — each section builds on the previous one.
To follow along, add the schema module to your build.sbt:
// Scala 2.13 and Scala 3.3 to 3.8
libraryDependencies += "dev.zio" %% "zio-blocks-schema" % "0.0.56"
// Scala 3.9 and newer
libraryDependencies += "dev.zio" %% "zio-blocks-next-schema" % "0.0.56"
scalacOptions += "-experimental"
Scala 3.9 and newer use the zio-blocks-next-schema artifact name; the code in this tutorial is the same for every Scala version. For scala-cli, Scala.js, or cross-building, see How to Add ZIO Blocks to Your Build.
2. Background: One Description, Many Uses
Most Scala programs describe the same data several times. A case class says what a Book is, a JSON encoder says how to write it, a JSON decoder says how to read it, and a JSON Schema document tells other teams what to send. Each description is written by hand, and each one can drift away from the others when the case class changes.
ZIO Blocks takes a different approach. You describe the shape of a type once, as a Schema. Everything else — the JSON codec, a generic data view, the JSON Schema document, and the other formats ZIO Blocks supports — is derived from that one description. When the case class changes, the schema is derived again at compile time, and every view follows. In this tutorial we watch that happen for JSON.
3. Describe a Type with a Schema
A Schema[A] is a value that describes the structure of the type A: its name, its fields and their types. We rarely write one by hand. Instead, we ask the compiler to derive it with Schema.derived and store it as an implicit value in the type's companion object, where the rest of the library can find it.
Let's start with the smallest book we can think of — a title and a publication year:
import zio.blocks.schema._
case class Book(title: String, year: Int)
object Book {
implicit val schema: Schema[Book] = Schema.derived
}
The code above has three parts:
import zio.blocks.schema._bringsSchemaand the extension methods we use later into scope.case class Book(title: String, year: Int)is an ordinary case class; nothing about it is special to ZIO Blocks.implicit val schema: Schema[Book] = Schema.derivedderives the schema at compile time and stores it where the compiler finds it whenever code asks for aSchema[Book].
A schema is a plain value, so we can print it and look at what the compiler derived:
println(Book.schema)
// Schema {
// record Book {
// title: String
// year: Int
// }
// }
The code above prints Book.schema, the value we derived in the previous step. Notice that the schema records the type as a record named Book with two fields, in the order we declared them. That's the whole description — and it is all the library needs to produce JSON.
4. Encode a Value to JSON
With a schema in scope, every Book value gains a toJsonString extension method. It asks the schema for its JSON codec and uses it to write the value as a compact JSON string.
Let's create a book and encode it:
val zionomicon = Book("Zionomicon", 2024)
val zionomiconJson = zionomicon.toJsonString
The code above:
Book("Zionomicon", 2024)creates a regular case class value.zionomicon.toJsonStringencodes it with the JSON codec derived fromBook.schemaand returns aString.
To see the JSON text, we print it:
println(zionomiconJson)
// {"title":"Zionomicon","year":2024}
The code above prints the string that toJsonString returned. Notice the output: each case class field becomes a JSON field with the same name, in declaration order, and there are no extra spaces. We never wrote an encoder — the schema was enough.
5. Decode JSON Back Into a Value
Encoding is half of a round trip. To read JSON, we call the fromJson extension method on a String and tell it which type we expect. Decoding can fail — the text might not be valid JSON or might not match Book — so fromJson returns an Either[SchemaError, Book] instead of throwing.
Let's decode the JSON from the previous step and compare the result with the original value:
val decoded = zionomiconJson.fromJson[Book]
// decoded: Either[SchemaError, Book] = Right(
// Book(title = "Zionomicon", year = 2024)
// )
decoded == Right(zionomicon)
// res2: Boolean = true
The code above:
zionomiconJson.fromJson[Book]findsBook.schema, derives the decoder from it and parses the string.Right(...)in the output means decoding succeeded and carries the decodedBook.decoded == Right(zionomicon)checks the round trip: the value we get back is equal to the value we started with.
That's a complete JSON round trip: one schema, an encoder and a decoder, and the guarantee that both sides agree because they come from the same description.
6. Grow the Model: Nested Types, Optional Fields and Lists
Real data is rarely flat. Let's give each book an author, record the author's birth year only when we know it, and add a list of tags. We start a fresh scope so we can redefine Book:
import zio.blocks.schema._
case class Author(name: String, born: Option[Int])
case class Book(title: String, author: Author, year: Int, tags: List[String])
object Book {
implicit val schema: Schema[Book] = Schema.derived
}
The code above:
Authoris a second case class with an optional field,born: Option[Int].Booknow nests anAuthorand holds aList[String]of tags.Book.schemais still one line.Schema.derivedlooks for a schema for each field's type and derives one when it finds none, soAuthor,Option[Int]andList[String]need no extra code.
Let's encode a book with every field filled in:
val programmingInScala = Book("Programming in Scala", Author("Martin Odersky", Some(1958)), 2008, List("scala", "fundamentals"))
// programmingInScala: Book = Book(
// title = "Programming in Scala",
// author = Author(name = "Martin Odersky", born = Some(1958)),
// year = 2008,
// tags = List("scala", "fundamentals")
// )
println(programmingInScala.toJsonString)
// {"title":"Programming in Scala","author":{"name":"Martin Odersky","born":1958},"year":2008,"tags":["scala","fundamentals"]}
The code above builds a book with a nested Author, Some(1958) as the birth year and a two-element tag list, then encodes the whole value in one call. The nested Author becomes a nested JSON object, Some(1958) is written as the plain number 1958, and the list becomes a JSON array.
Now let's encode a book where we don't know the author's birth year and have no tags yet:
val zionomicon = Book("Zionomicon", Author("John A. De Goes", None), 2024, Nil)
// zionomicon: Book = Book(
// title = "Zionomicon",
// author = Author(name = "John A. De Goes", born = None),
// year = 2024,
// tags = List()
// )
println(zionomicon.toJsonString)
// {"title":"Zionomicon","author":{"name":"John A. De Goes"},"year":2024}
The code above leaves the birth year as None and passes Nil, an empty tag list. Notice what is missing from the output: the JSON codec leaves out a field whose value is None and a list that is empty. Decoding works the same way in reverse — a missing optional field becomes None and a missing list becomes an empty list:
"""{"title":"Zionomicon","author":{"name":"John A. De Goes"},"year":2024}""".fromJson[Book]
// res6: Either[SchemaError, Book] = Right(
// Book(
// title = "Zionomicon",
// author = Author(name = "John A. De Goes", born = None),
// year = 2024,
// tags = List()
// )
// )
The schema grew with the case classes, and the round trip still holds without a single hand-written codec.
7. Read and Fix a Decode Error
Sooner or later, a decoder meets JSON that doesn't fit. Every failure comes back as a Left holding a SchemaError. Its SchemaError#message says what went wrong and where, using a path that starts at . for the top-level value.
Let's decode a book whose year field is missing:
val missingYear = """{"title":"Programming in Scala","author":{"name":"Martin Odersky"}}""".fromJson[Book]
// missingYear: Either[SchemaError, Book] = Left(
// SchemaError(
// List(
// ExpectationMismatch(
// source = DynamicOptic(IndexedSeq()),
// expectation = "missing required field \"year\""
// )
// )
// )
// )
The Left holds the error as data: what the decoder expected and the path where it stopped. To read it as one human-readable message, we pattern match on the Either:
missingYear match {
case Left(error) => println(error.message)
case Right(book) => println(s"Decoded: $book")
}
// missing required field "year" at: .
The code above:
"""...""".fromJson[Book]returns aLeftbecauseyearis required — its type isInt, notOption[Int].case Left(error) => println(error.message)prints the human-readable message from theSchemaError.at: .in the message says the problem is in the top-level object itself.
The path becomes really useful when the problem is deep inside the JSON. Let's send the author's name as a number:
val badName = """{"title":"Programming in Scala","author":{"name":42},"year":2008}""".fromJson[Book]
// badName: Either[SchemaError, Book] = Left(
// SchemaError(
// List(
// ExpectationMismatch(
// source = DynamicOptic(IndexedSeq(Field("author"), Field("name"))),
// expectation = "expected '\"'"
// )
// )
// )
// )
badName match {
case Left(error) => println(error.message)
case Right(book) => println(s"Decoded: $book")
}
// expected '"' at: .author.name
The message says the decoder expected ", the opening quote of a JSON string, but found the number 42. Notice the path after at: — it walks from the top-level object into author and then into name, so we know exactly which value to fix. Let's fix both problems in the input and decode again:
val fixed = """{"title":"Programming in Scala","author":{"name":"Martin Odersky"},"year":2008}""".fromJson[Book]
// fixed: Either[SchemaError, Book] = Right(
// Book(
// title = "Programming in Scala",
// author = Author(name = "Martin Odersky", born = None),
// year = 2008,
// tags = List()
// )
// )
The code above repeats the decode with name as a string and year present. The result is a Right, and the decoded book has born = None and an empty tag list, just like in the previous section.
fromJson never throws on bad input. Because the return type is Either[SchemaError, Book], the compiler reminds us to handle the failure case every time we decode.
8. Look at the Same Data Two More Ways
The schema gives us more than a JSON codec. To finish, let's look at two more views that come from the same Book.schema.
The first is Schema#toDynamicValue, which turns a value into a DynamicValue — a generic, type-independent tree of records, sequences and primitives. Library code uses it to work with any data without knowing its Scala type. Let's convert programmingInScala:
println(Book.schema.toDynamicValue(programmingInScala))
// {
// title: "Programming in Scala",
// author: {
// name: "Martin Odersky",
// born: {
// value: 1958
// } @ {tag: "Some"}
// },
// year: 2008,
// tags: [
// "scala",
// "fundamentals"
// ]
// }
The code above converts the book into a DynamicValue and prints that generic tree. Notice that the printed tree follows the structure of Book: a record with title, author, year and tags fields. One detail differs from the JSON: born appears as a value tagged Some, because the generic view keeps the Option case that the JSON codec flattened into a plain number. The show extension method prints the same text in one call, as println(programmingInScala.show).
The second view is Schema#toJsonSchema, which describes the JSON format itself as a JSON Schema document that other services and tools can read:
println(Book.schema.toJsonSchema)
// {
// "properties": {
// "title": {
// "type": "string"
// },
// "author": {
// "properties": {
// "name": {
// "type": "string"
// },
// "born": {
// "type": [
// "null",
// "integer"
// ],
// "maximum": 2147483647,
// "minimum": -2147483648
// }
// },
// "type": "object",
// "required": [
// "name"
// ],
// "title": "Author"
// },
// "year": {
// "type": "integer",
// "maximum": 2147483647,
// "minimum": -2147483648
// },
// "tags": {
// "items": {
// "type": "string"
// },
// "type": [
// "array",
// "null"
// ],
// "title": "List[String]"
// }
// },
// "type": "object",
// "required": [
// "title",
// "author",
// "year"
// ],
// "title": "Book"
// }
The code above:
Book.schema.toJsonSchemaderives a JSON Schema document from the same description that our encoder and decoder use."required"liststitle,authorandyear— exactly the fields our decoder rejected when they were missing — while the optionalbornand thetagslist are not required.
That's the "one description, many uses" idea from the start of this tutorial, made visible: the case class, the JSON codec, the generic view and the JSON Schema document all agree because they come from one schema.
9. Putting It Together
This example combines all the concepts we've learned — deriving a schema for nested case classes, encoding to JSON, decoding back, checking the round trip, handling a decode error and printing the generic view:
package yourfirstschema
import zio.blocks.schema._
/**
* Your First Schema: Round-Trip a Type Through JSON — Complete Example
*
* Derives a schema for nested case classes, encodes to JSON, decodes back,
* checks the round trip, handles a decode error and prints the generic view.
*
* Run with: cd schema-examples && sbt "runMain yourfirstschema.CompleteExample"
*/
object CompleteExample extends App {
case class Author(name: String, born: Option[Int])
case class Book(title: String, author: Author, year: Int, tags: List[String])
object Book {
implicit val schema: Schema[Book] = Schema.derived
}
val programmingInScala =
Book("Programming in Scala", Author("Martin Odersky", Some(1958)), 2008, List("scala", "fundamentals"))
// Encode to JSON and decode it back
val programmingInScalaJson = programmingInScala.toJsonString
println(programmingInScalaJson)
val decoded = programmingInScalaJson.fromJson[Book]
println(decoded)
println(decoded == Right(programmingInScala))
// Read a decode error
val missingYear = """{"title":"Programming in Scala","author":{"name":"Martin Odersky"}}""".fromJson[Book]
missingYear match {
case Left(error) => println(error.message)
case Right(book) => println(s"Decoded: $book")
}
// The generic view of the same data
println(programmingInScala.show)
}
10. Running the Examples
All examples in this tutorial have corresponding runnable Scala files in the schema-examples project. To get them, clone the repository and change into the examples project:
git clone https://github.com/zio/zio-blocks.git
cd zio-blocks/schema-examples
Run them in order to follow the tutorial step by step.
Describe a Type with a Schema
This example derives a schema for the flat Book case class and prints it, showing the record that the compiler derived.
schema-examples/src/main/scala/yourfirstschema/Concept1DescribeExample.scala
package yourfirstschema
import zio.blocks.schema._
/**
* Your First Schema: Round-Trip a Type Through JSON — Concept 1: Describe a
* Type with a Schema
*
* Derives a Schema for a flat Book case class and prints the record the
* compiler derived.
*
* Run with: cd schema-examples && sbt "runMain
* yourfirstschema.Concept1DescribeExample"
*/
object Concept1DescribeExample extends App {
case class Book(title: String, year: Int)
object Book {
implicit val schema: Schema[Book] = Schema.derived
}
println(Book.schema)
}
Run it from the schema-examples directory:
sbt "runMain yourfirstschema.Concept1DescribeExample"
Encode a Value to JSON
This example encodes a Book to a compact JSON string with toJsonString.
schema-examples/src/main/scala/yourfirstschema/Concept2EncodeExample.scala
package yourfirstschema
import zio.blocks.schema._
/**
* Your First Schema: Round-Trip a Type Through JSON — Concept 2: Encode a Value
* to JSON
*
* Encodes a Book to a compact JSON string with toJsonString.
*
* Run with: cd schema-examples && sbt "runMain
* yourfirstschema.Concept2EncodeExample"
*/
object Concept2EncodeExample extends App {
case class Book(title: String, year: Int)
object Book {
implicit val schema: Schema[Book] = Schema.derived
}
val zionomicon = Book("Zionomicon", 2024)
val zionomiconJson = zionomicon.toJsonString
println(zionomiconJson)
}
Run it from the schema-examples directory:
sbt "runMain yourfirstschema.Concept2EncodeExample"
Decode JSON Back Into a Value
This example decodes the JSON with fromJson and checks that the round trip returns the original value.
schema-examples/src/main/scala/yourfirstschema/Concept3DecodeExample.scala
package yourfirstschema
import zio.blocks.schema._
/**
* Your First Schema: Round-Trip a Type Through JSON — Concept 3: Decode JSON
* Back Into a Value
*
* Decodes the JSON with fromJson and checks that the round trip returns the
* original value.
*
* Run with: cd schema-examples && sbt "runMain
* yourfirstschema.Concept3DecodeExample"
*/
object Concept3DecodeExample extends App {
case class Book(title: String, year: Int)
object Book {
implicit val schema: Schema[Book] = Schema.derived
}
val zionomicon = Book("Zionomicon", 2024)
val zionomiconJson = zionomicon.toJsonString
val decoded = zionomiconJson.fromJson[Book]
println(decoded)
println(decoded == Right(zionomicon))
}
Run it from the schema-examples directory:
sbt "runMain yourfirstschema.Concept3DecodeExample"
Grow the Model
This example adds a nested Author, an optional birth year and a list of tags, and shows how None and empty lists are left out of the JSON and restored on decode.
schema-examples/src/main/scala/yourfirstschema/Concept4NestedTypesExample.scala
package yourfirstschema
import zio.blocks.schema._
/**
* Your First Schema: Round-Trip a Type Through JSON — Concept 4: Grow the
* Model: Nested Types, Optional Fields and Lists
*
* Adds a nested Author, an optional birth year and a list of tags, and shows
* that None and empty lists are left out of the JSON and restored on decode.
*
* Run with: cd schema-examples && sbt "runMain
* yourfirstschema.Concept4NestedTypesExample"
*/
object Concept4NestedTypesExample extends App {
case class Author(name: String, born: Option[Int])
case class Book(title: String, author: Author, year: Int, tags: List[String])
object Book {
implicit val schema: Schema[Book] = Schema.derived
}
val programmingInScala =
Book("Programming in Scala", Author("Martin Odersky", Some(1958)), 2008, List("scala", "fundamentals"))
println(programmingInScala.toJsonString)
val zionomicon = Book("Zionomicon", Author("John A. De Goes", None), 2024, Nil)
println(zionomicon.toJsonString)
println("""{"title":"Zionomicon","author":{"name":"John A. De Goes"},"year":2024}""".fromJson[Book])
}
Run it from the schema-examples directory:
sbt "runMain yourfirstschema.Concept4NestedTypesExample"
Read and Fix a Decode Error
This example decodes JSON with a missing field and a wrongly typed nested field, prints each SchemaError message with its path, and then decodes the fixed input.
schema-examples/src/main/scala/yourfirstschema/Concept5DecodeErrorsExample.scala
package yourfirstschema
import zio.blocks.schema._
/**
* Your First Schema: Round-Trip a Type Through JSON — Concept 5: Read and Fix a
* Decode Error
*
* Decodes JSON with a missing field and a wrongly typed nested field, prints
* each SchemaError message with its path, then decodes the fixed input.
*
* Run with: cd schema-examples && sbt "runMain
* yourfirstschema.Concept5DecodeErrorsExample"
*/
object Concept5DecodeErrorsExample extends App {
case class Author(name: String, born: Option[Int])
case class Book(title: String, author: Author, year: Int, tags: List[String])
object Book {
implicit val schema: Schema[Book] = Schema.derived
}
val missingYear = """{"title":"Programming in Scala","author":{"name":"Martin Odersky"}}""".fromJson[Book]
println(missingYear match {
case Left(error) => error.message
case Right(book) => s"Decoded: $book"
})
val badName = """{"title":"Programming in Scala","author":{"name":42},"year":2008}""".fromJson[Book]
println(badName match {
case Left(error) => error.message
case Right(book) => s"Decoded: $book"
})
val fixed = """{"title":"Programming in Scala","author":{"name":"Martin Odersky"},"year":2008}""".fromJson[Book]
println(fixed)
}
Run it from the schema-examples directory:
sbt "runMain yourfirstschema.Concept5DecodeErrorsExample"
Look at the Same Data Two More Ways
This example prints the DynamicValue view of a book and the JSON Schema document derived from Book.schema.
schema-examples/src/main/scala/yourfirstschema/Concept6OtherViewsExample.scala
package yourfirstschema
import zio.blocks.schema._
/**
* Your First Schema: Round-Trip a Type Through JSON — Concept 6: Look at the
* Same Data Two More Ways
*
* Prints the DynamicValue view of a book and the JSON Schema document derived
* from Book.schema.
*
* Run with: cd schema-examples && sbt "runMain
* yourfirstschema.Concept6OtherViewsExample"
*/
object Concept6OtherViewsExample extends App {
case class Author(name: String, born: Option[Int])
case class Book(title: String, author: Author, year: Int, tags: List[String])
object Book {
implicit val schema: Schema[Book] = Schema.derived
}
val programmingInScala =
Book("Programming in Scala", Author("Martin Odersky", Some(1958)), 2008, List("scala", "fundamentals"))
println(Book.schema.toDynamicValue(programmingInScala))
println(Book.schema.toJsonSchema)
}
Run it from the schema-examples directory:
sbt "runMain yourfirstschema.Concept6OtherViewsExample"
The Complete Example
The complete example from "Putting It Together" runs everything from the tutorial in one program:
sbt "runMain yourfirstschema.CompleteExample"
To compile every example at once without running them:
sbt compile
11. What You've Learned
In this tutorial, you learned:
- What a
Schemais — one description of a type's shape — and how to derive it withSchema.derived - How to encode a value with
toJsonStringand decode it withfromJson, and why the round trip holds - How nested case classes,
Optionfields andListfields are derived automatically, and howNoneand empty lists are left out of the JSON - How to read a
SchemaErrormessage and use its path to find and fix bad input - How the same schema also gives you a
DynamicValueview and a JSON Schema document
You now have a solid foundation in ZIO Blocks schemas. The next step is to tune the JSON format and use the same schema with other formats.
12. Where to Go Next
- Want to see every extension method? Read Extension Syntax, which covers
toJson,toJsonBytes,showand more. - Ready to customize the JSON? The customization guide walks through it task by task. The JSON Codec overview shows how the codec pieces fit together, and JSON Configuration covers reader and writer settings and field-name mapping with
NameMapper. - Want to understand errors in depth? Read SchemaError for every error case and how errors are combined.
- Curious about the generic view? Read DynamicValue.
- Need another format? Follow One Type, Many Formats to derive JSON, YAML, XML, TOON, MessagePack, Avro and CSV codecs from the same
Schema. The Built-in Codecs overview lists Avro, BSON, CSV, MessagePack, Thrift, TOON, XML and YAML, all derived from the sameSchema. - Need to validate incoming JSON? Follow How to Generate JSON Schema from a Type and Validate Payloads Against It to derive a JSON Schema from your
Schemaand check payloads against it. - Want the full API? The Schema reference page documents every operation on
Schema.