Last active
November 29, 2020 19:11
-
-
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
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* 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