Skip to content

Instantly share code, notes, and snippets.

@aaronlevin
Last active November 29, 2020 19:11
Show Gist options
  • Save aaronlevin/797baf3aea86a9ad7f7aaccb47808c1a to your computer and use it in GitHub Desktop.
Save aaronlevin/797baf3aea86a9ad7f7aaccb47808c1a to your computer and use it in GitHub Desktop.
Writing a typeclass in Scala that crawls a shapelss Coproduct and returns a new Coproduct
/**
* Let's write a typeclass for a coproduct. The idea is we're given the name of a string, and we need to:
*
* 1. check that the string matches a value
* 2. if the string matches a value, convert that string into some type and return it
* 3. If the string doesn't match that value, try another alternative.
* 4. If no alternatives match the value, return an error.
*
* The usecase is based on something I encountered in real life: we have to parse different kind of events in
* my work's data pipeline, and the type of event (and subsequent parsing) depends on an "event type" string. I
* wanted to experiment with using shapeless Coproducts to "describe" the event types we might be parsing, and
* from that description, derive the parsing logic that will take the event type and parse the corresponding event.
*
* The "alternatives" in this design are represented by a single type: a coproduct with each element a Record field.
* The key in the record field is a singleton type indicating the event type. The type of the value in the Record field
* will correspond to the value we need to parse.
*
* In this explanation I've paired down the logic a bit. The string we use to check against the event type is also the string
* we parse into our type. So there is a dummy parser called 'FromString' that does this. A real lie implementation would be
* a little more involved.
*
* I am writing this out because getting the types to align was more challenging than I expected.
* Maybe this will be useful for you.
*/
object TypeClassCoProduct {
/**
* a dummy "parser" to extract a value from a string
*/
trait FromString[A] {
def fromString(str: String): A
}
implicit object intFromString extends FromString[Int] {
def fromString(str: String): Int = 10
}
implicit object stringFromString extends FromString[String] {
def fromString(str: String): String = str
}
/**
* our coproduct describing the coproduct of events
*/
val audioEvent = Witness("audio")
val clickEvent = Witness("click")
type Events = FieldType[audioEvent.T, String] :+: FieldType[clickEvent.T, String] :+: CNil
/**
* our typeclass that crawls a Coproduct, and at each step applys `apply`
* The idea being we test "string" against the witness in our coproduct of Record fields.
* when we find a match, we return it. If we don't find a match, we descend deeper.
*/
trait TypeCollect[A <: Coproduct] {
type Out <: Coproduct
def apply(str: String): Xor[String, Out]
}
/**
* nil instance. If we made it here, we encountered an error. Return left.
* what's nice is that CNil is uninhabitable, so the only option at this stage
* is returning Left.
*/
implicit def cnilCollect: TypeCollect[CNil] = new TypeCollect[CNil] {
type Out = CNil
def apply(str: String): Xor[String, Out] = Xor.left(str)
}
/**
* Here we hold the head of a coproduct and an instance for its tail. We also
* want an instance of FromString for our head element. Lasatly, we have a witness
* for the key name in our record, which we use to test against the value passed in.
*
* If we match the value, we return Inl. If we don't, we use the tailInstance to
* descend further into the coproduct trying to find the instance.
*
* the tricky part here is ensuring the types align, which is what `Out` is used for.
* We use `tailInstance.Out` to keep find out what happens below this instance, and
* if we need to descend deeper, we wrap the response in `Inr` to re-construct the types
* we've passed as we descend into the Coproduct.
*/
implicit def cconsCollect[Name, H, T <: Coproduct](
implicit
fsInstance: FromString[H],
tailInstance: TypeCollect[T],
witness: Witness.Aux[Name]
): TypeCollect[FieldType[Name, H] :+: T] = new TypeCollect[FieldType[Name, H] :+: T] {
type Out = H :+: tailInstance.Out
def apply(str: String): Xor[String, Out] = {
if (witness.value.toString == str) {
val h: H = fsInstance.fromString(str)
Xor.right(Inl(h))
} else {
tailInstance.apply(str).map { Inr(_) }
}
}
}
/**
* helper method
*/
def collector[A <: Coproduct](event: String)(implicit collectro: TypeCollect[A]) = collectro.apply(event)
}
/**
* usage:
scala> collector[Events]("audio")
res0: cats.data.Xor[String,event_protobuf.Records.TypeCollect[shapeless.:+:[shapeless.labelled.FieldType[event_protobuf.Records.audioEvent.T,String],shapeless.:+:[shapeless.labelled.FieldType[event_protobuf.Records.clickEvent.T,String],shapeless.CNil]]]#Out]
= Right(Inl(audio))
scala> collector[Events]("click")
res1: cats.data.Xor[String,event_protobuf.Records.TypeCollect[shapeless.:+:[shapeless.labelled.FieldType[event_protobuf.Records.audioEvent.T,String],shapeless.:+:[shapeless.labelled.FieldType[event_protobuf.Records.clickEvent.T,String],shapeless.CNil]]]#Out]
= Right(Inr(Inl(click)))
scala> collector[Events]("xxxxxxxxxxxxxxxx")
res2: cats.data.Xor[String,event_protobuf.Records.TypeCollect[shapeless.:+:[shapeless.labelled.FieldType[event_protobuf.Records.audioEvent.T,String],shapeless.:+:[shapeless.labelled.FieldType[event_protobuf.Records.clickEvent.T,String],shapeless.CNil]]]#Out]
= Left(xxxxxxxxxxxxxxxx)
*/
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment