4

I have some data that consists of three fields, let's say a String, an Int, and a Double.

They are all optional, but the whole thing must contain at least one of those fields.

I've tried cats.data.Ior, like type MyType = String Ior Int Ior Double. It works, but it feels a little bit clumsy when actually doing stuffs with it. Creating a case class with three Options and throwing an exception at the constructor if they are all None should kinda work too, but I don't like the idea.

I'm curious to know if there might be some other way to do this (preferably, by encoding the whole information only in the types like Ior but with less complexity so that using values of the type is not as complicated). Suggestions to use any other libraries are also welcome.

Haemin Yoo
  • 474
  • 5
  • 11
  • 2
    I think you should be ok using a class with the 3 optional values, you can declare the constructor as private and define a method for creating the instance that validates the condition, it could return `Option[YourClass]`, `Either[Error, YourClass]` or what you are using in your code base. – AlexITC Dec 04 '17 at 05:42
  • I think you are right. I'm still curious to know if there is a more natural way (if that's a thing) to encode this using only types. – Haemin Yoo Dec 04 '17 at 05:46
  • I think either using `cats.Ior` or rolling your 7 class `trait` would be the theoretically sound approach. The destructor in `cats.Ior` is a three-way `fold`, resulting in a 7-way `fold` for the three way type. This feels awkward. What do you plan to do with these values? Is there some kind of natural precedence between the three fields? Answering this question would make it easier to decide upon the "right" encoding. – ziggystar Dec 05 '17 at 08:30
  • The String/Int/Double thing was just an example, it is actually a String and two case classes. My use case is also quite simple, I only need to gather the pieces from different sources and serialize it to Json to send it over the network. The data are all unrelated to each other. – Haemin Yoo Dec 05 '17 at 11:24

1 Answers1

1

You probably thought of this idea as well, but what about something like this:

class StringOrIntOrDouble private(val first: Option[String], val second: Option[Int], val third: Option[Double]) {
  def this(first: String) = this(Some(first), None, None)

  def this(second: Int) = this(None, Some(second), None)

  def this(third: Double) = this(None, None, Some(third))

  def this(first: String, second: Int) = this(Some(first), Some(second), None)

  def this(second: Int, third: Double) = this(None, Some(second), Some(third))

  def this(first: String, third: Double) = this(Some(first), None, Some(third))

  def this(first: String, second: Int, third: Double) = this(Some(first), Some(second), Some(third))

}

The main constructor is private so you can't create an empty instance. Obviously you could extend it with implementation of Product and equals, hashCode, toString and other useful things as case classes do (but beware of copy that might easily break the invariant).

Unfortunately, if you want a generic version (or you have the same type in two places) you have to move all the "subset" constructors to a named methods to a companion object or it would not compile because of type erasure.

class TripleIor[+A, +B, +C] private(val first: Option[A], val second: Option[B], val third: Option[C]) {

}

object TripleIor {
  def first[A](first: A) = new TripleIor[A, Nothing, Nothing](Some(first), None, None)

  def second[B](second: B) = new TripleIor[Nothing, B, Nothing](None, Some(second), None)

  def third[C](third: C) = new TripleIor[Nothing, Nothing, C](None, None, Some(third))

  def firstAndSecond[A, B](first: A, second: B) = new TripleIor[A, B, Nothing](Some(first), Some(second), None)

  def secondAndThird[B, C](second: B, third: C) = new TripleIor[Nothing, B, C](None, Some(second), Some(third))

  def firstAndThird[A, C](first: A, third: C) = new TripleIor[A, Nothing, C](Some(first), None, Some(third))

  def apply[A, B, C](first: A, second: B, third: C) = new TripleIor[A, B, C](Some(first), Some(second), Some(third))
}

A more radical way would be to implement the same idea as a sealed trait and 7 subclasses but I don't think they would be much easier to use. Also it will let you to implement copy in a type-safe way but a the cost of a lot of typing (not shown here).

sealed trait TripleIor[A, B, C] extends Product {
  def firstOption: Option[A]

  def secondOption: Option[B]

  def thirdOption: Option[C]
}

object TripleIor {

  final case class First[A](first: A) extends TripleIor[A, Nothing, Nothing] {
    override def firstOption: Option[A] = Some(first)

    override def secondOption: Option[Nothing] = None

    override def thirdOption: Option[Nothing] = None
  }

  final case class Second[B](second: B) extends TripleIor[Nothing, B, Nothing] {
    override def firstOption: Option[Nothing] = None

    override def secondOption: Option[B] = Some(second)

    override def thirdOption: Option[Nothing] = None
  }

  final case class Third[C](third: C) extends TripleIor[Nothing, Nothing, C] {
    override def firstOption: Option[Nothing] = None

    override def secondOption: Option[Nothing] = None

    override def thirdOption: Option[C] = Some(third)
  }

  final case class FirstSecond[A, B](first: A, second: B) extends TripleIor[A, B, Nothing] {
    override def firstOption: Option[A] = Some(first)

    override def secondOption: Option[B] = Some(second)

    override def thirdOption: Option[Nothing] = None
  }

  final case class SecondThird[B, C](second: B, third: C) extends TripleIor[Nothing, B, C] {
    override def firstOption: Option[Nothing] = None

    override def secondOption: Option[B] = Some(second)

    override def thirdOption: Option[C] = Some(third)
  }

  final case class FirstThird[A, C](first: A, third: C) extends TripleIor[A, Nothing, C] {
    override def firstOption: Option[A] = Some(first)

    override def secondOption: Option[Nothing] = None

    override def thirdOption: Option[C] = Some(third)
  }

  final case class All[A, B, C](first: A, second: B, third: C) extends TripleIor[A, B, C] {
    override def firstOption: Option[A] = Some(first)

    override def secondOption: Option[B] = Some(second)

    override def thirdOption: Option[C] = Some(third)
  }
}

P.S. Beware that all examples here are just sketches to illustrate the idea that don't implement many useful or even required things

SergGr
  • 23,570
  • 2
  • 30
  • 51
  • @yhm, you are right. If you want specific classes for given types - that approach would work. If it has to be generic, then because of type erasure "subset" constructors must be named differently and thus moved to the companion object. This makes code uglier and makes sealed trait approach more compelling. – SergGr Dec 04 '17 at 06:06
  • In this case I have right now, I only needed a specific class, so this approach seems to be great. – Haemin Yoo Dec 04 '17 at 06:09
  • 1
    @yhm, OK so I returned back the type-specific case and also added a sketch for the sealed trait which actually might be easier to implement full-blown generics solution. – SergGr Dec 04 '17 at 06:18