How to Customize JSON Field Names, ADT Encoding and Optional Fields
In this guide, we change the JSON that a derived Schema reads and writes until it matches an API contract we don't control. By the end, you will know how to rename fields to snake_case, rename one field while still accepting its old name, tag sealed trait cases with a "type" field or with no tag at all, decide whether None is written as null, leave fields out of the JSON, and reject unknown fields.
Each section is a short recipe: the problem, the code, and the JSON it produces. We use two tools throughout — Modifier annotations, which change the schema of one type, and JsonCodecDeriver settings, which change every type a codec touches — and finish with a comparison to help you choose between them.
This guide covers:
- Renaming every field with a naming strategy
- Renaming one field and accepting old names
- Tagging sealed trait cases with a type field
- Encoding sealed traits without a tag
- Encoding case-object enums as strings
- Controlling optional fields, empty collections and default values
- Rejecting unknown fields
- Leaving a field out of the JSON
- Choosing between global settings and per-type modifiers
The Problem
The default JSON format of ZIO Blocks uses Scala field names as they are, wraps each sealed trait case in an object keyed by the case name, and leaves out None values and empty lists. That's a good default for data we write and read ourselves, but the services we talk to often expect something else: snake_case keys, a "type" field next to the case's own fields, or an explicit null for every missing value.
When the format is fixed by someone else, the usual fallback is to write the JSON by hand. For example, to send a book to a service that expects snake_case keys and no empty arrays, we might write:
case class Author(fullName: String)
case class Book(title: String, publishedYear: Int, isbn13: Option[String], author: Author, tags: List[String])
def quote(s: String): String = "\"" + s.replace("\\", "\\\\").replace("\"", "\\\"") + "\""
def authorToJson(author: Author): String =
s"""{"full_name":${quote(author.fullName)}}"""
def bookToJson(book: Book): String = {
val fields = List(
Some(s""""title":${quote(book.title)}"""),
Some(s""""published_year":${book.publishedYear}"""),
book.isbn13.map(isbn => s""""isbn13":${quote(isbn)}"""),
Some(s""""author":${authorToJson(book.author)}"""),
if (book.tags.isEmpty) None else Some(s""""tags":${book.tags.map(quote).mkString("[", ",", "]")}""")
)
fields.flatten.mkString("{", ",", "}")
}
This approach repeats every field name a second time, has no decoder at all, and silently drifts from the case class the next time someone adds a field. Each new type, and each sealed trait with its own tagging rule, needs another hand-written function.
In this guide, we keep Schema.derived and change the format instead, with one annotation or one deriver setting per rule.
Prerequisites
We use the same schema module as the Your First Schema tutorial:
// Scala 2.13 and Scala 3.3 to 3.8
libraryDependencies += "dev.zio" %% "zio-blocks-schema" % "0.0.56"
// Scala 3.9 and newer (also needs scalacOptions += "-experimental")
libraryDependencies += "dev.zio" %% "zio-blocks-next-schema" % "0.0.56"
This guide assumes you can derive a schema with Schema.derived and round-trip a value with toJsonString and fromJson, as shown in Your First Schema. Every example uses two imports: zio.blocks.schema._ brings in Schema, Modifier and NameMapper, and zio.blocks.schema.json._ brings in JsonCodecDeriver, JsonCodec and DiscriminatorKind.
The Book Catalog Model
Our running example is a small catalog of Scala books. A Book has a nested Author, an optional ISBN, a list of tags, an optional edition number and a language field with a default value:
import zio.blocks.schema._
import zio.blocks.schema.json._
case class Author(fullName: String)
object Author {
implicit val schema: Schema[Author] = Schema.derived
}
case class Book(
title: String,
publishedYear: Int,
isbn13: Option[String],
author: Author,
tags: List[String],
edition: Option[Int],
language: String = "en"
)
object Book {
implicit val schema: Schema[Book] = Schema.derived
}
val fpInScala = Book(
"Functional Programming in Scala",
2014,
Some("9781617290657"),
Author("Paul Chiusano"),
List("scala", "functional-programming"),
Some(1)
)
With no customization, toJsonString writes every field under its Scala name and leaves out language because it holds the default value:
println(fpInScala.toJsonString)
// {"title":"Functional Programming in Scala","publishedYear":2014,"isbn13":"9781617290657","author":{"fullName":"Paul Chiusano"},"tags":["scala","functional-programming"],"edition":1}
This is the shape we change in the rest of the guide.
Rename Every Field With a Naming Strategy
The most common request is a different naming convention, such as snake_case for a Python or Ruby service. A NameMapper converts each Scala field name; the built-in strategies are NameMapper.SnakeCase, NameMapper.KebabCase, NameMapper.CamelCase, NameMapper.PascalCase, NameMapper.Identity (the default) and NameMapper.Custom(f), which applies any String => String function.
To apply a strategy to every type in a codec, we derive a codec from the schema with JsonCodecDeriver#withFieldNameMapper. Schema#derive returns a JsonCodec[Book]:
val snakeCaseCodec: JsonCodec[Book] =
Book.schema.derive(JsonCodecDeriver.withFieldNameMapper(NameMapper.SnakeCase))
We call JsonCodec#encodeToString and JsonCodec#decode on the codec directly:
println(snakeCaseCodec.encodeToString(fpInScala))
// {"title":"Functional Programming in Scala","published_year":2014,"isbn13":"9781617290657","author":{"full_name":"Paul Chiusano"},"tags":["scala","functional-programming"],"edition":1}
snakeCaseCodec.decode(snakeCaseCodec.encodeToString(fpInScala)) == Right(fpInScala)
// res3: Boolean = true
The mapper reaches the nested Author too, so fullName becomes full_name. Digits don't start a new word, so isbn13 stays as it is.
toJsonString and fromJson always use the default codec of the schema. A codec built with Schema#derive applies its settings only when you call it yourself, so keep it in one place — for example, as a val in the companion object — and use it everywhere you read or write that JSON.
When only one type needs the convention, annotate it with Modifier.fieldNaming instead. The annotation becomes part of the schema, so the plain toJsonString picks it up. It accepts "snake_case", "kebab-case", "camelCase", "PascalCase" and "identity":
import zio.blocks.schema._
import zio.blocks.schema.json._
case class Author(fullName: String)
object Author {
implicit val schema: Schema[Author] = Schema.derived
}
@Modifier.fieldNaming("snake_case")
case class Book(title: String, publishedYear: Int, isbn13: Option[String], author: Author)
object Book {
implicit val schema: Schema[Book] = Schema.derived
}
Now we encode a book with the plain extension method:
println(Book("Programming in Scala", 2008, Some("9780981531601"), Author("Martin Odersky")).toJsonString)
// {"title":"Programming in Scala","published_year":2008,"isbn13":"9780981531601","author":{"fullName":"Martin Odersky"}}
The annotation renames only the fields declared directly in Book. The nested Author keeps fullName, because its own schema has no annotation. A strategy name the library doesn't know, such as "SCREAMING_SNAKE", fails with an IllegalArgumentException when the codec is derived, not while decoding.
Rename One Field and Accept Its Old Name
Sometimes a single field needs a different name — the API calls the ISBN isbn, not isbn13 — and older clients still send a previous name. Modifier.rename sets the name used for both encoding and decoding, and Modifier.alias adds an extra name that the decoder accepts but the encoder never writes:
import zio.blocks.schema._
case class Book(
title: String,
@Modifier.rename("isbn")
@Modifier.alias("isbn_13")
isbn13: Option[String]
)
object Book {
implicit val schema: Schema[Book] = Schema.derived
}
val pis = Book("Programming in Scala", Some("9780981531601"))
The encoder writes the renamed key:
println(pis.toJsonString)
// {"title":"Programming in Scala","isbn":"9780981531601"}
The decoder accepts both the new name and the alias:
"""{"title":"Programming in Scala","isbn":"9780981531601"}""".fromJson[Book]
// res8: Either[SchemaError, Book] = Right(
// Book(title = "Programming in Scala", isbn13 = Some("9780981531601"))
// )
"""{"title":"Programming in Scala","isbn_13":"9780981531601"}""".fromJson[Book]
// res9: Either[SchemaError, Book] = Right(
// Book(title = "Programming in Scala", isbn13 = Some("9780981531601"))
// )
The original Scala name is no longer one of the accepted names. Because isbn13 is optional and unknown fields are ignored by default, input that still uses it decodes without an error, but the value is lost:
"""{"title":"Programming in Scala","isbn13":"9780981531601"}""".fromJson[Book]
// res10: Either[SchemaError, Book] = Right(
// Book(title = "Programming in Scala", isbn13 = None)
// )
If clients may still send the Scala name, add it as an alias too, for example @Modifier.alias("isbn13"). Modifier.rename also wins over any naming strategy from the previous section, so it is the way to make one exception to a snake_case rule.
Tag Sealed Trait Cases With a Type Field
A sealed trait needs a tag in the JSON so the decoder knows which case to build. Model the formats a book is sold in:
import zio.blocks.schema._
import zio.blocks.schema.json._
sealed trait Format
object Format {
case class Paperback(pages: Int) extends Format
case class Ebook(sizeMb: Double) extends Format
case class Audiobook(minutes: Int) extends Format
implicit val schema: Schema[Format] = Schema.derived
}
val paperback: Format = Format.Paperback(320)
By default, the case name is the key of a wrapper object around the case's fields. This is DiscriminatorKind.Key:
println(paperback.toJsonString)
// {"Paperback":{"pages":320}}
Many APIs expect a flat object with a "type" field instead. JsonCodecDeriver#withDiscriminatorKind with DiscriminatorKind.Field produces that shape, and JsonCodecDeriver#withCaseNameMapper changes how case names are written:
val typeFieldCodec: JsonCodec[Format] =
Format.schema.derive(
JsonCodecDeriver
.withDiscriminatorKind(DiscriminatorKind.Field("type"))
.withCaseNameMapper(NameMapper.SnakeCase)
)
The new codec writes the case name into a "type" field and reads it back:
println(typeFieldCodec.encodeToString(paperback))
// {"type":"paperback","pages":320}
typeFieldCodec.decode("""{"type":"audiobook","minutes":600}""")
// res14: Either[SchemaError, Format] = Right(Audiobook(600))
The decoder looks up the "type" field wherever it appears in the object, so it doesn't need to come first.
The same choice can live on the sealed trait itself. Modifier.discriminator selects a field discriminator, Modifier.caseNaming applies a naming strategy to the case names, and Modifier.rename on a case overrides one name:
import zio.blocks.schema._
@Modifier.discriminator("type")
@Modifier.caseNaming("snake_case")
sealed trait Format
object Format {
case class Paperback(pages: Int) extends Format
case class Ebook(sizeMb: Double) extends Format
@Modifier.rename("audio")
case class Audiobook(minutes: Int) extends Format
implicit val schema: Schema[Format] = Schema.derived
}
val formats: List[Format] = List(Format.Paperback(320), Format.Ebook(4.5), Format.Audiobook(600))
Because the annotations are part of the schema, toJsonString uses them:
formats.foreach(format => println(format.toJsonString))
// {"type":"paperback","pages":320}
// {"type":"ebook","sizeMb":4.5}
// {"type":"audio","minutes":600}
Encode Sealed Traits Without a Tag
Some APIs send a case's fields with no tag at all and expect the reader to work out the case from the fields that are present. DiscriminatorKind.None writes each case as a plain object and, when decoding, tries the cases in declaration order until one succeeds:
import zio.blocks.schema._
import zio.blocks.schema.json._
sealed trait Format
object Format {
case class Paperback(pages: Int) extends Format
case class Ebook(sizeMb: Double) extends Format
case class Audiobook(minutes: Int) extends Format
implicit val schema: Schema[Format] = Schema.derived
}
val untaggedCodec: JsonCodec[Format] =
Format.schema.derive(JsonCodecDeriver.withDiscriminatorKind(DiscriminatorKind.None))
Encoding drops the tag, and decoding picks the first case whose fields fit:
println(untaggedCodec.encodeToString(Format.Paperback(320)))
// {"pages":320}
untaggedCodec.decode("""{"minutes":600}""")
// res19: Either[SchemaError, Format] = Right(Audiobook(600))
{"minutes":600} is not a valid Paperback or Ebook, because their required fields are missing, so the decoder moves on and builds an Audiobook. Input that fits no case fails with one generic message:
untaggedCodec.decode("""{"chapters":12}""") match {
case Left(error) => println(error.message)
case Right(format) => println(s"Decoded: $format")
}
// expected a variant value at: .
Without a tag, the order of the cases decides the result. A case whose fields are all optional, or a case whose fields are a subset of a later case's fields, matches input meant for the later case, and the decoder never reaches it. Use DiscriminatorKind.None only when every case has a required field that no earlier case accepts, and prefer a type field whenever you control the format.
Encode Case-Object Enums as Strings
A sealed trait whose cases are all case objects is an enumeration, and the JSON codec writes each value as a plain string by default:
import zio.blocks.schema._
import zio.blocks.schema.json._
sealed trait Availability
object Availability {
case object InStock extends Availability
case object OutOfPrint extends Availability
case object Preorder extends Availability
implicit val schema: Schema[Availability] = Schema.derived
}
val outOfPrint: Availability = Availability.OutOfPrint
The plain encoder writes the case name as a string, a case name mapper changes its spelling, and JsonCodecDeriver#withEnumValuesAsStrings set to false switches back to the wrapper-object form used for other sealed traits:
println(outOfPrint.toJsonString)
// "OutOfPrint"
println(Availability.schema.derive(JsonCodecDeriver.withCaseNameMapper(NameMapper.SnakeCase)).encodeToString(outOfPrint))
// "out_of_print"
println(Availability.schema.derive(JsonCodecDeriver.withEnumValuesAsStrings(false)).encodeToString(outOfPrint))
// {"OutOfPrint":{}}
@Modifier.caseNaming("snake_case") on the sealed trait gives the same snake_case strings through toJsonString.
Control Optional Fields, Empty Collections and Defaults
By default, the encoder leaves out three kinds of fields, and the decoder fills them back in when they are missing:
| Field value | Encoder setting (default) | Decoder setting (default) |
|---|---|---|
None in an Option field | JsonCodecDeriver#withTransientNone(true): left out | JsonCodecDeriver#withRequireOptionFields(false): may be missing |
| An empty collection | JsonCodecDeriver#withTransientEmptyCollection(true): left out | JsonCodecDeriver#withRequireCollectionFields(false): may be missing |
| A value equal to the field default | JsonCodecDeriver#withTransientDefaultValue(true): left out | JsonCodecDeriver#withRequireDefaultValueFields(false): may be missing |
Return to the book model from the start of the guide and add a book whose ISBN and edition are unknown and which has no tags yet:
import zio.blocks.schema._
import zio.blocks.schema.json._
case class Author(fullName: String)
object Author {
implicit val schema: Schema[Author] = Schema.derived
}
case class Book(
title: String,
publishedYear: Int,
isbn13: Option[String],
author: Author,
tags: List[String],
edition: Option[Int],
language: String = "en"
)
object Book {
implicit val schema: Schema[Book] = Schema.derived
}
val zionomicon = Book("Zionomicon", 2024, None, Author("John A. De Goes"), Nil, None)
The default codec leaves out isbn13, tags, edition and language:
println(zionomicon.toJsonString)
// {"title":"Zionomicon","publishedYear":2024,"author":{"fullName":"John A. De Goes"}}
If the receiving service expects every field to be present, turn the three encoder settings off. None is then written as null, an empty list as [], and the default value as itself:
val explicitCodec: JsonCodec[Book] =
Book.schema.derive(
JsonCodecDeriver
.withTransientNone(false)
.withTransientEmptyCollection(false)
.withTransientDefaultValue(false)
)
The same book now comes out with every field, and it still decodes back to the original value:
println(explicitCodec.encodeToString(zionomicon))
// {"title":"Zionomicon","publishedYear":2024,"isbn13":null,"author":{"fullName":"John A. De Goes"},"tags":[],"edition":null,"language":"en"}
explicitCodec.decode(explicitCodec.encodeToString(zionomicon)) == Right(zionomicon)
// res28: Boolean = true
The decoder settings work the other way: they make decoding strict. With JsonCodecDeriver#withRequireOptionFields set to true, an Option field must appear in the input, either with a value or as null:
val strictCodec: JsonCodec[Book] =
Book.schema.derive(JsonCodecDeriver.withRequireOptionFields(true))
Decoding the default output of the previous step now fails, because isbn13 and edition are missing:
strictCodec.decode("""{"title":"Zionomicon","publishedYear":2024,"author":{"fullName":"John A. De Goes"}}""") match {
case Left(error) => println(error.message)
case Right(book) => println(s"Decoded: $book")
}
// missing required field "isbn13" at: .
JsonCodecDeriver#withRequireCollectionFields does the same for collections, and JsonCodecDeriver#withRequireDefaultValueFields ignores field defaults when decoding, so a field such as language must be present even though it has a default.
Reject Unknown Fields
By default, the decoder skips JSON fields that don't belong to the type. That keeps old readers working when a writer adds fields, but it also hides typos in hand-written input. Modifier.noExtraFields makes the decoder of one type reject unknown fields:
import zio.blocks.schema._
import zio.blocks.schema.json._
@Modifier.noExtraFields()
case class Author(fullName: String)
object Author {
implicit val schema: Schema[Author] = Schema.derived
}
case class Book(title: String, author: Author)
object Book {
implicit val schema: Schema[Book] = Schema.derived
}
Book still skips an unknown subtitle, while Author rejects a misspelled field and reports where it is:
"""{"title":"Zionomicon","subtitle":"A Guide","author":{"fullName":"John A. De Goes"}}""".fromJson[Book]
// res31: Either[SchemaError, Book] = Right(
// Book(title = "Zionomicon", author = Author("John A. De Goes"))
// )
"""{"title":"Zionomicon","author":{"fullname":"John A. De Goes"}}""".fromJson[Book] match {
case Left(error) => println(error.message)
case Right(book) => println(s"Decoded: $book")
}
// unexpected field "fullname" at: .author
To reject unknown fields in every type a codec handles, use JsonCodecDeriver#withRejectExtraFields instead:
Book.schema
.derive(JsonCodecDeriver.withRejectExtraFields(true))
.decode("""{"title":"Zionomicon","subtitle":"A Guide","author":{"fullName":"John A. De Goes"}}""") match {
case Left(error) => println(error.message)
case Right(book) => println(s"Decoded: $book")
}
// unexpected field "subtitle" at: .
Leave a Field Out of the JSON
Some fields belong to the Scala value but not to the JSON: a relevance score computed in memory, or an old identifier that we still accept from clients but no longer send. Modifier.transient leaves a field out of the JSON in both directions, and Modifier.encodeTransient leaves it out only when encoding:
import zio.blocks.schema._
case class Book(
title: String,
@Modifier.transient() searchScore: Double = 0.0,
@Modifier.encodeTransient() legacyId: Option[Long]
)
object Book {
implicit val schema: Schema[Book] = Schema.derived
}
The encoder writes neither field:
println(Book("Zionomicon", 0.93, Some(42L)).toJsonString)
// {"title":"Zionomicon"}
When decoding, a transient field ignores the input and takes its default value, while an encode-transient field is still read:
"""{"title":"Zionomicon","searchScore":0.5,"legacyId":42}""".fromJson[Book]
// res36: Either[SchemaError, Book] = Right(
// Book(title = "Zionomicon", searchScore = 0.0, legacyId = Some(42L))
// )
Because the decoder has to produce a value for a field it never reads, a transient or encode-transient field needs a default value unless it is an Option or a collection, which fall back to None or an empty collection. Without one, Schema.derived fails at compile time with a Missing default value for transient or encodeTransient field error that names the field and its type, so the mistake never reaches production.
Choose Between Global Settings and Per-Type Modifiers
Most rules in this guide can be applied in two ways. The table below sums up where each one lives:
| Rule | Per-type modifier | Global deriver setting |
|---|---|---|
| Field naming strategy | @Modifier.fieldNaming(...) | JsonCodecDeriver#withFieldNameMapper(...) |
| One field or case name | @Modifier.rename(...) | — |
| Extra accepted names | @Modifier.alias(...) | — |
| Type field discriminator | @Modifier.discriminator(...) | JsonCodecDeriver#withDiscriminatorKind(DiscriminatorKind.Field(...)) |
| No discriminator | — | JsonCodecDeriver#withDiscriminatorKind(DiscriminatorKind.None) |
| Case naming strategy | @Modifier.caseNaming(...) | JsonCodecDeriver#withCaseNameMapper(...) |
| Enums as wrapper objects | — | JsonCodecDeriver#withEnumValuesAsStrings(false) |
| Optional, empty and default fields | — | JsonCodecDeriver#withTransient* and JsonCodecDeriver#withRequire* |
| Leave a field out | @Modifier.transient(), @Modifier.encodeTransient() | — |
| Reject unknown fields | @Modifier.noExtraFields() | JsonCodecDeriver#withRejectExtraFields(true) |
Use a modifier when the rule describes the type itself — "this API always calls the ISBN isbn". The modifier travels with the schema, so toJsonString, fromJson and every codec derived from the schema agree on it, and nobody can forget to pass a setting.
Use a deriver setting when the rule describes the service you are talking to — "this partner wants snake_case and null everywhere". One codec applies the rule to every nested type without annotating each one, and a different codec for a different service can use different settings for the same types.
When both are present, the modifier wins: a type annotated with @Modifier.fieldNaming("snake_case") keeps snake_case names even in a codec built with JsonCodecDeriver#withFieldNameMapper(NameMapper.KebabCase), and a @Modifier.discriminator on a sealed trait overrides JsonCodecDeriver#withDiscriminatorKind. @Modifier.noExtraFields() can only turn rejection on, never off.
To see the precedence in action, annotate a type with one strategy and build a codec with another:
import zio.blocks.schema._
import zio.blocks.schema.json._
@Modifier.fieldNaming("snake_case")
case class Book(title: String, publishedYear: Int)
object Book {
implicit val schema: Schema[Book] = Schema.derived
}
val kebabCodec: JsonCodec[Book] =
Book.schema.derive(JsonCodecDeriver.withFieldNameMapper(NameMapper.KebabCase))
The codec still writes snake_case names, because the annotation on Book takes precedence over the deriver setting:
println(kebabCodec.encodeToString(Book("Zionomicon", 2024)))
// {"title":"Zionomicon","published_year":2024}
Putting It Together
This example combines the recipes into one API format: snake_case field names, a renamed ISBN with an alias, a "type" field for book formats, explicit null values and strict decoding of unknown fields:
package customizejsonencoding
import zio.blocks.schema._
import zio.blocks.schema.json._
/**
* Customize JSON: Putting It Together — Complete Example
*
* One API format combining snake_case field names, a renamed ISBN with an
* alias, a "type" field for book formats, explicit null values and strict
* decoding of unknown fields.
*
* Run with: cd schema-examples && sbt "runMain
* customizejsonencoding.CompleteExample"
*/
object CompleteExample extends App {
@Modifier.discriminator("type")
@Modifier.caseNaming("snake_case")
sealed trait Format
object Format {
case class Paperback(pages: Int) extends Format
case class Ebook(sizeMb: Double) extends Format
case class Audiobook(minutes: Int) extends Format
implicit val schema: Schema[Format] = Schema.derived
}
case class Author(fullName: String)
object Author {
implicit val schema: Schema[Author] = Schema.derived
}
case class Book(
title: String,
publishedYear: Int,
@Modifier.rename("isbn")
@Modifier.alias("isbn_13")
isbn13: Option[String],
author: Author,
tags: List[String],
edition: Option[Int],
formats: List[Format]
)
object Book {
implicit val schema: Schema[Book] = Schema.derived
// Keep the codec in one place and use it everywhere this JSON is read or written
val apiCodec: JsonCodec[Book] =
schema.derive(
JsonCodecDeriver
.withFieldNameMapper(NameMapper.SnakeCase)
.withTransientNone(false)
.withRejectExtraFields(true)
)
}
val zionomicon = Book("Zionomicon", 2024, None, Author("John A. De Goes"), Nil, None, Nil)
val fpInScala = Book(
"Functional Programming in Scala",
2014,
Some("9781617290657"),
Author("Paul Chiusano"),
List("scala", "functional-programming"),
Some(1),
List(Format.Paperback(320))
)
// Encode both books
println(Book.apiCodec.encodeToString(zionomicon))
println(Book.apiCodec.encodeToString(fpInScala))
// Decode a payload that still uses the alias
println(
Book.apiCodec.decode(
"""{"title":"Programming in Scala","published_year":2008,"isbn_13":"9780981531601","author":{"full_name":"Martin Odersky"},"edition":null}"""
)
)
// Reject a payload with an unknown field
Book.apiCodec.decode(
"""{"title":"Zionomicon","published_year":2024,"isbn":null,"author":{"full_name":"John A. De Goes"},"edition":null,"subtitle":"A Guide"}"""
) match {
case Left(error) => println(error.message)
case Right(book) => println(s"Decoded: $book")
}
// Check the round trip
println(Book.apiCodec.decode(Book.apiCodec.encodeToString(fpInScala)) == Right(fpInScala))
}
Running the Examples
All examples in this guide 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
Each example runs on its own, so you can jump straight to the recipe you need.
Rename Every Field With a Naming Strategy
This example applies snake_case names globally with JsonCodecDeriver#withFieldNameMapper and to one type with @Modifier.fieldNaming:
schema-examples/src/main/scala/customizejsonencoding/FieldNamingExample.scala
package customizejsonencoding
import zio.blocks.schema._
import zio.blocks.schema.json._
/**
* Customize JSON: Rename Every Field With a Naming Strategy
*
* Applies snake_case names to every type with JsonCodecDeriver, and to one type
* with Modifier.fieldNaming.
*
* Run with: cd schema-examples && sbt "runMain
* customizejsonencoding.FieldNamingExample"
*/
object FieldNamingExample extends App {
case class Author(fullName: String)
object Author {
implicit val schema: Schema[Author] = Schema.derived
}
case class Book(
title: String,
publishedYear: Int,
isbn13: Option[String],
author: Author,
tags: List[String],
edition: Option[Int],
language: String = "en"
)
object Book {
implicit val schema: Schema[Book] = Schema.derived
}
val fpInScala = Book(
"Functional Programming in Scala",
2014,
Some("9781617290657"),
Author("Paul Chiusano"),
List("scala", "functional-programming"),
Some(1)
)
// With no customization, every field is written under its Scala name
println(fpInScala.toJsonString)
// A codec derived with a field name mapper applies to every type it touches
val snakeCaseCodec: JsonCodec[Book] =
Book.schema.derive(JsonCodecDeriver.withFieldNameMapper(NameMapper.SnakeCase))
println(snakeCaseCodec.encodeToString(fpInScala))
println(snakeCaseCodec.decode(snakeCaseCodec.encodeToString(fpInScala)) == Right(fpInScala))
// Modifier.fieldNaming applies to one type and travels with the schema
@Modifier.fieldNaming("snake_case")
case class AnnotatedBook(title: String, publishedYear: Int, isbn13: Option[String], author: Author)
object AnnotatedBook {
implicit val schema: Schema[AnnotatedBook] = Schema.derived
}
println(
AnnotatedBook("Programming in Scala", 2008, Some("9780981531601"), Author("Martin Odersky")).toJsonString
)
}
Run it from the schema-examples directory:
sbt "runMain customizejsonencoding.FieldNamingExample"
Rename One Field and Accept Its Old Name
This example renames one field with @Modifier.rename, accepts an old name with @Modifier.alias, and shows that the original Scala name is no longer read:
schema-examples/src/main/scala/customizejsonencoding/RenameAndAliasExample.scala
package customizejsonencoding
import zio.blocks.schema._
/**
* Customize JSON: Rename One Field and Accept Its Old Name
*
* Renames one field with Modifier.rename, accepts an old name with
* Modifier.alias, and shows the Scala name is no longer read.
*
* Run with: cd schema-examples && sbt "runMain
* customizejsonencoding.RenameAndAliasExample"
*/
object RenameAndAliasExample extends App {
case class Book(
title: String,
@Modifier.rename("isbn")
@Modifier.alias("isbn_13")
isbn13: Option[String]
)
object Book {
implicit val schema: Schema[Book] = Schema.derived
}
val pis = Book("Programming in Scala", Some("9780981531601"))
// The encoder writes the renamed key
println(pis.toJsonString)
// The decoder accepts both the new name and the alias
println("""{"title":"Programming in Scala","isbn":"9780981531601"}""".fromJson[Book])
println("""{"title":"Programming in Scala","isbn_13":"9780981531601"}""".fromJson[Book])
// The original Scala name is no longer accepted, so the value is lost
println("""{"title":"Programming in Scala","isbn13":"9780981531601"}""".fromJson[Book])
}
Run it from the schema-examples directory:
sbt "runMain customizejsonencoding.RenameAndAliasExample"