Friday
Friday is built off of a parser that is the topic of a chapter in Function Programming in Scala (2nd Ed) of parser combinators.
The library provides an AST to convert JSON to/from, as well as type-classes for Encoders, Decoders, and Codecs.
There is also an emphasis on Json AST helper methods to easily work with JSON, without having to convert to/from an explicit schema.
Working with the AST
The Json AST is described fully as
enum Json {
case JsonNull
case JsonBool(value: Boolean)
case JsonNumber(value: Double)
case JsonString(value: String)
case JsonArray(value: IndexedSeq[Json])
case JsonObject(value: Map[String, Json])
}
A json string can be parsed to Json, using the parse method on the Json companion object.
def parse(json: String): Either[ParseError, Json]
Accessing Values
There are two ways to access values from a Json instance:
- Direct Access (Unsafe) - These methods will throw an exception if the value is not of the expected type:
strVal- Get the underlying StringnumVal- Get the underlying DoubleboolVal- Get the underlying BooleanarrVal- Get the underlying IndexedSeq[Json]objVal- Get the underlying Map[String, Json]
For example:
JsonString("Some Str").strVal // Works fine
JsonString("Some Str").numVal // Throws exception
- Safe Access - These methods return an
Optionof the underlying value:strOpt- Try to get StringnumOpt- Try to get DoubleboolOpt- Try to get BooleanarrOpt- Try to get IndexedSeq[Json]objOpt- Try to get Map[String, Json]
Working with Objects
To quickly parse through possible sub-fields on a JsonObject, there is a ? extension method on both Json and
Option[Json] that takes a field name as an argument:
def ?(field: String): Option[Json]
This allows for safe traversal of nested JSON structures. For example:
{
"name": "Branch",
"some": {
"nested": {
"key": "value"
}
}
}
We can safely traverse this structure:
val js: Json = ???
val maybeName: Option[Json] = js ? "name" // Some(JsonString("Branch"))
val deepField: Option[Json] = js ? "some" ? "nested" ? "key" // Some(JsonString("value"))
// Missing fields return None without throwing exceptions
val probablyNot: Option[Json] = js ? "totally" ? "not" ? "there" // None
Type Classes
Friday provides three main type classes for working with JSON:
JsonEncoder[A]- For converting Scala types to JSONJsonDecoder[A]- For converting JSON to Scala typesJsonCodec[A]- Combines both encoding and decoding capabilities
Decoders
A JsonDecoder[A] converts Json to type A by implementing:
def decode(json: Json): Try[A]
For example:
given JsonDecoder[String] with {
def decode(json: Json): Try[String] =
Try(json.strVal)
}
Common decoders are provided and can be imported with:
import dev.alteration.branch.friday.JsonDecoder.given
Auto derivation is supported for both Product types (case classes) and Sum types (enums/sealed traits):
// Product type
case class Person(name: String, age: Int) derives JsonDecoder
// Sum type
enum Status derives JsonDecoder {
case Active
case Inactive
case Pending
}
// Usage
val personJson = """{"name": "Mark", "age": 42}"""
val person: Try[Person] = Json.decode[Person](personJson)
val statusJson = "\"Active\""
val status: Try[Status] = Json.decode[Status](statusJson)
Encoders
A JsonEncoder[A] converts type A to Json by implementing:
def encode(a: A): Json
For example:
given JsonEncoder[String] with {
def encode(a: String): Json = Json.JsonString(a)
}
Common encoders are provided and can be imported with:
import dev.alteration.branch.friday.JsonEncoder.given
Auto derivation works the same as with decoders for both Product and Sum types:
// Product type
case class Person(name: String, age: Int) derives JsonEncoder
// Sum type
enum Status derives JsonEncoder {
case Active
case Inactive
case Pending
}
// Usage
val person = Person("Mark", 42)
val json: Json = person.toJson // Using extension method
// or
val json: Json = Json.encode(person) // Using companion object
val status = Status.Active
val statusJson: Json = status.toJson // JsonString("Active")
Codecs
When you need both encoding and decoding, use JsonCodec[A]:
trait JsonCodec[A] { self =>
given encoder: JsonEncoder[A]
given decoder: JsonDecoder[A]
def encode(a: A): Json = encoder.encode(a)
def decode(json: Json): Try[A] = decoder.decode(json)
}
Codecs can be created in several ways:
- Auto derivation for Product types (case classes) and Sum types (enums/sealed traits):
// Product type
case class Person(name: String, age: Int) derives JsonCodec
// Sum type
enum Status derives JsonCodec {
case Active
case Inactive
case Pending
}
- Combining existing encoder and decoder from the companion object JsonCodec.apply:
val codec: JsonCodec[Person] = JsonCodec[Person] // If encoder and decoder are in scope
- From explicit encode/decode functions:
val codec = JsonCodec.from[Person](
decode = json => Try(/* decode logic */),
encode = person => /* encode logic */
)
- Transforming existing codecs:
// Transform a String codec into an Instant codec
val instantCodec: JsonCodec[Instant] = JsonCodec[String].transform(
Instant.parse // String => Instant
)(_.toString) // Instant => String
The codec provides extension methods for convenient usage:
// Encoding
person.toJson // Convert to Json
person.toJsonString // Convert directly to JSON string
// Decoding
json.decodeAs[Person] // Json => Try[Person]
jsonString.decodeAs[Person] // String => Try[Person]
You can also transform codecs to work with different types while preserving type safety:
// Transform with bimap
val longCodec: JsonCodec[Long] = JsonCodec[String].bimap(_.toLong)(_.toString)
// Transform with map
val intCodec: JsonCodec[Int] = JsonCodec[Long].map(_.toInt)(_.toLong)
Working with Sum Types
Sum types (enums and sealed traits) are fully supported through auto derivation. This includes both simple enums and parameterized cases:
// Simple enum
enum Color derives JsonCodec {
case Red
case Green
case Blue
}
// Enum with parameterized cases
enum Shape derives JsonCodec {
case Circle(radius: Double)
case Rectangle(width: Double, height: Double)
case Point
}
// Usage
val red = Color.Red
val redJson = red.toJson // JsonString("Red")
val circle = Shape.Circle(5.0)
val circleJson = circle.toJson // JsonObject(Map("Circle" -> JsonObject(Map("radius" -> JsonNumber(5.0)))))
val shapeStr = """{"Circle":{"radius":5.0}}"""
val decoded: Try[Shape] = shapeStr.decodeAs[Shape] // Success(Circle(5.0))
Sum types can also be combined with product types in complex nested structures:
case class User(name: String, status: Status) derives JsonCodec
enum Status derives JsonCodec {
case Active
case Suspended(reason: String)
case Deleted
}
val user = User("Alice", Status.Suspended("Payment overdue"))
val userJson = user.toJsonString
// {"name":"Alice","status":{"Suspended":{"reason":"Payment overdue"}}}
Other Libraries
If you like Friday, you should check out uPickle for a more comprehensive JSON library.