Scala 3 FAQ: How does 'derives' work? (Notes on type classes, derivation macros, and compile-time derivation)

Scala 3 FAQ: How does this ZIO HTTP and ZIO JSON code works, specifically the derives JsonEncoder portion of the code:

case class Greeting(message: String) derives JsonEncoder

Solution

In Scala 3, the derives keyword is used to automatically generate implementations for type classes. Here’s how this works in the context of ZIO JSON.

Code explanation

Given this code:

case class Greeting(message: String) derives JsonEncoder
  1. Case class definition: case class Greeting(message: String) defines a case class called Greeting with a single field, message, of type String.

  2. Type class derivation with derives: The derives JsonEncoder part tells Scala to automatically derive an implementation of the JsonEncoder type class for the Greeting class.

    • JsonEncoder: This is a type class provided by ZIO JSON that enables an instance of Greeting to be converted to JSON. When you derive JsonEncoder, Scala will automatically create a JsonEncoder instance for the Greeting case class, so you don’t need to write any manual encoding logic.
  3. How it works: When you use derives JsonEncoder, Scala automatically generates code that conforms to the JsonEncoder interface based on the structure of the Greeting class. This means it will create a JSON representation of a Greeting instance, mapping the message field to a JSON key-value pair like { "message": "Hello!" }.

How derivation works internally

The derives keyword leverages Scala 3's new metaprogramming capabilities. Libraries like ZIO JSON provide derivation macros that inspect the structure of the case class and produce a JsonEncoder implementation without you needing to write boilerplate code. For example, in ZIO JSON:

  • The library includes a JsonEncoder implementation that uses macros to automatically generate an encoder for case classes, based on the fields in the class.

More details on derives, type classes, and macros

derives is a Scala 3 feature that automatically generates type class instances — in this case, a JsonEncoder — using compile-time derivation. It’s similar to older approaches like @deriveJsonEncoder annotations in Scala 2, but with cleaner syntax.

Here’s how it works:

  1. When you write derives JsonEncoder, the compiler will automatically generate the code needed to convert a Greeting instance to JSON.

  2. Behind the scenes, it creates something roughly equivalent to:

case class Greeting(message: String)

object Greeting {
    implicit val jsonEncoder: JsonEncoder[Greeting] = new JsonEncoder[Greeting] {
        def encode(greeting: Greeting): Json = 
            Json.Obj("message" -> Json.Str(greeting.message))
    }
}

Other

You can also derive multiple type classes at once:

case class Greeting(message: String) derives JsonEncoder, JsonDecoder

This is particularly useful when you need both encoding (to JSON) and decoding (from JSON).

Benefits

The main benefits of using derives are:

  • Less boilerplate code
  • Automatic updates if you change the case class fields
  • Compile-time generation (better performance than runtime reflection)

Bonus: derives compared to using “given” instances

As an additional note, this code:

case class ToDo(
    id: String,
    task: String,
    completed: Boolean
) derives JsonEncoder, JsonDecoder

is the equivalent of this much-longer code:

case class ToDo(
    id: String,
    task: String,
    completed: Boolean
)

object ToDo:
    given JsonEncoder[ToDo] = DeriveJsonEncoder.gen[ToDo]
    given JsonDecoder[ToDo] = DeriveJsonDecoder.gen[ToDo]