Skip to content

Add derived for Get, Put, Meta (single-field Products) and document Scala 3 recursion pitfalls #2421

Description

@kharisovilyas

Description:

Summary

In Doobie for Scala 3, Read and Write currently support the derives syntax for Product types. However, Get, Put, and Meta lack this capability, creating an inconsistency in the API. This forces users to implement instances manually, which often leads to infinite implicit recursion loops in Scala 3 (#1854).

I propose:

  1. Implementing derived for Get, Put, and Meta specifically for single-field Product types (newtypes).
  2. Formally documenting the "Parameter Syntax" as the official solution for opaque type recursion.

1. Proposed Implementation

The implementation follows the established Platform trait pattern and ensures that Doobie's diagnostics (typeStack) are preserved by using tmap and tcontramap with TypeName.

A. Get / GetPlatform

File: modules/core/src/main/scala-3/doobie/util/GetPlatform.scala

// Add to GetPlatform trait
inline def derived[A <: Product](using m: Mirror.ProductOf[A], name: TypeName[A]): Get[A] =
  inline erasedValue[m.MirroredElemTypes] match
    case _: (inner *: EmptyTuple) =>
      val underlying = summonInline[Get[inner]]
      // tmap ensures the type name 'A' is pushed to the typeStack for JDBC diagnostics
      underlying.tmap(v => m.fromProduct(Tuple1(v)))
    case _ =>
      compiletime.error("Get derivation is only supported for single-field product types (newtypes).")

B. Put / PutPlatform

File: modules/core/src/main/scala-3/doobie/util/PutPlatform.scala (New file, to be mixed into Put object)

trait PutPlatform:
  inline def derived[A <: Product](using m: Mirror.ProductOf[A], name: TypeName[A]): Put[A] =
    inline erasedValue[m.MirroredElemTypes] match
      case _: (inner *: EmptyTuple) =>
        val underlying = summonInline[Put[inner]]
        underlying.tcontramap(a => Tuple.fromProductTyped(a).head)
      case _ =>
        compiletime.error("Put derivation is only supported for single-field product types (newtypes).")

C. Meta

File: modules/core/src/main/scala/doobie/util/meta/meta.scala

// Add to object Meta
inline def derived[A <: Product](using m: Mirror.ProductOf[A], name: TypeName[A]): Meta[A] =
  inline erasedValue[m.MirroredElemTypes] match
    case _: (inner *: EmptyTuple) =>
      val underlying = summonInline[Meta[inner]]
      underlying.timap(v => m.fromProduct(Tuple1(v)))(a => Tuple.fromProductTyped(a).head)
    case _ =>
      compiletime.error("Meta derivation is only supported for single-field product types (newtypes).")

2. Why priority traits are not needed

Unlike Read and Write, which support automatic derivation (and thus require LowestPriority to avoid shadowing user-defined instances), this proposal for Get, Put, and Meta focuses on semi-automatic derivation (explicit opt-in via derives).
Since the instance is only generated when explicitly requested, we don't need the Derived wrapper or LowestPriority indirection. If a user defines a manual given and also uses derives, Scala 3 will correctly report an ambiguity error.

3. Addressing Opaque Type Recursion (#1854)

Since opaque types do not generate a Mirror automatically, derives will not solve their recursion directly. I will contribute a section to The Book of Doobie documenting the "Parameter Syntax" as the recommended pattern to break the self-reference loop:

opaque type UserId = Int
object UserId {
  // Safe: requesting Get[Int] as an explicit parameter prevents the 
  // compiler from picking given_Get_UserId recursively.
  given (using g: Get[Int]): Get[UserId] = g.tmap(UserId(_))
}

Benefits

API Parity: Enables the clean derives Get, Put, Meta syntax, matching the UX of Read and Write.
Diagnostics: High-quality JDBC error messages are maintained because TypeName[A] is used to populate the typeStack.
Type Safety: Uses Scala 3 Tuple.fromProductTyped for safe, macro-free extraction of field values.
Simplicity: The implementation is direct and lightweight, avoiding unnecessary indirection since there is no global automatic derivation for these types.
Education: Directly resolves the long-standing confusion in #1854 by providing a clear, documented solution for opaque types.
Example usage after these changes:

case class MessageId(value: UUID) derives Get, Put, Meta

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions