Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Shapeless case study

In my application, I have a bunch of components capable of rendering Html:

class StandardComponent {
  def render: Html
}

They are instantiated at run-time from ComponentDefinition objects by a ComponentBuilder, which provides access to run-time data:

class ComponentBuilder {
  def makeComponent(componentDef: ComponentDefinition): StandardComponent
}

Then there are several helpers that facilitate rendering of sub-components within components:

def fromComponent(componentDef: ComponentDefinition)(htmlFn: Html => Future[Html])(implicit componentBuilder: ComponentBuilder): Future[Html]

def fromComponents(componentDefs: Seq[ComponentDefinition])(htmlFn: Seq[Html] => Future[Html])(implicit componentBuilder: ComponentBuilder): Future[Html]

def fromOptionalComponent(componentDefOpt: Option[ComponentDefinition])(htmlFn: Option[Html] => Future[Html])(implicit componentBuilder: ComponentBuilder): Future[Html]

def fromComponentMap[K](componentDefMap: Map[K, ComponentDefinition])(htmlFn: Map[K, Html] => Future[Html])(implicit componentBuilder: ComponentBuilder): Future[Html]

The problem is that oftentimes, a component needs to use several of these from* calls. Although they are designed to be nestable, it can turn into a bit of a mess:

implicit val componentBuilder: ComponentBuilder = ???

val subComponent: ComponentDefinition = ???
val subComponents: Seq[ComponentDefinition] = ???
val subComponentOpt: Option[ComponentDefinition] = ???

fromComponent(subComponent) { html =>
  fromComoponents(subComponents) { htmls =>
    fromOptionalComponent(subComponentOpt) { optHtml =>
      ???
    }
  }
}

What I'd like to able to do is something roughly like:

withSubComponents(
  subComponent, subComponents, subComponentOpt
) { case (html, htmls, optHtml) => /* as Html, Seq[Html], and Option[Html] */
  ???
}

So, I want to make withSubComponents variadic in its arguments, and I want to make the closure it takes in its second argument list have an argument list that depends on the first argument list in arity and type. Ideally, it also takes the implicit ComponentBuilder, like the individual helpers do. That's the ideal syntax, but I'm open to alternatives. I with I could provide some examples of what I have so far, but all I have are ideas so far. It feels like I need to make an HList of a CoProduct, and then I need the way to tie the two arguments together.

like image 490
acjay Avatar asked Jan 24 '26 07:01

acjay


1 Answers

The first step in improving the DSL can be to move the methods to an implicit conversion like this:

implicit class SubComponentEnhancements[T](subComponent: T)(
  implicit cb: ComponentBuilder[T]) {

  def fromComponent(f: cb.HtmlType => Future[Html]): Future[Html] = ???
}

Note that I declared fromComponent to be valid for each type T that has a ComponentBuilder defined. As you can see I also imagined the ComponentBuilder to have an HtmlType. In your example that would be the Seq[Html], Option[Html], etc. The ComponentBuilder now looks like this:

trait ComponentBuilder[T] {
  type HtmlType
  def render(componentDef: T): HtmlType
}

I also imagined the ComponentBuilder to be able to render the component into some type of Html. Let's declare some component builders To be able to call the fromComponent method on the different types.

object ComponentBuilder {

  implicit def single =
    new ComponentBuilder[ComponentDefinition] {
      type HtmlType = Html
      def render(componentDef: ComponentDefinition) = {
        // Create standard component from a component definition
        val standardComponent = new StandardComponent
        standardComponent.render
      }
    }

  implicit def seq[T](
    implicit cb: ComponentBuilder[T]) =
    new ComponentBuilder[Seq[T]] {
      type HtmlType = Seq[cb.HtmlType]
      def render(componentDef: Seq[T]) =
        componentDef.map(c => cb.render(c))
    }

  implicit def option[T](
    implicit cb: ComponentBuilder[T]) =
    new ComponentBuilder[Option[T]] {
      type HtmlType = Option[cb.HtmlType]
      def render(componentDef: Option[T]) =
        componentDef.map(c => cb.render(c))
    }
}

Notice that each of the component builders specifies an HtmlType that is in sync with the type of the ComponentBuilder. Builders for container types simply request a component builder for their contents. This allows us to nest different combinations without too much extra effort. We could generalize that concept even further, but for now this is fine.

As for the single component builder, you could define more generically, allowing you to have different types of component definitions. Converting them to a standard component could be done using a Converter that could be located in serveral different places (companion object of X, companion object of Converter or a separate object that users need to import manually).

trait Converter[X] {
  def convert(c:X):StandardComponent
}

object ComponentDefinition {
  implicit val defaultConverter =
    new Converter[ComponentDefinition] {
      def convert(c: ComponentDefinition):StandardComponent = ???
    }
}

implicit def single[X](implicit converter: Converter[X]) =
  new ComponentBuilder[X] {
    type HtmlType = Html
    def render(componentDef: X) =
      converter.convert(componentDef).render
  }

Anyway, the code now looks like the following:

subComponent fromComponent { html =>
  subComponents fromComponent { htmls =>
    subComponentOpt fromComponent { optHtml =>
      ???
    }
  }
}

This looks like a familiar pattern, let's rename the methods:

subComponent flatMap { html =>
   subComponents flatMap { htmls =>
     subComponentOpt map { optHtml =>
       ???
     }
   }
 }

Note that we are in the wishful thinking space, the above code will not compile. If we had some way of making it compile we could however write something like the following:

for {
  html <- subComponent
  htmls <- subComponents
  optHtml <- subComponentOpt
} yield ???

That looks pretty amazing to me, unfortunately Option and Seq have a flatMap function themselves, so we need to hide those. The following code looks clean and gives us the oportunity to hide the flatMap and map methods.

trait Wrapper[+A] {
  def map[B](f:A => B):Wrapper[B]
  def flatMap[B](f:A => Wrapper[B]):Wrapper[B]
}

implicit class HtmlEnhancement[T](subComponent:T) {
  def html:Wrapper[T] = ???
}

for {
  html <- subComponent.html
  htmls <- subComponents.html
  optHtml <- subComponentOpt.html
} yield ???

As you can see we are still in wishful thinking space, let's see if we can fill in the blanks.

case class Wrapper[+A](value: A) {
  def map[B](f: A => B) = Wrapper(f(value))
  def flatMap[B](f: A => Wrapper[B]) = f(value)
}

implicit class HtmlEnhancement[T](subComponent: T)(
  implicit val cb: ComponentBuilder[T]) {

  def html: Wrapper[cb.HtmlType] = Wrapper(cb.render(subComponent))
}

The implementation is not that complicated because we can use the tools we created earlier. Note that during wishful thinking I returned a Wrapper[T] while we actually needed the html, so I am now using the HtmlType from the component builder.

To improve type inference, we will change the ComponentBuilder slightly. We will change the HtmlType type member to a type parameter.

trait ComponentBuilder[T, R] {
  def render(componentDef: T): R
}

implicit class HtmlEnhancement[T, R](subComponent: T)(
  implicit val cb: ComponentBuilder[T, R]) {

  def html:Wrapper[R] = Wrapper(cb.render(subComponent))
}

The different builders need a change as well

object ComponentBuilder {

  implicit def single[X](implicit converter: Converter[X]) =
    new ComponentBuilder[X, Html] {
      def render(componentDef: X) =
        converter.convert(componentDef).render
    }

  implicit def seq[T, R](
    implicit cb: ComponentBuilder[T, R]) =
    new ComponentBuilder[Seq[T], Seq[R]] {
      def render(componentDef: Seq[T]) =
        componentDef.map(c => cb.render(c))
    }

  implicit def option[T, R](
    implicit cb: ComponentBuilder[T, R]) =
    new ComponentBuilder[Option[T], Option[R]] {
      def render(componentDef: Option[T]) =
        componentDef.map(c => cb.render(c))
    }
}

The end result now looks like this:

val wrappedHtml =
  for {
    html <- subComponent.html
    htmls <- subComponents.html
    optHtml <- subComponentOpt.html
  } yield {
    // Do some interesting stuff with the html
    htmls ++ optHtml.toSeq :+ html
  }

// type of `result` is `Seq[Html]`
val result = wrappedHtml.value
// or
val Wrapper(result) = wrappedHtml

As you might have noticed, I skipped the Future, you can add that yourself as you please.

I am not sure if this is how you envisioned your DSL, but it at least gives you some tools to create a really cool one.

like image 185
EECOLOR Avatar answered Jan 25 '26 22:01

EECOLOR



Donate For Us

If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!