Examples

← JSON Codec | API | Examples


Overview

This document demonstrates practical usage patterns for the JSON codec, including configuration options, custom encoders/decoders, and real-world scenarios.

Basic Product Encoding

import org.sigilaris.core.codec.json.*

case class Person(name: String, age: Int) derives JsonCodec

val person = Person("Alice", 30)
// Encode
val json = JsonEncoder[Person].encode(person)
// json: JsonValue = JObject(
//   fields = Map("name" -> JString(value = "Alice"), "age" -> JNumber(value = 30))
// )

// Decode
JsonDecoder[Person].decode(json)
// res0: Either[DecodeFailure, Person] = Right(
//   value = Person(name = "Alice", age = 30)
// )

Nested Structures

import org.sigilaris.core.codec.json.*

case class Address(street: String, city: String, zipCode: String)
  derives JsonCodec

case class Company(name: String, address: Address)
  derives JsonCodec

case class Employee(
  id: String,
  name: String,
  company: Company,
  salary: BigDecimal
) derives JsonCodec
val employee = Employee(
  id = "emp-001",
  name = "Bob Smith",
  company = Company("Acme Inc", Address("123 Main St", "NYC", "10001")),
  salary = BigDecimal("75000.00")
)
// employee: Employee = Employee(
//   id = "emp-001",
//   name = "Bob Smith",
//   company = Company(
//     name = "Acme Inc",
//     address = Address(street = "123 Main St", city = "NYC", zipCode = "10001")
//   ),
//   salary = 75000.00
// )

JsonEncoder[Employee].encode(employee)
// res2: JsonValue = JObject(
//   fields = Map(
//     "id" -> JString(value = "emp-001"),
//     "name" -> JString(value = "Bob Smith"),
//     "company" -> JObject(
//       fields = Map(
//         "name" -> JString(value = "Acme Inc"),
//         "address" -> JObject(
//           fields = Map(
//             "street" -> JString(value = "123 Main St"),
//             "city" -> JString(value = "NYC"),
//             "zipCode" -> JString(value = "10001")
//           )
//         )
//       )
//     ),
//     "salary" -> JString(value = "75000.00")
//   )
// )

Coproduct (Sealed Trait) Encoding

Sealed traits use wrapped-by-type-key discriminator:

import org.sigilaris.core.codec.json.*

sealed trait PaymentMethod derives JsonCodec
case class CreditCard(number: String, expiry: String) extends PaymentMethod
case class BankTransfer(accountNumber: String, routingNumber: String)
  extends PaymentMethod
case object Cash extends PaymentMethod
val payment1: PaymentMethod = CreditCard("1234-5678", "12/25")
// payment1: PaymentMethod = CreditCard(number = "1234-5678", expiry = "12/25")
val payment2: PaymentMethod = Cash
// payment2: PaymentMethod = Cash

// Wrapped discriminator format
JsonEncoder[PaymentMethod].encode(payment1)
// res4: JsonValue = JObject(
//   fields = Map(
//     "CreditCard" -> JObject(
//       fields = Map(
//         "number" -> JString(value = "1234-5678"),
//         "expiry" -> JString(value = "12/25")
//       )
//     )
//   )
// )
JsonEncoder[PaymentMethod].encode(payment2)
// res5: JsonValue = JObject(fields = Map("Cash" -> JObject(fields = Map())))

Field Naming Policies

Snake Case

import org.sigilaris.core.codec.json.*

case class UserProfile(firstName: String, lastName: String, emailAddress: String)
  derives JsonCodec

val profile = UserProfile("Alice", "Smith", "alice@example.com")
// Default: Identity
JsonEncoder[UserProfile].encode(profile)
// res7: JsonValue = JObject(
//   fields = Map(
//     "firstName" -> JString(value = "Alice"),
//     "lastName" -> JString(value = "Smith"),
//     "emailAddress" -> JString(value = "alice@example.com")
//   )
// )

// To use SnakeCase, create encoder with config
// (In practice, you'd configure globally or via given instances)
val snakeConfig = JsonConfig.default.copy(
  fieldNaming = FieldNamingPolicy.SnakeCase
)
// snakeConfig: JsonConfig = JsonConfig(
//   fieldNaming = SnakeCase,
//   dropNullValues = true,
//   treatAbsentAsNull = true,
//   writeBigIntAsString = true,
//   writeBigDecimalAsString = true,
//   discriminator = DiscriminatorConfig(typeNameStrategy = SimpleName)
// )
// With snakeConfig: { "first_name": "Alice", "last_name": "Smith", ... }

Kebab Case

import org.sigilaris.core.codec.json.*

val kebabConfig = JsonConfig.default.copy(
  fieldNaming = FieldNamingPolicy.KebabCase
)
// firstName → first-name

Camel Case

import org.sigilaris.core.codec.json.*

case class DataClass(FirstName: String, LastName: String)
  derives JsonCodec

val camelConfig = JsonConfig.default.copy(
  fieldNaming = FieldNamingPolicy.CamelCase
)
// FirstName → firstName

Optional Fields and Null Handling

import org.sigilaris.core.codec.json.*

case class UserData(
  name: String,
  email: Option[String],
  phone: Option[String]
) derives JsonCodec
val user1 = UserData("Alice", Some("alice@example.com"), None)
// user1: UserData = UserData(
//   name = "Alice",
//   email = Some(value = "alice@example.com"),
//   phone = None
// )
val user2 = UserData("Bob", None, Some("555-1234"))
// user2: UserData = UserData(
//   name = "Bob",
//   email = None,
//   phone = Some(value = "555-1234")
// )

JsonEncoder[UserData].encode(user1)
// res11: JsonValue = JObject(
//   fields = Map(
//     "name" -> JString(value = "Alice"),
//     "email" -> JString(value = "alice@example.com")
//   )
// )
JsonEncoder[UserData].encode(user2)
// res12: JsonValue = JObject(
//   fields = Map(
//     "name" -> JString(value = "Bob"),
//     "phone" -> JString(value = "555-1234")
//   )
// )

Drop Null Values

import org.sigilaris.core.codec.json.*

val dropNullConfig = JsonConfig.default.copy(dropNullValues = true)
// With this config, None fields would be omitted from output

Treat Absent as Null

import org.sigilaris.core.codec.json.*

case class PartialData(name: String, age: Option[Int]) derives JsonCodec
// Missing "age" field
val json = JsonValue.obj("name" -> JsonValue.JString("Alice"))
// json: JsonValue = JObject(fields = Map("name" -> JString(value = "Alice")))

val treatAbsentConfig = JsonConfig.default.copy(treatAbsentAsNull = true)
// treatAbsentConfig: JsonConfig = JsonConfig(
//   fieldNaming = Identity,
//   dropNullValues = true,
//   treatAbsentAsNull = true,
//   writeBigIntAsString = true,
//   writeBigDecimalAsString = true,
//   discriminator = DiscriminatorConfig(typeNameStrategy = SimpleName)
// )
// With this config, missing "age" decodes as None
val decs = JsonDecoder.configured(treatAbsentConfig)
// decs: Decoders = org.sigilaris.core.codec.json.JsonDecoder$configured$Decoders@17eb0d2c
import decs.given
JsonDecoder[PartialData].decode(json)
// res15: Either[DecodeFailure, PartialData] = Right(
//   value = PartialData(name = "Alice", age = None)
// )

Big Number Formatting

BigInt as String

import org.sigilaris.core.codec.json.*

case class Transaction(
  id: String,
  amount: BigInt,
  nonce: BigInt
) derives JsonCodec
val tx = Transaction(
  "tx-001",
  BigInt("123456789012345678901234567890"),
  BigInt("42")
)
// tx: Transaction = Transaction(
//   id = "tx-001",
//   amount = 123456789012345678901234567890,
//   nonce = 42
// )

// Default: writeBigIntAsString = true
JsonEncoder[Transaction].encode(tx)
// res17: JsonValue = JObject(
//   fields = Map(
//     "id" -> JString(value = "tx-001"),
//     "amount" -> JString(value = "123456789012345678901234567890"),
//     "nonce" -> JString(value = "42")
//   )
// )
// Encodes as: { ..., "amount": "123456789012345678901234567890", ... }

BigDecimal as Number

import org.sigilaris.core.codec.json.*

case class Price(currency: String, amount: BigDecimal) derives JsonCodec
val price = Price("USD", BigDecimal("29.99"))
// price: Price = Price(currency = "USD", amount = 29.99)

// Default: writeBigDecimalAsString = true
JsonEncoder[Price].encode(price)
// res19: JsonValue = JObject(
//   fields = Map(
//     "currency" -> JString(value = "USD"),
//     "amount" -> JString(value = "29.99")
//   )
// )
// To encode as JSON number: writeBigDecimalAsString = false

Decoders accept both string and number representations for robustness.

Collections

Lists

import org.sigilaris.core.codec.json.*

case class Playlist(name: String, songs: List[String]) derives JsonCodec
val playlist = Playlist("Favorites", List("Song A", "Song B", "Song C"))
// playlist: Playlist = Playlist(
//   name = "Favorites",
//   songs = List("Song A", "Song B", "Song C")
// )
JsonEncoder[Playlist].encode(playlist)
// res21: JsonValue = JObject(
//   fields = Map(
//     "name" -> JString(value = "Favorites"),
//     "songs" -> JArray(
//       values = Vector(
//         JString(value = "Song A"),
//         JString(value = "Song B"),
//         JString(value = "Song C")
//       )
//     )
//   )
// )

Vectors

import org.sigilaris.core.codec.json.*

case class Tags(values: Vector[String]) derives JsonCodec
val tags = Tags(Vector("scala", "json", "codec"))
// tags: Tags = Tags(values = Vector("scala", "json", "codec"))
JsonEncoder[Tags].encode(tags)
// res23: JsonValue = JObject(
//   fields = Map(
//     "values" -> JArray(
//       values = Vector(
//         JString(value = "scala"),
//         JString(value = "json"),
//         JString(value = "codec")
//       )
//     )
//   )
// )

Maps with String Keys

import org.sigilaris.core.codec.json.*

case class Config(settings: Map[String, String]) derives JsonCodec
val config = Config(Map("theme" -> "dark", "language" -> "en"))
// config: Config = Config(
//   settings = Map("theme" -> "dark", "language" -> "en")
// )
JsonEncoder[Config].encode(config)
// res25: JsonValue = JObject(
//   fields = Map(
//     "settings" -> JObject(
//       fields = Map(
//         "theme" -> JString(value = "dark"),
//         "language" -> JString(value = "en")
//       )
//     )
//   )
// )

Maps with Non-String Keys

Requires JsonKeyCodec[K] instance:

import org.sigilaris.core.codec.json.*
import java.util.UUID

case class UserPermissions(permissions: Map[UUID, String])
  derives JsonCodec
val permissions = UserPermissions(Map(
  UUID.fromString("550e8400-e29b-41d4-a716-446655440000") -> "admin",
  UUID.fromString("6ba7b810-9dad-11d1-80b4-00c04fd430c8") -> "user"
))
// permissions: UserPermissions = UserPermissions(
//   permissions = Map(
//     550e8400-e29b-41d4-a716-446655440000 -> "admin",
//     6ba7b810-9dad-11d1-80b4-00c04fd430c8 -> "user"
//   )
// )

JsonEncoder[UserPermissions].encode(permissions)
// res27: JsonValue = JObject(
//   fields = Map(
//     "permissions" -> JObject(
//       fields = Map(
//         "550e8400-e29b-41d4-a716-446655440000" -> JString(value = "admin"),
//         "6ba7b810-9dad-11d1-80b4-00c04fd430c8" -> JString(value = "user")
//       )
//     )
//   )
// )

Map keys are encoded as JSON object field names (strings).

Custom Encoders and Decoders

Using contramap (Encoder)

import org.sigilaris.core.codec.json.*
import java.time.LocalDate
import java.time.format.DateTimeFormatter

case class CustomDate(value: LocalDate)

given JsonEncoder[CustomDate] =
  JsonEncoder[String].contramap { cd =>
    cd.value.format(DateTimeFormatter.ISO_LOCAL_DATE)
  }
JsonEncoder[CustomDate].encode(CustomDate(LocalDate.of(2025, 1, 15)))
// res29: JsonValue = JString(value = "2025-01-15")

Using map and emap (Decoder)

import org.sigilaris.core.codec.json.*
import org.sigilaris.core.failure.DecodeFailure
import cats.syntax.either.*
import java.time.LocalDate
import java.time.format.DateTimeFormatter
import scala.util.Try

case class CustomDate(value: LocalDate)

given JsonDecoder[CustomDate] =
  JsonDecoder[String].emap { s =>
    Try(LocalDate.parse(s, DateTimeFormatter.ISO_LOCAL_DATE))
      .toEither
      .bimap(
        ex => DecodeFailure(s"Invalid date format: ${ex.getMessage}"),
        CustomDate(_)
      )
  }
val validDate = JsonValue.JString("2025-01-15")
// validDate: JsonValue = JString(value = "2025-01-15")
val invalidDate = JsonValue.JString("not-a-date")
// invalidDate: JsonValue = JString(value = "not-a-date")

JsonDecoder[CustomDate].decode(validDate)
// res31: Either[DecodeFailure, CustomDate] = Right(
//   value = CustomDate(value = 2025-01-15)
// )
JsonDecoder[CustomDate].decode(invalidDate).isLeft
// res32: Boolean = true

Bidirectional Custom Codec

import org.sigilaris.core.codec.json.*
import org.sigilaris.core.failure.DecodeFailure
import cats.syntax.either.*
import java.time.LocalDate
import java.time.format.DateTimeFormatter
import scala.util.Try

case class CustomDate(value: LocalDate)

object CustomDate:
  given JsonEncoder[CustomDate] with
    def encode(cd: CustomDate): JsonValue =
      JsonValue.JString(cd.value.format(DateTimeFormatter.ISO_LOCAL_DATE))

  given JsonDecoder[CustomDate] = new JsonDecoder[CustomDate]:
    def decode(json: JsonValue): Either[DecodeFailure, CustomDate] =
      json match
        case JsonValue.JString(s) =>
          Try(LocalDate.parse(s, DateTimeFormatter.ISO_LOCAL_DATE))
            .toEither
            .bimap(
              ex => DecodeFailure(s"Invalid date: ${ex.getMessage}"),
              CustomDate(_)
            )
        case _ =>
          DecodeFailure(s"Expected JString for CustomDate, got $json").asLeft

  given JsonCodec[CustomDate] = JsonCodec(summon[JsonEncoder[CustomDate]], summon[JsonDecoder[CustomDate]])
val date = CustomDate(LocalDate.of(2025, 1, 15))
// date: CustomDate = CustomDate(value = 2025-01-15)
val encoded = JsonEncoder[CustomDate].encode(date)
// encoded: JsonValue = JString(value = "2025-01-15")
JsonDecoder[CustomDate].decode(encoded)
// res34: Either[DecodeFailure, CustomDate] = Right(
//   value = CustomDate(value = 2025-01-15)
// )

Custom Discriminator Mapping

import org.sigilaris.core.codec.json.*

sealed trait Event derives JsonCodec
case class UserCreated(userId: String, username: String) extends Event
case class UserDeleted(userId: String) extends Event
case class UserUpdated(userId: String, fields: Map[String, String]) extends Event
val event1: Event = UserCreated("user-1", "alice")
// event1: Event = UserCreated(userId = "user-1", username = "alice")
val event2: Event = UserDeleted("user-2")
// event2: Event = UserDeleted(userId = "user-2")

// Default: SimpleName discriminator
JsonEncoder[Event].encode(event1)
// res36: JsonValue = JObject(
//   fields = Map(
//     "UserCreated" -> JObject(
//       fields = Map(
//         "userId" -> JString(value = "user-1"),
//         "username" -> JString(value = "alice")
//       )
//     )
//   )
// )
JsonEncoder[Event].encode(event2)
// res37: JsonValue = JObject(
//   fields = Map(
//     "UserDeleted" -> JObject(fields = Map("userId" -> JString(value = "user-2")))
//   )
// )

// Custom mapping
val customConfig = JsonConfig.default.copy(
  discriminator = DiscriminatorConfig(
    TypeNameStrategy.Custom(Map(
      "UserCreated" -> "user.created",
      "UserDeleted" -> "user.deleted",
      "UserUpdated" -> "user.updated"
    ))
  )
)
// customConfig: JsonConfig = JsonConfig(
//   fieldNaming = Identity,
//   dropNullValues = true,
//   treatAbsentAsNull = true,
//   writeBigIntAsString = true,
//   writeBigDecimalAsString = true,
//   discriminator = DiscriminatorConfig(
//     typeNameStrategy = Custom(
//       mapping = Map(
//         "UserCreated" -> "user.created",
//         "UserDeleted" -> "user.deleted",
//         "UserUpdated" -> "user.updated"
//       )
//     )
//   )
// )
// With customConfig: { "user.created": { "userId": "user-1", ... } }

Working with JsonParser and JsonPrinter

Parse JSON String

import org.sigilaris.core.codec.json.*
import org.sigilaris.core.codec.json.backend.circe.CirceJsonOps

val jsonString = """{"name":"Alice","age":30}"""
// Parse to JsonValue
val parsed = CirceJsonOps.parse(jsonString)
// parsed: Either[ParseFailure, JsonValue] = Right(
//   value = JObject(
//     fields = Map(
//       "name" -> JString(value = "Alice"),
//       "age" -> JNumber(value = 30)
//     )
//   )
// )
import org.sigilaris.core.codec.json.*
import org.sigilaris.core.codec.json.backend.circe.CirceJsonOps

val json = JsonValue.obj(
  "name" -> JsonValue.JString("Bob"),
  "age" -> JsonValue.JNumber(25)
)
// Print to JSON string
CirceJsonOps.print(json)
// res40: String = "{\"name\":\"Bob\",\"age\":25}"

Full Roundtrip

import org.sigilaris.core.codec.json.*
import org.sigilaris.core.codec.json.backend.circe.CirceJsonOps

case class Product(id: String, name: String, price: BigDecimal)
  derives JsonCodec

val product = Product("p-1", "Widget", BigDecimal("19.99"))
// Domain → JsonValue
val jsonValue = JsonEncoder[Product].encode(product)
// jsonValue: JsonValue = JObject(
//   fields = Map(
//     "id" -> JString(value = "p-1"),
//     "name" -> JString(value = "Widget"),
//     "price" -> JString(value = "19.99")
//   )
// )

// JsonValue → String (via Circe)
val jsonString = CirceJsonOps.print(jsonValue)
// jsonString: String = "{\"id\":\"p-1\",\"name\":\"Widget\",\"price\":\"19.99\"}"

// String → JsonValue (via Circe)
val reparsed = CirceJsonOps.parse(jsonString)
// reparsed: Either[ParseFailure, JsonValue] = Right(
//   value = JObject(
//     fields = Map(
//       "id" -> JString(value = "p-1"),
//       "name" -> JString(value = "Widget"),
//       "price" -> JString(value = "19.99")
//     )
//   )
// )

// JsonValue → Domain
reparsed.flatMap(JsonDecoder[Product].decode(_))
// res42: Either[SigilarisFailure, Product] = Right(
//   value = Product(id = "p-1", name = "Widget", price = 19.99)
// )

Validation with emap

import org.sigilaris.core.codec.json.*
import org.sigilaris.core.failure.DecodeFailure
import cats.syntax.either.*

case class Email(value: String)

object Email:
  given JsonEncoder[Email] with
    def encode(email: Email): JsonValue =
      JsonValue.JString(email.value)

  given JsonDecoder[Email] = new JsonDecoder[Email]:
    def decode(json: JsonValue): Either[DecodeFailure, Email] =
      json match
        case JsonValue.JString(s) =>
          if s.contains("@") && s.contains(".") then
            Email(s).asRight
          else
            DecodeFailure(s"Invalid email format: $s").asLeft
        case _ =>
          DecodeFailure(s"Expected string for Email, got $json").asLeft

  given JsonCodec[Email] = JsonCodec(summon[JsonEncoder[Email]], summon[JsonDecoder[Email]])
val validEmail = JsonValue.JString("alice@example.com")
// validEmail: JsonValue = JString(value = "alice@example.com")
val invalidEmail = JsonValue.JString("not-an-email")
// invalidEmail: JsonValue = JString(value = "not-an-email")

JsonDecoder[Email].decode(validEmail)
// res44: Either[DecodeFailure, Email] = Right(
//   value = Email(value = "alice@example.com")
// )
JsonDecoder[Email].decode(invalidEmail).isLeft
// res45: Boolean = true

Complex Example: API Response

import org.sigilaris.core.codec.json.*
import java.time.Instant

sealed trait ApiStatus derives JsonCodec
case object Success extends ApiStatus
case object Error extends ApiStatus

case class Metadata(
  requestId: String,
  timestamp: Instant,
  status: ApiStatus
) derives JsonCodec

case class UserInfo(
  id: String,
  username: String,
  email: String
) derives JsonCodec

case class ApiResponse(
  data: Option[UserInfo],
  metadata: Metadata,
  errors: List[String]
) derives JsonCodec

val response = ApiResponse(
  data = Some(UserInfo("u-1", "alice", "alice@example.com")),
  metadata = Metadata(
    requestId = "req-123",
    timestamp = Instant.parse("2025-01-15T10:30:00Z"),
    status = Success
  ),
  errors = List()
)
JsonEncoder[ApiResponse].encode(response)
// res47: JsonValue = JObject(
//   fields = Map(
//     "data" -> JObject(
//       fields = Map(
//         "id" -> JString(value = "u-1"),
//         "username" -> JString(value = "alice"),
//         "email" -> JString(value = "alice@example.com")
//       )
//     ),
//     "metadata" -> JObject(
//       fields = Map(
//         "requestId" -> JString(value = "req-123"),
//         "timestamp" -> JString(value = "2025-01-15T10:30:00Z"),
//         "status" -> JObject(fields = Map("Success" -> JObject(fields = Map())))
//       )
//     ),
//     "errors" -> JArray(values = Vector())
//   )
// )

← JSON Codec | API | Examples