How to narrow the type of an upper-bounded type parameter in a state machine encoding?

Viewed 87

Say I have a Cake that can cycle through a number of states:

sealed trait State extends Product with Serializable
object State {
    final case object Raw extends State
    final case class JustRight(temperature: Int) extends State
    final case class Burnt(charCoalContent: Double) extends State
}
final case class Cake[S <: State](name: String, state: S)

This is nice, because now I can make sure that I only try to put Raw cakes into the oven, instead of eating them right away.

But sometimes I just have a Cake[State] lying around and want to try to eat it, but only if it's in an edible state. I could of course always pattern match on cake.state, but I thought it should be possible to save myself a few keystrokes by adding a method def narrow[S <: State]: Cake[State] => Option[Cake[S]].

However, now I'm struggling to actually implement that function. The compiler accepts Try(cake.asInstanceOf[Cake[S]]).toOption, but it seems that would always succeed (I guess because the type parameter is erased, and actually any type A would be accepted here, not just S). What seems to work is Try(cake.copy(state = cake.state.asInstanceOf[S])).toOption, but now I've made a superfluous copy of my data. Is there another better way? Or is that whole encoding maybe flawed from the get-go?

2 Answers

You can solve this problem using a typeclass that checks and casts (in a typesafe way) the type of the state.

sealed trait State extends Product with Serializable
object State {
    final case object Raw extends State
    type Raw = Raw.type
    final case class JustRight(temperature: Int) extends State
    final case class Burnt(charCoalContent: Double) extends State
  
    sealed trait Checker[S <: State] {
      def check(state: State): Option[S]
    }
    object Checker {
      private def instance[S <: State](pf: PartialFunction[State, S]): Checker[S] =
        new Checker[S] {
          val f = pf.lift
          override def check(state: State): Option[S] = f(state)
        }
      
      implicit final val RawChecker: Checker[Raw] = instance {
        case Raw => Raw
      }
      
      implicit final val JustRightChecker: Checker[JustRight] = instance {
        case s @ JustRight(_) => s
      }
      
      implicit final val BurntChecker: Checker[Burnt] = instance {
        case s @ Burnt(_) => s
      }
    }
}

final case class Cake[S <: State](name: String, state: S)

def narrow[S <: State](cake: Cake[State])(implicit checker: State.Checker[S]): Option[Cake[S]] =
  checker.check(cake.state).map(s => cake.copy(state = s))

Which you can use like this:

val rawCake: Cake[State] = Cake(name = "Foo", state = State.Raw)

narrow[State.Raw](rawCake)
// res: Option[Cake[State.Raw]] = Some(Cake(Foo,Raw))
narrow[State.JustRight](rawCake)
// res: Option[Cake[State.JustRight] = None

BTW if you want to avoid the copy, you may change check to just return Boolean and use a dirty asInstanceOf.

// Technically speaking it is unsafe, but it seems to work just right.
def narrowUnsafe[S <: State](cake: Cake[State])(implicit checker: State.Checker[S]): Option[Cake[S]] =
  if (checker.check(cake.state)) Some(cake.asInstanceOf[Cake[S]])
  else None

(You can see the code running here)

If you don't know the specific type of your Cake's state at compile time, you might want to use ClassTag to check the type of your state instead, because throwing and catching ClassCastExceptions doesn't seem like a good idea to me:

def narrow[S <: State](cake: Cake[_])(implicit classTag: ClassTag[S]): Option[Cake[S]] =
  Option.when(classTag.runtimeClass.isInstance(cake.state))(cake.asInstanceOf[Cake[S]])

Scastie

This works by checking if the cake's state is an instance of the erased class of S. If your states take type parameters, however, you may want to use TypeTag instead.

However, if you do know the specific type, you might want to use =:= <:< to check (<:< rejects Cake[State]):

def narrow[S <: State] = new NarrowFn[S]

class NarrowFn[S <: State] {
  def apply[S2 <: State](cake: Cake[S2])(implicit ev: S2 <:< S = null): Option[Cake[S]] =
    Option.when(ev != null)(cake.asInstanceOf[Cake[S]])
}

Scastie

Note that neither of these are great solutions, and I'd suggest just making a separate method for each case and use plain pattern matching to get your answer.

Related