2

Can someone explain me what the difference between these two approaches for typeclass instance derivation (specifically for Option[A])?

1.

trait MyTrait[A] {...}

object MyTrait extends LowPriority {
 // instances for primitives
}

trait LowPriority extends LowestPriority {
 final implicit def generic[A, H <: HList](
    implicit gen: Generic.Aux[A, H],
    h: Lazy[MyTrait[H]]
  ): MyTrait[A] = ???

  final implicit val hnil: MyTrait[HNil] = ???

  final implicit def product[H, T <: HList](
    implicit h: Lazy[MyTrait[H]],
    t: Lazy[MyTrait[T]]
  ): MyTrait[H :: T] = ???
}

// special instances for Options
trait LowestPriority {
  implicit def genericOption[A, Repr <: HList](
    implicit gen: Generic.Aux[A, Repr],
    hEncoder: Lazy[MyTrait[Option[Repr]]]
  ): MyTrait[Option[A]] = ???

  implicit val hnilOption: MyTrait[Option[HNil]] = ???

  implicit def productOption1[H, T <: HList](
    implicit
    head: Lazy[MyTrait[Option[H]]],
    tail: Lazy[MyTrait[Option[T]]],
    notOption: H <:!< Option[Z] forSome { type Z }
  ): MyTrait[Option[H :: T]] = ???

  implicit def product2[H, T <: HList](
    implicit
    head: Lazy[MyTrait[Option[H]]],
    tail: Lazy[MyTrait[Option[T]]
  ): MyTrait[Option[Option[H] :: T]] = ???
}
trait MyTrait[A] {...}

object MyTrait extends LowPriority {
 // instances for primitives
}

trait LowPriority {
// deriving instances for options from existing non-option instances
 final implicit def forOption[A](implicit instance: MyTrait[A]): MyTrait[Option[A]] = ??? // <<<----

 final implicit def generic[A, H <: HList](
    implicit gen: Generic.Aux[A, H],
    h: Lazy[MyTrait[H]]
  ): MyTrait[A] = ???

  final implicit val hnil: MyTrait[HNil] = ???

  final implicit def product[H, T <: HList](
    implicit h: Lazy[MyTrait[H]],
    t: Lazy[MyTrait[T]]
  ): MyTrait[H :: T] = ???
}

I tried both and they worked correctly, but i'm not sure that they will produce the same results for all cases (maybe i've missed something).

Do we really need LowestPriority instances for this? Am i right if i would say that the first approach gives us just a little bit more flexibility?

Nikita Ryanov
  • 1,280
  • 1
  • 12
  • 23

2 Answers2

2

Actually it's hard to say without right hand sides and actual implementations.

From information you provided it doesn't follow that the two type classes behave equivalently.

For example in the 1st approach you consider some special cases, so theoretically it's possible that you redefine some general behavior in special case differently.

By the way, Option[A] is a coproduct of Some[A] and None.type (List[A] is a coproduct of scala.::[A] and Nil.type) and sometimes it's easier to derive a type class for coproducts than for Option[A] (or List[A]).

Dmytro Mitin
  • 34,874
  • 2
  • 15
  • 47
2

I assuming that by "worked correctly" you mean "compiled" or "worked for some simple use case".

Both of your examples deal with generic product types, but not with generic sum types, so there is no risk that e.g. Option[A] could get derived using Some[A] :+: None :+: CNil, which would enforce some ambiguity. So (as far as I can tell) you could write the second version like:

trait MyTrait[A] {...}

object MyTrait extends LowPriority {
 // instances for primitives

// deriving instances for options from existing non-option instances
 final implicit def forOption[A](implicit instance: MyTrait[A]): MyTrait[Option[A]] = ??? 
}

trait LowPriority {
// <<<----
 final implicit def hcons[A, H <: HList](
    implicit gen: Generic.Aux[A, H],
    h: Lazy[MyTrait[H]]
  ): MyTrait[A] = ???

  final implicit val hnil: MyTrait[HNil] = ???

  final implicit def product[H, T <: HList](
    implicit h: Lazy[MyTrait[H]],
    t: Lazy[MyTrait[T]]
  ): MyTrait[H :: T] = ???
}

and it would derive things correctly.

But how 1. and 2. differs?

In second version you can derive MyTrait[Option[A]] if you can derive for A, and you can derive for any A which is primitive/option/product - so Option[Option[String]], Option[String] and Option[SomeCaseClass] should all work. It should also work if this SomeCaseClass contains fields which are Options, or other case classes which are Options, etc.

Version 1. is slightly different:

  • at first you are looking for primitives
  • then you try to derive for a product (so e.g. Option would not be handled here)
  • then you do something weird:
    • genericOption assumes that you created a Option[Repr], and then I guess map it using Repr
    • in order to build that Repr you take Option[HNil] and prepend types inside Option using productOption, which would break if someone used Option as a field
    • so you "fix" that by prepending an Option in a special case product2

I guess, you tested that only against case classes, because the first version would not work for:

  • Option for primitives (Option[String], Option[Int] or whatever you defined as primitive)
  • nested options (Option[Option[String]])
  • options for custom defined types which are not case classes but have manually defined instances:
    class MyCustomType
    object MyCustomType {
      implicit val myTrait: MyTrait[MyCustomType]
    }
    implicitly[Option[MyCustomType]]
    

For that reason any solution with implicit def forOption[A](implicit instance: MyTrait[A]): MyTrait[Option[A]] is simpler and more bulletproof.

Depending on what you put directly into companion low-priority implicits might be or might not be needed:

  • if you defined coproducts then manual support for e.g. Option, List, Either could conflict with shapeless derived ones
  • if you manually implemented MyTrait implicit for some type in its companion object then it would have the same priority as implicits directly in MyTrait - so if it could be derived using shapeless you could have conflicts

For that reason it makes sense to put shapeless implicits in LowPriorityImplicits but primitives, and manual codecs for List, Option, Either, etc directly in companion. That is, unless you defined some e.g. Option[String] implicits directly in companion which could clash with "Option[A] with implicit for A".

Since I don't know your exact use case I cannot tell for sure, but I would probably go with the seconds approach, or most likely with the one I implemented in the snippet above.

Mateusz Kubuszok
  • 18,063
  • 3
  • 33
  • 53
  • Thank you! About first version - first time i saw this in `doobie` project when tried to get some insights how does it work with shapeless (https://github.com/tpolecat/doobie/blob/master/modules/core/src/main/scala/doobie/util/write.scala). After that i tried to implement something similar but have a question about especially this approach because i haven't ever seen this in many shapeless's examples/tutorials – Nikita Ryanov Aug 19 '20 at 16:54
  • 1
    I think that in Doobie's case this is something desired. There, the behavior of "`Write[Option[A]]` from `Put[A]`" might be intended for primitives, while appending values in `Option[H :: T]` can use be used to distinct behavior for when you prepend `Option[H]` (where handling `null` would not require nulling whole final result) or non-`Option` `H` (where e.g. `null` in one field could result in None of whole `Repr`/`A`). So the behaviors we are expecting here are different. – Mateusz Kubuszok Aug 19 '20 at 18:42
  • 1
    So there the 3-layers make sense: if your have `Put[A]`, lift it to `Write[Option[A]]` (companion), if you have a non-optional product to handle, use derivation where `null` on any field would cause _error_ (LowerPriority), if there is `Option[A]` where `A` is product-type then null on `Option` field could be handled, but `null` on non-optional field would be `None` of a whole `A` (EvenLowerPrioroty). Putting EvenLower with the same level as Lower was probably causing conflicts for `Option` derivation. – Mateusz Kubuszok Aug 19 '20 at 18:54
  • Thank you! This was crucial for understanding – Nikita Ryanov Aug 19 '20 at 20:10
  • No problem, though it's just a wild guess without any investigation, so don't treat it as a definitive answer, more like suggestion to explore. – Mateusz Kubuszok Aug 19 '20 at 20:19