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
Option
of 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.wishingtree.branch.friday.JsonDecoder.given
Auto derivation is supported for Product
types (case classes):
case class Person(name: String, age: Int) derives JsonDecoder
// Usage
val personJson = """{"name": "Mark", "age": 42}"""
val person: Try[Person] = Json.decode[Person](personJson)
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.wishingtree.branch.friday.JsonEncoder.given
Auto derivation works the same as with decoders:
case class Person(name: String, age: Int) derives JsonEncoder
// Usage
val person = Person("Mark", 42)
val json: Json = person.toJson // Using extension method
// or
val json: Json = Json.encode(person) // Using companion object
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 case classes:
case class Person(name: String, age: Int) derives JsonCodec
- 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)
Other Libraries
If you like Friday, you should check out uPickle for a more comprehensive JSON library.